Des statistiques sans robots avec GoatCounter auto-hébergé sur NixOS

Vincent Bernat

En 2016, j’ai re­tiré Google Ana­ly­tics de ce blog pour ne pas me rendre com­plice de la plus grande ma­chine à as­pi­rer les don­nées per­son­nelles. À la place, j’ana­ly­sais les jour­naux de mes ser­veurs1 avec GoAccess. De­puis quelques an­nées, les sta­tis­tiques ne veulent plus rien dire, mal­gré mes ten­ta­tives pour fil­trer les ro­bots : les ro­bots pilleurs des IA gonflent le nombre de vi­si­teurs à en­vi­ron 2 000 par jour. J’ai fini par adop­ter GoatCounter, une pla­te­forme libre de me­sure d’au­dience, res­pec­tueuse de la vie pri­vée. J’ai rem­placé le client JavaScript pour fil­trer les ro­bots de fa­çon plus agres­sive et ajouté une so­lu­tion de re­pli en CSS. Pour ga­gner en fia­bi­lité, j’ai écrit un proxy lo­cal, ins­tallé sur cha­cun des cinq ser­veurs web qui hé­bergent ce blog. La suite de cet ar­ticle dé­taille com­ment ces élé­ments s’ar­ti­culent et com­ment je dé­ploie l’en­semble sur NixOS. ❄️

Pourquoi GoatCounter ?#

GoatCounter ne col­lecte au­cune don­née per­son­nelle : au lieu de sto­cker l’adresse IP du lec­teur ou de s’ap­puyer sur des co­okies, il crée un iden­ti­fiant de ses­sion va­lide 8 heures à par­tir de l’agent uti­li­sa­teur et de l’adresse IP. Ses fonc­tion­na­li­tés sont mo­destes, mais suf­fi­santes pour un blog. Pour un aperçu de l’in­ter­face, l’au­teur de GoatCounter pro­pose une ins­tance pu­blique pour son site. Une ver­sion en ligne per­met de l’es­sayer avant de l’hé­ber­ger soi-même. Avec un seul bi­naire et une base de don­nées SQLite, GoatCounter est l’une des so­lu­tions auto-hé­ber­gées les plus lé­gères. Parmi les al­ter­na­tives res­pec­tueuses de la vie pri­vée fi­gurent, par ordre crois­sant de com­plexité, Umami, Plau­sible et Rybbit.

Tableau de bord de GoatCounter montrant les statistiques de mon blog sur la
semaine passée, dont l'article sur le protocole Spanning Tree avec 1 224 vues,
ainsi que les statistiques sur les navigateurs (52 % pour Chrome, 32 % pour
Firefox)

Client JavaScript sur mesure#

GoatCounter four­nit un pe­tit client JavaScript : 2 189 oc­tets une fois mi­ni­fié et com­pressé avec gzip. Il em­barque des fonc­tion­na­li­tés dont je ne me sers pas : un comp­teur de vi­sites, le suivi des clics, di­vers ré­glages, etc. Je le rem­place par cette fonc­tion pour en­re­gis­trer une visite :

const count = ({ event, title } = {}) => {
  const params = new URLSearchParams({
    p: event || location.pathname,
    t: title || document.title,
    r: document.referrer,
    q: location.search,
    s: document.documentElement.clientWidth,
    e: !!event,
    rnd: Math.random().toString(36).slice(2, 7),
  });
  fetch(`/count?${params}`, { keepalive: true }).catch(() => {});
};

Pour fil­trer les ro­bots2, je vais plus loin en exi­geant une in­ter­ac­tion de l’uti­li­sa­teur, une idée re­prise de Bear Blog.

let sendHit = () => (sendHit = () => {}, count());
["touchmove", "mousemove", "keydown", "pointerdown"].forEach((eventName) =>
  document.addEventListener(eventName, sendHit, {
    once: true,
    passive: true,
  }),
);

