Bot-free self-hosted analytics with GoatCounter on NixOS

Vincent Bernat

In 2016, I re­moved Google An­a­lyt­ics from this blog to avoid be­ing com­plicit in feed­ing the big­gest ma­chine for har­vest­ing per­sonal data. In­stead, I re­lied on GoAccess to an­a­lyze my server logs.1 For the past cou­ple of years, the sta­tis­tics have made no sense, de­spite my at­tempts to fil­ter bots: AI scrap­ers in­flate the num­ber of vis­i­tors to around 2,000 per day. Even­tu­ally, I set­tled on GoatCounter, an open-source, pri­vacy-friendly web an­a­lyt­ics plat­form. I re­placed the JavaScript client to fil­ter bots more ag­gres­sively and added a CSS fall­back. To im­prove re­li­a­bil­ity, I im­ple­mented a lo­cal proxy run­ning on each of the five web servers serv­ing this blog. The rest of this post de­tails how these pieces fit to­gether and how I de­ploy them on NixOS. ❄️

Why GoatCounter?#

GoatCounter does not col­lect per­sonal data: in­stead of stor­ing the reader’s IP ad­dress or re­ly­ing on cook­ies, it cre­ates a ses­sion iden­ti­fier valid for 8 hours from the user agent and the IP ad­dress. Its fea­ture set is mod­est but suf­fi­cient for a blog. If you want to look at the in­ter­face, GoatCounter’s au­thor runs a pub­lic in­stance for his site. A hosted ver­sion lets you try it be­fore run­ning your own in­stance. With a sin­gle bi­nary and an SQLite data­base, GoatCounter is one of the light­est self-hosted so­lu­tions. Pri­vacy-friendly al­ter­na­tives, in in­creas­ing or­der of com­plex­ity, in­clude Umami, Plau­si­ble, and Rybbit.

GoatCounter dashboard showing some statistics from my blog, including an
article on the spanning tree protocol with 1,224 views for the past week, the
referrers, and the breakdown of browsers (52% Chrome, 32% Firefox, with 16% for
Firefox 155 and 6% for Firefox 156)

Custom JavaScript client#

GoatCounter in­cludes a small JavaScript client—2,189 bytes mini­fied and gzipped. It ships some fea­tures I don’t use: a vis­i­tor counter, track­ing clicks, con­fig­urable set­tings, etc. I re­place it with this func­tion to reg­is­ter a hit:

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(() => {});
};

To fil­ter bots,2 I go the ex­tra mile by re­quir­ing a user in­ter­ac­tion—an idea I stole from Bear Blog.

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

If a reader has dis­abled JavaScript in their browser, I record the hit us­ing a CSS im­age. The :hover pseudo-class loads it only af­ter an in­ter­ac­tion, an­other trick stolen from Bear Blog. About 2% of my vis­i­tors fit into this bucket.3

<!DOCTYPE html>
<html lang="en" class="nojs">
  <head>
    <script>
      // The JavaScript code for this blog requires 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>

Where GoAccess re­ported around 2,000 vis­i­tors a day, GoatCounter counts fewer than 200 hu­mans.4 I as­sume AI scrap­ers use a low-ef­fort ap­proach: if the con­tent is avail­able with­out bar­ri­ers, as on this blog, they don’t spawn a com­plex mech­a­nized browser that could trig­ger a page view. Even crawlers run­ning JavaScript, like Google­bot with its head­less Chromium, do not in­ter­act with the page and never trig­ger the events I lis­ten to. The in­ter­ac­tion-based “proof of hu­man­ity” I use is likely to keep working.5

Up­date (2026-11)

I added an ad­di­tional fil­ter for the “NoJS” tracker to ex­clude bots pulling the URL from the page. The fol­low­ing snip­pet checks if the query has r set to NoJS and if the Sec-Fetch-Dest header value is not image. Then, Caddy an­swers an empty 204. Oth­er­wise, it for­wards the re­quest to 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 {
    # …
  }
}

Local proxy#

Five servers across the world in Eu­rope and in North Amer­ica serve the con­tent of this web­site, but GoatCounter runs on only one of them. To avoid los­ing track of vis­i­tors when GoatCounter is down, I run a lo­cal proxy lis­ten­ing on the same /count end­point. On each server, it stores the hits in mem­ory with a buf­fer large enough to sur­vive sev­eral days of down­time. It sends them in batches to the up­stream back­end us­ing the /api/v0/count au­then­ti­cated endpoint.