Si un lec­teur a désac­tivé JavaScript dans son na­vi­ga­teur, j’en­re­gistre la vi­site à l’aide d’une image CSS. La pseudo-classe :hover ne la charge qu’après une in­ter­ac­tion, une autre as­tuce pi­quée à Bear Blog. En­vi­ron 2 % de mes vi­si­teurs entrent dans cette ca­tégorie3.

<!DOCTYPE html>
<html lang="en" class="nojs">
  <head>
    <script>
      // Le code JavaScript de ce blog requiert ES6
      if ("noModule" in HTMLScriptElement.prototype)
        document.documentElement.classList.remove("nojs");
    </script>
  </head>
  <body>
  <!-- ... -->
    <style>
      .nojs body:hover {
        border-width: 0;
        border-image: url('/count?p=/en/blog/2026-kpi-goodhart&t=Building...&r=NoJS&e=false');
      }
    </style>
  </body>
</html>

Là où GoAccess an­non­çait en­vi­ron 2 000 vi­si­teurs par jour, GoatCounter compte moins de 200 hu­mains4. Je sup­pose que ces pilleurs pri­vi­lé­gient le moindre ef­fort : si le contenu est ac­ces­sible sans obs­tacle, comme sur ce blog, ils ne vont pas lan­cer une usine à gaz qui dé­clen­che­rait une page vue. Même les ro­bots qui exé­cutent JavaScript, comme Goo­gle­bot avec son Chro­mium sans in­ter­face, n’in­ter­agissent pas avec la page et ne dé­clenchent ja­mais les évé­ne­ments que j’écoute. La « preuve d’hu­ma­nité » par l’in­ter­ac­tion que j’uti­lise me semble pou­voir durer5.

Mise à jour (11.2026)

J’ai ajouté un filtre sup­plé­men­taire pour le comp­teur “NoJS” afin d’ex­clure les ro­bots qui ré­cu­pèrent l’URL de la page. Le code sui­vant vé­ri­fie si le pa­ra­mètre r de la re­quête vaut NoJS et si la va­leur de l’en-tête Sec-Fetch-Dest n’est pas image. Dans ce cas, Caddy ren­voie une ré­ponse 204 vide. Si­non, il trans­met la re­quête à GoatCounter.

route /count {
  log_skip
  @nojsbot {
    query r=NoJS
    not header Sec-Fetch-Dest image
  }
  respond @nojsbot 204
  reverse_proxy 127.0.0.3:8087 {
    # …
  }
}

Proxy local#

Cinq ser­veurs à tra­vers le monde en Eu­rope et en Amé­rique du Nord servent le contenu de ce site, mais GoatCounter ne tourne que sur l’un d’eux. Pour ne pas perdre la trace des vi­si­teurs quand GoatCounter est in­dis­po­nible, j’exé­cute un proxy lo­cal qui écoute sur le même point de ter­mi­nai­son /count. Sur chaque ser­veur, il conserve les vi­sites en mé­moire, avec un tam­pon as­sez grand pour te­nir plu­sieurs jours en cas d’in­dis­po­ni­bi­lité. Il les en­voie par lots au ser­veur amont via le point de ter­mi­nai­son au­then­ti­fié /api/v0/count.

🖼 Serveurs sur une carte. web02 est à Paris, web03 à Helsinki, web04 à Nuremberg, web05 à Ashburn, web06 à Chicago.

J’ai pro­posé le code du proxy dans la pull re­quest #909. Le main­te­neur de GoatCounter a re­fusé de main­te­nir cet amas de code pour un cas d’usage aussi niche. Dé­ve­lop­pant moi aussi des lo­gi­ciels libres, j’adopte sou­vent la même po­si­tion pour mes propres pro­jets : l’ef­fort ponc­tuel d’un contri­bu­teur peut se trans­for­mer en en­ga­ge­ment sur le long terme pour le mainteneur.

J’ex­pose le point de ter­mi­nai­son du proxy sur le do­maine de ce site pour échap­per aux blo­queurs de pu­bli­cité. Cela donne l’im­pres­sion que je ne res­pecte pas le choix du lec­teur, mais comme GoatCounter res­pecte la vie pri­vée, je trouve cela ac­ceptable.

location = /count {
  access_log off;
  proxy_pass http://127.0.0.3:8087/count;
  proxy_pass_request_headers off;
  proxy_set_header Accept-Language $http_accept_language;
  proxy_set_header User-Agent $http_user_agent;
  proxy_set_header X-Real-Ip $remote_addr;
}

Déploiement sur NixOS#

Mes ser­veurs web tournent sous NixOS, une dis­tri­bu­tion Li­nux dé­cla­ra­tive avec une ges­tion de confi­gu­ra­tion in­té­grée. Je gère ce pe­tit trou­peau avec Col­mena, un ou­til de dé­ploie­ment sans état pour NixOS. Ma confi­gu­ra­tion est dis­po­nible sur GitHub.

Déployer des applications dans des conteneurs#

Pour une meilleure iso­la­tion, chaque ap­pli­ca­tion tourne dans un conte­neur lé­ger et éphé­mère, pro­pulsé par sys­temd-ns­pawn. Chaque conte­neur exé­cute une ins­tance al­lé­gée de NixOS. Un mo­dule en­cap­sule les op­tions containers de NixOS pour évi­ter de ré­pé­ter les mêmes op­tions pour chaque ap­pli­ca­tion6. Les conte­neurs par­tagent la pile ré­seau de l’hôte : l’iso­la­tion sup­plé­men­taire ne vaut pas la com­plexité ac­crue. Pour les al­lé­ger, je désac­tive aussi quelques ser­vices non es­sentiels.

{ config, lib, ... }:
let
  cfg = config.luffy.containers;
in
{
  # Réglages configurables pour notre module
  options.luffy.containers = lib.mkOption {
    default = { };
    description = "Ephemeral containers sharing the host network.";
    type = lib.types.attrsOf (lib.types.submodule {
      options = {
        config = lib.mkOption {
          type = lib.types.deferredModule;
          default = { };
          description = "NixOS configuration of the container.";
        };
      };
    });
  };

  # Traduit nos options en conteneurs NixOS
  config = {
    containers = lib.mapAttrs
      (name: container: {
        ephemeral = true;
        autoStart = true;
        privateNetwork = false;
        extraFlags = [ "--resolv-conf=replace-host" ];
        config = {
          imports = [ container.config ];
          networking.firewall.enable = false;
          system.stateVersion = config.system.stateVersion;
          systemd.services = {
            console-getty.enable = false;
            systemd-logind.enable = false;
            systemd-oomd.enable = false;
          };
        };
      })
      cfg;
  };
}

Pour confi­gu­rer une ins­tance GoatCounter tour­nant dans un conte­neur et écou­tant sur 127.0.0.4:8088, nous im­por­tons le mo­dule7 et dé­cla­rons le conte­neur dans l’en­semble d’at­tri­buts config.luffy.containers :

{ pkgs, config, ... }: {
  imports = [ ./modules/container.nix ];
  config.luffy.containers.goatcounter = {
    config = {
      services.goatcounter = {
        enable = true;
        address = "127.0.0.4";
        port = 8088;
        proxy = true;
      };
    };
  };
}

Comme les conte­neurs sont éphé­mères, il faut conser­ver les don­nées per­sis­tantes dans des ré­per­toires de l’hôte. Nous ajou­tons une op­tion mounts et de­man­dons aux conte­neurs de NixOS d’ex­po­ser les ré­per­toires ainsi confi­gu­rés via l’op­tion bindMounts.

{ config, lib, ... }:
let
  cfg = config.luffy.containers;
in
{
  options.luffy.containers = lib.mkOption {
    type = lib.types.attrsOf (lib.types.submodule {
      options = {
        mounts = lib.mkOption {
          type = lib.types.listOf lib.types.str;
          default = [ ];
          description = "Host directories mounted read-write at the same place.";
        };
      };
    });
  };

  config = {
    containers = lib.mapAttrs
      (name: container: {
        bindMounts =
          lib.genAttrs container.mounts (path: { hostPath = path; isReadOnly = false; });
      })
      cfg;
  };
}