🖼 Servers on a map. web02 is in Paris, web03 in Helsinki, web04 in Nuremberg, web05 in Ashburn, web06 in Chicago.

I pro­posed the code for the proxy in pull re­quest #909. GoatCounter’s main­tainer de­clined to main­tain so much code for such a niche use case. As a fel­low open-source de­vel­oper, I of­ten hold the same po­si­tion for my own projects: a one-time con­trib­u­tor ef­fort may trans­late into a long-term main­tainer com­mitment.

I ex­pose the end­point for the proxy on the do­main of this web­site to evade ad block­ers. This sounds like I don’t re­spect the reader’s choice, but as GoatCounter is pri­vacy-friendly, I find it 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;
}

Deploying on NixOS#

My web servers run NixOS, a declar­a­tive Linux dis­tri­bu­tion with built-in con­fig­u­ra­tion man­age­ment. I man­age this small fleet with Col­mena, a state­less de­ploy­ment tool for NixOS. My con­fig­u­ra­tion is avail­able on GitHub.

Deploying applications in containers#

For bet­ter iso­la­tion, each ap­pli­ca­tion runs in­side an ephemeral light­weight con­tainer, pow­ered by sys­temd-nspawn. Each con­tainer runs a stripped-down NixOS in­stance. A mod­ule wraps NixOS’s containers op­tions to avoid re­peat­ing the same op­tions for each ap­pli­ca­tion.6 The con­tain­ers share their net­work names­pace with the host: the ad­di­tional iso­la­tion is not worth the in­creased com­plex­ity. For a smaller foot­print, I also dis­able a few non-es­sen­tial services.

{ config, lib, ... }:
let
  cfg = config.luffy.containers;
in
{
  # User-configurable settings for our custom 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.";
        };
      };
    });
  };

  # Translate our options to NixOS containers
  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;
  };
}

To con­fig­ure a GoatCounter in­stance run­ning in a con­tainer and lis­ten­ing on 127.0.0.4:8088, we im­port the mod­ule7 and de­clare the con­tainer in the config.luffy.containers at­tribute set:

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

As the con­tain­ers are ephemeral, we need to keep per­sis­tent data in di­rec­to­ries on the host. We add a mounts op­tion and ask NixOS’s con­tain­ers to ex­pose the con­fig­ured di­rec­to­ries through the bindMounts option.

{ 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;
  };
}

For ex­am­ple, to per­sist GoatCounter’s data­base in the /var/db/goatcounter di­rec­tory on the host, we add the di­rec­tory to the mounts op­tion and al­ter the ser­vice def­i­ni­tion to tell GoatCounter where the database is.

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

A con­tainer may also need some se­crets. Col­mena can up­load se­crets with­out stor­ing them in the Nix store. We add a keys op­tion to our con­tain­ers. It takes an at­tribute set map­ping se­cret names to the com­mands to pop­u­late them. Then, the mod­ule de­clares the re­quired se­crets to Col­mena in the deployment.keys op­tion, makes the con­tainer de­pend on the pres­ence of the se­crets, and ex­poses them to the container.

{ 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 uploads each secret in `/var/keys` and make them available
    # to the group "keys".
    deployment.keys = lib.concatMapAttrs
      (_: container: lib.mapAttrs
        (_: keyCommand: {
          inherit keyCommand;
          group = "keys";
          permissions = "0640";
          destDir = "/var/keys";
        })
        container.keys)
      cfg;

    # The container can only start if the required secrets are available.
    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;

    # Mount each secret inside the container.
    containers = lib.mapAttrs
      (name: container: {
        bindMounts = lib.mapAttrs'
          (key: _: lib.nameValuePair "/etc/${key}" {
            hostPath = "/var/keys/${key}";
            isReadOnly = true;
          })
          container.keys;
      })
      cfg;
  };
}

For ex­am­ple, GoatCounter needs cre­den­tials to down­load the GeoIP data­base. I pro­vide a lo­cal com­mand to fetch the se­cret from my pass­word man­ager and ex­pose it in­side the con­tainer through the /etc/goatcounter.env en­vi­ron­ment file.

{ 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" ];
      };
    };
  };
}

GoatCounter server#

Nix­p­kgs al­ready pack­ages GoatCounter. By over­rid­ing the src and vendorHash at­tributes, I re­use its def­i­ni­tion for my cus­tom ver­sion with the proxy:

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