Par exemple, pour conser­ver la base de don­nées de GoatCounter dans le ré­per­toire /var/db/goatcounter de l’hôte, nous ajou­tons ce ré­per­toire à l’op­tion mounts et mo­di­fions la dé­fi­ni­tion du ser­vice pour in­di­quer à GoatCounter où trou­ver la base de données.

{ config, ... }:
let
  databaseDirectory = "/var/db/goatcounter";
in {
  config.luffy.containers.goatcounter = {
    mounts = [ databaseDirectory ];
    config = {
      services.goatcounter = {
        extraArgs = [ "-db=sqlite+${databaseDirectory}/db.sqlite" ];
      };
    };
  };
}

Un conte­neur peut aussi avoir be­soin de se­crets. Col­mena peut en­voyer des se­crets sans les sto­cker dans /nix. Nous ajou­tons une op­tion keys à nos conte­neurs. Elle prend un en­semble d’at­tri­buts as­so­ciant le nom de chaque se­cret à la com­mande qui le four­nit. En­suite, le mo­dule dé­clare les se­crets re­quis à Col­mena dans l’op­tion deployment.keys, fait dé­pendre le conte­neur de la pré­sence des se­crets, puis les at­tache au conteneur.

{ config, lib, ... }:
let
  cfg = config.luffy.containers;
in
{
  options.luffy.containers = lib.mkOption {
    type = lib.types.attrsOf (lib.types.submodule {
      options = {
        keys = lib.mkOption {
          type = lib.types.attrsOf (lib.types.listOf lib.types.str);
          default = { };
          description = "Secrets, as a command to run locally. They are mounted in /etc.";
        };
      };
    });
  };

  config = {
    # Colmena place chaque secret dans `/var/keys` et le rend accessible
    # au groupe "keys".
    deployment.keys = lib.concatMapAttrs
      (_: container: lib.mapAttrs
        (_: keyCommand: {
          inherit keyCommand;
          group = "keys";
          permissions = "0640";
          destDir = "/var/keys";
        })
        container.keys)
      cfg;

    # Le conteneur ne peut démarrer que si les secrets requis sont disponibles.
    systemd.services = lib.mapAttrs'
      (name: container:
        let
          units = map (key: "${key}-key.service") (lib.attrNames container.keys);
        in
        lib.nameValuePair "container@${name}" {
          requires = units;
          after = units;
        })
      cfg;

    # Attache chaque secret dans le conteneur.
    containers = lib.mapAttrs
      (name: container: {
        bindMounts = lib.mapAttrs'
          (key: _: lib.nameValuePair "/etc/${key}" {
            hostPath = "/var/keys/${key}";
            isReadOnly = true;
          })
          container.keys;
      })
      cfg;
  };
}

Par exemple, GoatCounter a be­soin d’iden­ti­fiants pour té­lé­char­ger la base de don­nées GeoIP. Je four­nis une com­mande lo­cale pour ré­cu­pé­rer le se­cret de­puis mon ges­tion­naire de mots de passe et je l’ex­pose dans le conte­neur via le fi­chier d’en­vi­ron­ne­ment /etc/goatcounter.env.

{ pkgs, config, ... }: 
let
  keyCommand = variable: [
    "${pkgs.runtimeShell}"
    "-c"
    "pass show personal/nixops/secrets | grep '^${variable}='"
  ];
in {
  config.luffy.containers.goatcounter = {
    keys."goatcounter.env" = keyCommand "GOATCOUNTER_GEODB";
    config = {
      systemd.services.goatcounter.serviceConfig = {
        EnvironmentFile = "/etc/goatcounter.env";
        SupplementaryGroups = [ "keys" ];
      };
    };
  };
}

Serveur GoatCounter#