I wrote a NixOS mod­ule to en­cap­su­late GoatCounter: the con­tainer def­i­ni­tion, the ser­vice def­i­ni­tion, and the se­crets. The mod­ule ac­cepts the fol­low­ing op­tions: package, serve.enable, serve.listenAddress, serve.port, and serve.databaseFile. I al­ready de­tailed the con­tainer con­fig­u­ra­tion in the pre­vi­ous sec­tion. In the end, I chose not to re­use the GoatCounter mod­ule from NixOS: it’s small, so it’s bet­ter to in­su­late my mod­ule from un­ex­pected fu­ture changes.

{ 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"
        ];
        # Transfer database ownership to dynamically assigned user "goatcounter".
        ExecStartPre = "+${chown} goatcounter:goatcounter ${databaseDirectory}";
        ReadWritePaths = databaseDirectory;
      };
    };
  };
}

The fol­low­ing snip­pet con­fig­ures GoatCounter to lis­ten on 127.0.0.4:8088:

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

The last step is to con­fig­ure ng­inx to ex­pose GoatCounter on the In­ter­net. I dis­able the /count end­point as the lo­cal proxy handles it.

{ 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;
      '';
    };
  };
}

GoatCounter proxy#

The same NixOS mod­ule con­fig­ures the lo­cal proxy, with the fol­low­ing op­tions: proxy.enable, proxy.listenAddress, proxy.port, and proxy.site—the site re­ceiv­ing the batches of page views. The lo­cal proxy has no per­sis­tent data, but it needs the API key to au­then­ti­cate to the main GoatCounter in­stance: its con­tainer uses the keys op­tion but not the mounts option.

{ 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 requests per second per IP
        ];
      };
    };
  };
}

For each server, I en­able the lo­cal proxy with the fol­low­ing snip­pet. The ng­inx con­fig­u­ra­tion shown ear­lier ex­poses the /count end­point un­der the same do­main as my blog.

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

Backup of the SQLite database with Litestream#

Litestream is a stream­ing repli­ca­tion tool for SQLite data­bases. It com­presses the changes com­mit­ted to the write-ahead log (WAL) next to the data­base and sends them to a re­mote des­ti­na­tion. I en­cap­su­late its con­fig­u­ra­tion in a NixOS mod­ule, which takes an at­tribute set databases map­ping a name to the path of the data­base to back up.

Litestream also runs in a con­tainer. I mount the data­bases to repli­cate, as well as the se­crets to push the back­ups to a Het­zner stor­age box us­ing 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_'"
      ];
    };
  };
}

In­side the con­tainer, I con­fig­ure Litestream through NixOS’s services.litestream options:

  • full snap­shots ev­ery day, kept for 15 days,
  • three lev­els of com­paction for trans­ac­tion files: 5 min­utes, 30 min­utes, and 3 hours,
  • auto-re­covery,8
  • replica stored in a di­rec­tory match­ing the host name, and
  • cre­den­tials read from /etc/litestream.env and ex­posed through vari­able ex­pansion.
{ config, pkgs, lib, ... }:
let
  cfg = config.luffy.litestream;
in
{
  config.luffy.containers.litestream = {
    config = {
      # The databases belong to dynamically allocated users, whose UID is
      # not known here, so Litestream runs as root.
      systemd.services.litestream.serviceConfig = {
        User = lib.mkForce "root";
        Group = lib.mkForce "root";
      };
      # Use NixOS service.
      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;
        };
      };
    };
  };
}

To back up GoatCounter’s data­base, I de­clare a goatcounter at­tribute in luffy.litestream.databases, set to the data­base path:

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

On the SFTP server, we can in­spect Litestream’s work, with the com­pacted trans­ac­tions and the full snapshots:

❯ 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
[…]

We can re­store the data­base from the backup with a few shell com­mands. First, we stop the con­tain­ers. Then, we move the dam­aged data­base away, in­voke litestream restore from the right en­vi­ron­ment, and restart the con­tainers.9

# 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

Ten years af­ter re­mov­ing Google An­a­lyt­ics, JavaScript-based an­a­lyt­ics is back on this blog, but with­out stor­ing cook­ies or IP ad­dresses, and with­out in­volv­ing a third party. I still write for my­self first, no­tably be­cause it lets me dig into a topic and re­fer back to it years later. But know­ing a bit more about my fel­low hu­man read­ers is a nice bonus, even the ones dis­abling JavaScript. 🐐