Nixpkgs four­nit déjà un pa­quet pour GoatCounter. En sur­char­geant les at­tri­buts src et vendorHash, je réuti­lise sa dé­fi­ni­tion pour ma ver­sion per­son­na­li­sée avec le proxy :

{ goatcounter, fetchFromGitHub }:
goatcounter.overrideAttrs (_: {
  src = fetchFromGitHub {
    owner = "vincentbernat";
    repo = "goatcounter";
    rev = "feature/proxy";
    hash = "sha256-dJRlQlFu3tjcEgabT1LEbyFrasJlhmYu4L/T7EkoNcY=";
  };
  vendorHash = "sha256-c9Q5OrbZR+q6pD3SgPPWe8JUzcZco1AVUKGaV61k5DE=";
})

J’ai écrit un mo­dule NixOS pour en­cap­su­ler GoatCounter : la dé­fi­ni­tion du conte­neur, celle du ser­vice et les se­crets. Le mo­dule ac­cepte les op­tions sui­vantes : package, serve.enable, serve.listenAddress, serve.port et serve.databaseFile. J’ai déjà dé­taillé la confi­gu­ra­tion du conte­neur dans la sec­tion pré­cé­dente. Fi­na­le­ment, j’ai choisi de ne pas réuti­li­ser le mo­dule GoatCounter de NixOS : il est plu­tôt simple et le ré­écrire per­met de me pro­té­ger de chan­ge­ments fu­turs in­attendus.

{ config, pkgs, lib, ... }:
let
  cfg = config.luffy.goatcounter;
  databaseDirectory = builtins.dirOf cfg.serve.databaseFile;
  chown = "${pkgs.coreutils}/bin/chown -R";
in {
  config.luffy.containers.goatcounter = {
    config.systemd.services.goatcounter = {
      description = "GoatCounter Web Analytics";
      wantedBy = [ "multi-user.target" ];
      serviceConfig = {
        EnvironmentFile = "/etc/goatcounter.env";
        SupplementaryGroups = [ "keys" ];
        DynamicUser = true;
        Restart = "always";
        ExecStart = lib.escapeShellArgs [
          (lib.getExe cfg.package)
          "serve"
          "-listen=${cfg.serve.listenAddress}:${toString cfg.serve.port}"
          "-tls=none"
          "-db=sqlite+${cfg.serve.databaseFile}"
          "-automigrate"
        ];
        # Donne la base de données à l'utilisateur dynamique "goatcounter".
        ExecStartPre = "+${chown} goatcounter:goatcounter ${databaseDirectory}";
        ReadWritePaths = databaseDirectory;
      };
    };
  };
}

Le bout de code sui­vant confi­gure GoatCounter pour écou­ter sur 127.0.0.4:8088 :

{
  luffy.goatcounter = {
    serve = {
      enable = true;
      listenAddress = "127.0.0.4";
      port = 8088;
    };
  };
}

La der­nière étape consiste à confi­gu­rer nginx pour ex­po­ser GoatCounter sur In­ter­net. Je désac­tive le point de ter­mi­nai­son /count, car sa fonc­tion est as­su­rée par le proxy local.

{ config, ... }:
let
  cfg = config.luffy.goatcounter.serve;
in
{
  services.nginx.virtualHosts."goatcounter.luffy.cx" = {
    forceSSL = true;
    locations = {
      "/" = {
        proxyPass = "http://${cfg.listenAddress}:${toString cfg.port}";
      };
      "= /count".extraConfig = ''
        return 404;
      '';
    };
  };
}

Proxy GoatCounter#

Le même mo­dule NixOS confi­gure le proxy lo­cal, avec les op­tions sui­vantes : proxy.enable, proxy.listenAddress, proxy.port et proxy.site (le site qui re­çoit les lots de pages vues). Le proxy lo­cal n’a au­cune don­née per­sis­tante, mais il a be­soin de la clé d’API pour s’au­then­ti­fier au­près de l’ins­tance prin­ci­pale de GoatCounter : son conte­neur uti­lise l’op­tion keys, mais pas l’op­tion mounts.

{ config, pkgs, lib, ... }:
let
  cfg = config.luffy.goatcounter;
  keyCommand = _: [ "…" ];
in
{
  config.luffy.containers.goatcounter-proxy = {
    keys."goatcounter-proxy.env" = keyCommand "GOATCOUNTER_API_KEY";
    config.systemd.services.goatcounter = {
      description = "GoatCounter Proxy.";
      wantedBy = [ "multi-user.target" ];
      serviceConfig = {
        EnvironmentFile = "/etc/goatcounter-proxy.env";
        SupplementaryGroups = [ "keys" ];
        DynamicUser = true;
        Restart = "always";
        ExecStart = lib.escapeShellArgs [
          (lib.getExe cfg.package)
          "proxy"
          "-site=${cfg.proxy.site}"
          "-listen=${cfg.proxy.listenAddress}:${toString cfg.proxy.port}"
          "-ratelimit=10/1"  # 10 requêtes par seconde par IP
        ];
      };
    };
  };
}

Sur chaque ser­veur, j’ac­tive le proxy lo­cal avec le bout de code sui­vant. La confi­gu­ra­tion nginx pré­sen­tée plus haut ex­pose le point de ter­mi­nai­son /count sous le même do­maine que mon blog.

{
  luffy.goatcounter = {
    proxy = {
      enable = true;
      site = "goatcounter.luffy.cx";
      listenAddress = "127.0.0.3";
      port = 8087;
    };
  };
}

Sauvegarde de la base de données SQLite avec Litestream#

Li­tes­tream est un ou­til de ré­pli­ca­tion en continu pour les bases de don­nées SQLite. Il com­presse les chan­ge­ments pla­cés dans le jour­nal d’écri­ture an­ti­ci­pée (WAL) si­tué à côté de la base de don­nées et les en­voie vers une des­ti­na­tion dis­tante. J’en­cap­sule sa confi­gu­ra­tion dans un mo­dule NixOS, qui prend un en­semble d’at­tri­buts databases as­so­ciant un nom au che­min de la base de don­nées à sau­vegarder.

Li­tes­tream tourne lui aussi dans un conte­neur. Je monte les bases de don­nées à ré­pli­quer, ainsi que les se­crets pour pous­ser les sau­ve­gardes vers une sto­rage box de Hetz­ner via SFTP :

{ config, pkgs, lib, ... }:
let
  cfg = config.luffy.litestream;
  databaseDirs = lib.unique (map builtins.dirOf (builtins.attrValues cfg.databases));
in
{
  config = lib.mkIf (cfg.databases != { }) {
    luffy.containers.litestream = {
      mounts = databaseDirs;
      keys."litestream.env" = [
        "${pkgs.runtimeShell}"
        "-c"
        "pass show personal/nixops/secrets | grep '^SQLITE_BACKUP_'"
      ];
    };
  };
}

Dans le conte­neur, je confi­gure Li­tes­tream à tra­vers les op­tions services.litestream de NixOS :

  • des ins­tan­ta­nés com­plets chaque jour, conser­vés 15 jours ;
  • trois ni­veaux de com­pac­tage pour les tran­sac­tions : 5 mi­nutes, 30 mi­nutes et 3 heures ;
  • la re­prise au­to­matique8 ;
  • un ré­plica sto­cké dans un ré­per­toire por­tant le nom de l’hôte ;
  • des iden­ti­fiants lus de­puis /etc/litestream.env et ex­po­sés par ex­pan­sion de va­riables.
{ config, pkgs, lib, ... }:
let
  cfg = config.luffy.litestream;
in
{
  config.luffy.containers.litestream = {
    config = {
      # Les bases de données appartiennent à des utilisateurs dynamiques, dont
      # l'UID n'est pas connu ici : Litestream tourne donc en root.
      systemd.services.litestream.serviceConfig = {
        User = lib.mkForce "root";
        Group = lib.mkForce "root";
      };
      # Utilise le service NixOS.
      services.litestream = {
        enable = true;
        environmentFile = "/etc/litestream.env";
        settings = {
          auto-recover = true;
          snapshot = {
            interval = "24h";
            retention = "360h";
          };
          levels = [
            { interval = "5m"; }
            { interval = "30m"; }
            { interval = "3h"; }
          ];
          dbs = lib.mapAttrsToList
            (name: path: {
              inherit path;
              replica = {
                type = "sftp";
                host = "\${SQLITE_BACKUP_HOST}";
                user = "\${SQLITE_BACKUP_USER}";
                password = "\${SQLITE_BACKUP_PASSWORD}";
                host-key = "\${SQLITE_BACKUP_HOSTKEY}";
                path = "${config.networking.hostName}/${name}";
              };
            })
            cfg.databases;
        };
      };
    };
  };
}

Pour sau­ve­gar­der la base de don­nées de GoatCounter, je dé­clare un at­tri­but goatcounter dans luffy.litestream.databases avec pour va­leur le che­min de la base de données :

{ config, ... }:
let
  cfg = config.luffy.goatcounter.serve;
in
{
  luffy.litestream.databases.goatcounter = cfg.databaseFile;
}

Sur le ser­veur SFTP, nous pou­vons ins­pec­ter le tra­vail de Li­tes­tream, avec les tran­sac­tions com­pac­tées et les ins­tan­ta­nés complets :

❯ ls web02/goatcounter/ltx
web02/goatcounter/ltx/0
web02/goatcounter/ltx/1
web02/goatcounter/ltx/2
web02/goatcounter/ltx/3
web02/goatcounter/ltx/9
❯ ls -lh web02/goatcounter/ltx/1
29.1K Sep  5 01:25 0000000000003f2a-0000000000003f2b.ltx
72.4K Sep  5 02:03 0000000000003f2c-0000000000003f2d.ltx
63.3K Sep  5 02:24 0000000000003f2e-0000000000003f2f.ltx
[…]
❯ ls -lh web02/goatcounter/ltx/9
 8.5M Sep  5 02:00 0000000000000001-0000000000003f2b.ltx
 8.5M Sep  6 02:03 0000000000000001-0000000000004008.ltx
 8.6M Sep  7 02:03 0000000000000001-00000000000043a8.ltx
[…]

Nous pou­vons res­tau­rer la base de don­nées de­puis la sau­ve­garde avec quelques com­mandes shell. D’abord, nous ar­rê­tons les conte­neurs. En­suite, nous met­tons de côté la base de don­nées en­dom­ma­gée, in­vo­quons litestream restore de­puis le bon en­vi­ron­ne­ment et re­dé­mar­rons les conteneurs9.

# systemctl stop container@goatcounter container@litestream
# mv /var/db/goatcounter/db.sqlite{,.old}
# ( . /etc/nixos-containers/litestream.conf ; 
>   set -a ; . /var/keys/litestream.env ; set +a ;
>   $SYSTEM_PATH/sw/bin/litestream \
>     restore -config $SYSTEM_PATH/etc/litestream.yml /var/db/goatcounter/db.sqlite)
# ls -lh /var/db/goatcounter/db.sqlite
-rw-r--r-- 1 root root 20M Sep 20 07:33 /var/db/goatcounter/db.sqlite
# systemctl start container@goatcounter container@litestream

Dix ans après avoir re­tiré Google Ana­ly­tics, les sta­tis­tiques en JavaScript sont de re­tour sur ce blog, mais sans sto­cker de co­okies ni d’adresses IP, et sans se re­po­ser sur un tiers. J’écris d’abord pour moi, no­tam­ment parce que cela me per­met de creu­ser un su­jet et de m’y ré­fé­rer des an­nées plus tard. Ce­pen­dant, en sa­voir un peu plus sur mes lec­teurs hu­mains est un bo­nus ap­pré­ciable, y com­pris sur ceux qui n’ac­tivent pas JavaScript. 🐐