{"slug": "bot-free-self-hosted-analytics-with-goatcounter-on-nixos", "title": "Bot-free self-hosted analytics with GoatCounter on NixOS", "summary": "Vincent Bernat replaced GoAccess with GoatCounter, an open-source, privacy-friendly web analytics platform, on his NixOS-hosted blog after AI scrapers inflated GoAccess's visitor counts to around 2,000 per day. Bernat wrote a custom JavaScript client that requires a user interaction before registering a hit and added a CSS image fallback for readers with JavaScript disabled, which he says covers about 2% of his visitors. He also deployed a local proxy on each of the five web servers serving the blog to improve reliability.", "body_md": "# Bot-free self-hosted analytics with GoatCounter on NixOS\n\n## Vincent Bernat\n\n21-minute read\n\nAlso available in\n\nFiled under\n\nAttachment:\n\nIn 2016, I [removed Google Analytics](https://github.com/vincentbernat/vincent.bernat.ch/commit/4c3be65404031bf6638c1ea3d891a3ea58e0a0fc) from this blog to avoid being\ncomplicit in feeding the biggest machine for harvesting personal data. Instead,\nI relied on [GoAccess](https://goaccess.io/) to analyze my server logs.<sup>[1](#sidenote-scrub)</sup> For the past couple\nof years, the statistics have made no sense, despite my attempts to filter bots:\n[AI scrapers](https://herman.bearblog.dev/the-great-scrape/) inflate the number of visitors to around 2,000 per\nday. Eventually, I settled on [GoatCounter](https://www.goatcounter.com/), an open-source, privacy-friendly\nweb analytics platform. I replaced the JavaScript client to filter bots more\naggressively and added a CSS fallback. To improve reliability, I implemented a\nlocal proxy running on each of the five web servers serving this blog. The rest\nof this post details how these pieces fit together and how I deploy them on\nNixOS. ❄️\n\n# Why GoatCounter?[#](#why-goatcounter)\n\nGoatCounter does not [collect personal data](https://www.goatcounter.com/help/sessions#technical-details-11181): instead of storing the reader’s\nIP address or relying on cookies, it creates a session identifier valid for 8\nhours from the user agent and the IP address. Its feature set is modest but\nsufficient for a blog. If you want to look at the interface, GoatCounter’s\nauthor runs a [public instance](https://stats.arp242.net) for [his site](https://www.arp242.net). A hosted\nversion lets you try it before running your own instance. With a single binary\nand an SQLite database, GoatCounter is one of the lightest self-hosted\nsolutions. Privacy-friendly alternatives, in increasing order of complexity,\ninclude [Umami](https://umami.is/), [Plausible](https://plausible.io/), and [Rybbit](https://rybbit.com/).\n\n# Custom JavaScript client[#](#custom-javascript-client)\n\nGoatCounter includes a [small JavaScript client](https://github.com/arp242/goatcounter/blob/main/public/count.js)—2,189 bytes minified and\ngzipped. It ships some features I don’t use: a visitor counter, tracking clicks,\nconfigurable settings, etc. I replace it with this function to register a hit:\n\n``` js\nconst count = ({ event, title } = {}) => {\n  const params = new URLSearchParams({\n    p: event || location.pathname,\n    t: title || document.title,\n    r: document.referrer,\n    q: location.search,\n    s: document.documentElement.clientWidth,\n    e: !!event,\n    rnd: Math.random().toString(36).slice(2, 7),\n  });\n  fetch(`/count?${params}`, { keepalive: true }).catch(() => {});\n};\n```\n\nTo filter bots,<sup>[2](#sidenote-bot-filter)</sup> I go the extra mile by requiring a user\ninteraction—an idea I stole from [Bear Blog](https://bearblog.dev/).\n\n``` js\nlet sendHit = () => (sendHit = () => {}, count());\n[\"touchmove\", \"mousemove\", \"keydown\", \"pointerdown\"].forEach((eventName) =>\n  document.addEventListener(eventName, sendHit, {\n    once: true,\n    passive: true,\n  }),\n);\n```\n\nIf a reader has disabled JavaScript in their browser, I record the hit using a\nCSS image. The `:hover` pseudo-class loads it only after an interaction, another\ntrick [stolen from Bear Blog](https://herman.bearblog.dev/how-bear-does-analytics-with-css/). About 2% of my visitors fit into this\nbucket.[3](#sidenote-referrer)\n\n```\n<!DOCTYPE html>\n<html lang=\"en\" class=\"nojs\">\n  <head>\n    <script>\n      // The JavaScript code for this blog requires ES6\n      if (\"noModule\" in HTMLScriptElement.prototype)\n        document.documentElement.classList.remove(\"nojs\");\n    </script>\n  </head>\n  <body>\n  <!-- ... -->\n    <style>\n      .nojs body:hover {\n        border-width: 0;\n        border-image: url('/count?p=/en/blog/2026-kpi-goodhart&t=Building...&r=NoJS&e=false');\n      }\n    </style>\n  </body>\n</html>\n```\n\nWhere GoAccess reported around 2,000 visitors a day, GoatCounter counts fewer\nthan 200 humans.<sup>[4](#sidenote-rss)</sup> I assume AI scrapers use a low-effort approach: if the\ncontent is available without barriers, as on this blog, they don’t spawn a\ncomplex mechanized browser that could trigger a page view. Even crawlers running\nJavaScript, like [Googlebot with its headless Chromium](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics), [do not\ninteract with the page](https://developers.google.com/search/docs/crawling-indexing/javascript/lazy-loading#:~:text=Google%20Search%20does%20not%20interact%20with%20your%20page) and never trigger the events I listen\nto. The interaction-based “proof of humanity” I use is likely to keep working.\n\n# Local proxy[#](#local-proxy)\n\nFive servers ~~across the world~~ in Europe and in North\nAmerica serve the content of this website, but GoatCounter runs on only\none of them. To avoid losing track of visitors when GoatCounter is down, I run a\nlocal proxy listening on the same `/count` endpoint. On each server, it stores\nthe hits in memory with a buffer large enough to survive several days of\ndowntime. It sends them in batches to the upstream backend using the\n[`/api/v0/count` authenticated endpoint](https://www.goatcounter.com/help/api).\n\nI proposed the code for the proxy in [pull request #909](https://github.com/arp242/goatcounter/pull/909). GoatCounter’s\nmaintainer declined to maintain so much code for such a niche use case. As a\nfellow open-source developer, I often hold the same position for my own\nprojects: a one-time contributor effort may translate into a long-term\nmaintainer commitment.\n\nI expose the endpoint for the proxy on the domain of this website to evade ad blockers. This sounds like I don’t respect the reader’s choice, but as GoatCounter is privacy-friendly, I find it acceptable.\n\n```\nlocation = /count {\n  access_log off;\n  proxy_pass http://127.0.0.3:8087/count;\n  proxy_pass_request_headers off;\n  proxy_set_header Accept-Language $http_accept_language;\n  proxy_set_header User-Agent $http_user_agent;\n  proxy_set_header X-Real-Ip $remote_addr;\n}\n```\n\n# Deploying on NixOS[#](#deploying-on-nixos)\n\nMy web servers run [NixOS](https://nixos.org/), a declarative Linux distribution with built-in\nconfiguration management. I manage this small fleet with [Colmena](https://colmena.cli.rs/), a\nstateless deployment tool for NixOS. My configuration is available on\n[GitHub](https://github.com/vincentbernat/nixops-take1/).\n\n## Deploying applications in containers[#](#deploying-applications-in-containers)\n\nFor better isolation, each application runs inside an ephemeral lightweight\ncontainer, powered by [systemd-nspawn](https://manpages.debian.org/systemd-nspawn.1.html). Each container runs a stripped-down\nNixOS instance. A [module wraps NixOS’s `containers` options](https://github.com/vincentbernat/nixops-take1/blob/master/modules/container.nix) to\navoid repeating the same options for each application.<sup>[5](#sidenote-module)</sup> The containers\nshare their network namespace with the host: the additional isolation is not\nworth the increased complexity. For a smaller footprint, I also disable a few\nnon-essential services.\n\n``` js\n{ config, lib, ... }:\nlet\n  cfg = config.luffy.containers;\nin\n{\n  # User-configurable settings for our custom module\n  options.luffy.containers = lib.mkOption {\n    default = { };\n    description = \"Ephemeral containers sharing the host network.\";\n    type = lib.types.attrsOf (lib.types.submodule {\n      options = {\n        config = lib.mkOption {\n          type = lib.types.deferredModule;\n          default = { };\n          description = \"NixOS configuration of the container.\";\n        };\n      };\n    });\n  };\n\n  # Translate our options to NixOS containers\n  config = {\n    containers = lib.mapAttrs\n      (name: container: {\n        ephemeral = true;\n        autoStart = true;\n        privateNetwork = false;\n        extraFlags = [ \"--resolv-conf=replace-host\" ];\n        config = {\n          imports = [ container.config ];\n          networking.firewall.enable = false;\n          system.stateVersion = config.system.stateVersion;\n          systemd.services = {\n            console-getty.enable = false;\n            systemd-logind.enable = false;\n            systemd-oomd.enable = false;\n          };\n        };\n      })\n      cfg;\n  };\n}\n```\n\nTo configure a GoatCounter instance running in a container and listening on\n`127.0.0.4:8088`, we import the module<sup>[6](#sidenote-import)</sup> and declare the container in the\n`config.luffy.containers` attribute set:\n\n```\n{ pkgs, config, ... }: {\n  imports = [ ./modules/container.nix ];\n  config.luffy.containers.goatcounter = {\n    config = {\n      services.goatcounter = {\n        enable = true;\n        address = \"127.0.0.4\";\n        port = 8088;\n        proxy = true;\n      };\n    };\n  };\n}\n```\n\nAs the containers are ephemeral, we need to keep persistent data in directories\non the host. We add a `mounts` option and ask NixOS’s containers to expose the\nconfigured directories through the `bindMounts` option.\n\n``` js\n{ config, lib, ... }:\nlet\n  cfg = config.luffy.containers;\nin\n{\n  options.luffy.containers = lib.mkOption {\n    type = lib.types.attrsOf (lib.types.submodule {\n      options = {\n        mounts = lib.mkOption {\n          type = lib.types.listOf lib.types.str;\n          default = [ ];\n          description = \"Host directories mounted read-write at the same place.\";\n        };\n      };\n    });\n  };\n\n  config = {\n    containers = lib.mapAttrs\n      (name: container: {\n        bindMounts =\n          lib.genAttrs container.mounts (path: { hostPath = path; isReadOnly = false; });\n      })\n      cfg;\n  };\n}\n```\n\nFor example, to persist GoatCounter’s database in the `/var/db/goatcounter`\ndirectory on the host, we add the directory to the `mounts` option and alter the\nservice definition to tell GoatCounter where the database is.\n\n``` js\n{ config, ... }:\nlet\n  databaseDirectory = \"/var/db/goatcounter\";\nin {\n  config.luffy.containers.goatcounter = {\n    mounts = [ databaseDirectory ];\n    config = {\n      services.goatcounter = {\n        extraArgs = [ \"-db=sqlite+${databaseDirectory}/db.sqlite\" ];\n      };\n    };\n  };\n}\n```\n\nA container may also need some secrets. Colmena can [upload secrets](https://colmena.cli.rs/unstable/features/keys.html) without\nstoring them in the Nix store. We add a `keys` option to our containers. It\ntakes an attribute set mapping secret names to the commands to populate them.\nThen, the module declares the required secrets to Colmena in the\n`deployment.keys` option, makes the container depend on the presence of the\nsecrets, and exposes them to the container.\n\n``` js\n{ config, lib, ... }:\nlet\n  cfg = config.luffy.containers;\nin\n{\n  options.luffy.containers = lib.mkOption {\n    type = lib.types.attrsOf (lib.types.submodule {\n      options = {\n        keys = lib.mkOption {\n          type = lib.types.attrsOf (lib.types.listOf lib.types.str);\n          default = { };\n          description = \"Secrets, as a command to run locally. They are mounted in /etc.\";\n        };\n      };\n    });\n  };\n\n  config = {\n    # Colmena uploads each secret in `/var/keys` and make them available\n    # to the group \"keys\".\n    deployment.keys = lib.concatMapAttrs\n      (_: container: lib.mapAttrs\n        (_: keyCommand: {\n          inherit keyCommand;\n          group = \"keys\";\n          permissions = \"0640\";\n          destDir = \"/var/keys\";\n        })\n        container.keys)\n      cfg;\n\n    # The container can only start if the required secrets are available.\n    systemd.services = lib.mapAttrs'\n      (name: container:\n        let\n          units = map (key: \"${key}-key.service\") (lib.attrNames container.keys);\n        in\n        lib.nameValuePair \"container@${name}\" {\n          requires = units;\n          after = units;\n        })\n      cfg;\n\n    # Mount each secret inside the container.\n    containers = lib.mapAttrs\n      (name: container: {\n        bindMounts = lib.mapAttrs'\n          (key: _: lib.nameValuePair \"/etc/${key}\" {\n            hostPath = \"/var/keys/${key}\";\n            isReadOnly = true;\n          })\n          container.keys;\n      })\n      cfg;\n  };\n}\n```\n\nFor example, GoatCounter needs credentials to download the GeoIP database. I\nprovide a local command to fetch the secret from my password manager and expose\nit inside the container through the `/etc/goatcounter.env` environment file.\n\n``` js\n{ pkgs, config, ... }: \nlet\n  keyCommand = variable: [\n    \"${pkgs.runtimeShell}\"\n    \"-c\"\n    \"pass show personal/nixops/secrets | grep '^${variable}='\"\n  ];\nin {\n  config.luffy.containers.goatcounter = {\n    keys.\"goatcounter.env\" = keyCommand \"GOATCOUNTER_GEODB\";\n    config = {\n      systemd.services.goatcounter.serviceConfig = {\n        EnvironmentFile = \"/etc/goatcounter.env\";\n        SupplementaryGroups = [ \"keys\" ];\n      };\n    };\n  };\n}\n```\n\n## GoatCounter server[#](#goatcounter-server)\n\nNixpkgs already packages GoatCounter. By overriding the `src` and `vendorHash`\nattributes, I reuse its definition for my custom version with the proxy:\n\n```\n{ goatcounter, fetchFromGitHub }:\ngoatcounter.overrideAttrs (_: {\n  src = fetchFromGitHub {\n    owner = \"vincentbernat\";\n    repo = \"goatcounter\";\n    rev = \"feature/proxy\";\n    hash = \"sha256-dJRlQlFu3tjcEgabT1LEbyFrasJlhmYu4L/T7EkoNcY=\";\n  };\n  vendorHash = \"sha256-c9Q5OrbZR+q6pD3SgPPWe8JUzcZco1AVUKGaV61k5DE=\";\n})\n```\n\nI wrote a [NixOS module](https://github.com/vincentbernat/nixops-take1/blob/master/modules/goatcounter.nix) to encapsulate GoatCounter: the\ncontainer definition, the service definition, and the secrets. The module\naccepts the following options: `package`, `serve.enable`, `serve.listenAddress`,\n`serve.port`, and `serve.databaseFile`. I already detailed the container\nconfiguration in the previous section. In the end, I chose not to reuse the\nGoatCounter module from NixOS: it’s small, so it’s better to insulate my module\nfrom unexpected future changes.\n\n``` js\n{ config, pkgs, lib, ... }:\nlet\n  cfg = config.luffy.goatcounter;\n  databaseDirectory = builtins.dirOf cfg.serve.databaseFile;\n  chown = \"${pkgs.coreutils}/bin/chown -R\";\nin {\n  config.luffy.containers.goatcounter = {\n    config.systemd.services.goatcounter = {\n      description = \"GoatCounter Web Analytics\";\n      wantedBy = [ \"multi-user.target\" ];\n      serviceConfig = {\n        EnvironmentFile = \"/etc/goatcounter.env\";\n        SupplementaryGroups = [ \"keys\" ];\n        DynamicUser = true;\n        Restart = \"always\";\n        ExecStart = lib.escapeShellArgs [\n          (lib.getExe cfg.package)\n          \"serve\"\n          \"-listen=${cfg.serve.listenAddress}:${toString cfg.serve.port}\"\n          \"-tls=none\"\n          \"-db=sqlite+${cfg.serve.databaseFile}\"\n          \"-automigrate\"\n        ];\n        # Transfer database ownership to dynamically assigned user \"goatcounter\".\n        ExecStartPre = \"+${chown} goatcounter:goatcounter ${databaseDirectory}\";\n        ReadWritePaths = databaseDirectory;\n      };\n    };\n  };\n}\n```\n\nThe following snippet configures GoatCounter to listen on `127.0.0.4:8088`:\n\n```\n{\n  luffy.goatcounter = {\n    serve = {\n      enable = true;\n      listenAddress = \"127.0.0.4\";\n      port = 8088;\n    };\n  };\n}\n```\n\nThe last step is to configure nginx to expose GoatCounter on the Internet. I\ndisable the `/count` endpoint as the local proxy handles it.\n\n``` js\n{ config, ... }:\nlet\n  cfg = config.luffy.goatcounter.serve;\nin\n{\n  services.nginx.virtualHosts.\"goatcounter.luffy.cx\" = {\n    forceSSL = true;\n    locations = {\n      \"/\" = {\n        proxyPass = \"http://${cfg.listenAddress}:${toString cfg.port}\";\n      };\n      \"= /count\".extraConfig = ''\n        return 404;\n      '';\n    };\n  };\n}\n```\n\n## GoatCounter proxy[#](#goatcounter-proxy)\n\nThe same [NixOS module](https://github.com/vincentbernat/nixops-take1/blob/master/modules/goatcounter.nix) configures the local proxy, with the\nfollowing options: `proxy.enable`, `proxy.listenAddress`, `proxy.port`, and\n`proxy.site`—the site receiving the batches of page views. The local proxy has\nno persistent data, but it needs the API key to authenticate to the main\nGoatCounter instance: its container uses the `keys` option but not the `mounts`\noption.\n\n``` js\n{ config, pkgs, lib, ... }:\nlet\n  cfg = config.luffy.goatcounter;\n  keyCommand = _: [ \"…\" ];\nin\n{\n  config.luffy.containers.goatcounter-proxy = {\n    keys.\"goatcounter-proxy.env\" = keyCommand \"GOATCOUNTER_API_KEY\";\n    config.systemd.services.goatcounter = {\n      description = \"GoatCounter Proxy.\";\n      wantedBy = [ \"multi-user.target\" ];\n      serviceConfig = {\n        EnvironmentFile = \"/etc/goatcounter-proxy.env\";\n        SupplementaryGroups = [ \"keys\" ];\n        DynamicUser = true;\n        Restart = \"always\";\n        ExecStart = lib.escapeShellArgs [\n          (lib.getExe cfg.package)\n          \"proxy\"\n          \"-site=${cfg.proxy.site}\"\n          \"-listen=${cfg.proxy.listenAddress}:${toString cfg.proxy.port}\"\n          \"-ratelimit=10/1\"  # 10 requests per second per IP\n        ];\n      };\n    };\n  };\n}\n```\n\nFor each server, I enable the local proxy with the following snippet. The\nnginx configuration shown earlier exposes the `/count` endpoint under the same\ndomain as my blog.\n\n```\n{\n  luffy.goatcounter = {\n    proxy = {\n      enable = true;\n      site = \"goatcounter.luffy.cx\";\n      listenAddress = \"127.0.0.3\";\n      port = 8087;\n    };\n  };\n}\n```\n\n## Backup of the SQLite database with Litestream[#](#backup-of-the-sqlite-database-with-litestream)\n\n[Litestream](https://litestream.io/) is a streaming replication tool for SQLite databases. It\ncompresses the changes committed to the write-ahead log (WAL) next to the\ndatabase and sends them to a remote destination. I encapsulate its configuration\nin a [NixOS module](https://github.com/vincentbernat/nixops-take1/blob/master/modules/litestream.nix), which takes an attribute set `databases`\nmapping a name to the path of the database to back up.\n\nLitestream also runs in a container. I mount the databases to replicate, as well\nas the secrets to push the backups to a [Hetzner storage box](https://www.hetzner.com/storage/storage-box/) using SFTP:\n\n``` js\n{ config, pkgs, lib, ... }:\nlet\n  cfg = config.luffy.litestream;\n  databaseDirs = lib.unique (map builtins.dirOf (builtins.attrValues cfg.databases));\nin\n{\n  config = lib.mkIf (cfg.databases != { }) {\n    luffy.containers.litestream = {\n      mounts = databaseDirs;\n      keys.\"litestream.env\" = [\n        \"${pkgs.runtimeShell}\"\n        \"-c\"\n        \"pass show personal/nixops/secrets | grep '^SQLITE_BACKUP_'\"\n      ];\n    };\n  };\n}\n```\n\nInside the container, I configure Litestream through NixOS’s\n`services.litestream` options:\n\n- full snapshots every day, kept for 15 days,\n- three [levels of compaction](https://fly.io/blog/litestream-v050-is-here/) for transaction files: 5 minutes, 30 minutes, and 3 hours,\n- auto-recovery,<sup>[7](#sidenote-auto-recover)</sup>\n- replica stored in a directory matching the host name, and\n- credentials read from `/etc/litestream.env` and exposed through variable expansion.\n\n``` js\n{ config, pkgs, lib, ... }:\nlet\n  cfg = config.luffy.litestream;\nin\n{\n  config.luffy.containers.litestream = {\n    config = {\n      # The databases belong to dynamically allocated users, whose UID is\n      # not known here, so Litestream runs as root.\n      systemd.services.litestream.serviceConfig = {\n        User = lib.mkForce \"root\";\n        Group = lib.mkForce \"root\";\n      };\n      # Use NixOS service.\n      services.litestream = {\n        enable = true;\n        environmentFile = \"/etc/litestream.env\";\n        settings = {\n          auto-recover = true;\n          snapshot = {\n            interval = \"24h\";\n            retention = \"360h\";\n          };\n          levels = [\n            { interval = \"5m\"; }\n            { interval = \"30m\"; }\n            { interval = \"3h\"; }\n          ];\n          dbs = lib.mapAttrsToList\n            (name: path: {\n              inherit path;\n              replica = {\n                type = \"sftp\";\n                host = \"\\${SQLITE_BACKUP_HOST}\";\n                user = \"\\${SQLITE_BACKUP_USER}\";\n                password = \"\\${SQLITE_BACKUP_PASSWORD}\";\n                host-key = \"\\${SQLITE_BACKUP_HOSTKEY}\";\n                path = \"${config.networking.hostName}/${name}\";\n              };\n            })\n            cfg.databases;\n        };\n      };\n    };\n  };\n}\n```\n\nTo back up GoatCounter’s database, I declare a `goatcounter` attribute in\n`luffy.litestream.databases`, set to the database path:\n\n``` js\n{ config, ... }:\nlet\n  cfg = config.luffy.goatcounter.serve;\nin\n{\n  luffy.litestream.databases.goatcounter = cfg.databaseFile;\n}\n```\n\nOn the SFTP server, we can inspect Litestream’s work, with the compacted transactions and the full snapshots:\n\n```\n❯ ls web02/goatcounter/ltx\nweb02/goatcounter/ltx/0\nweb02/goatcounter/ltx/1\nweb02/goatcounter/ltx/2\nweb02/goatcounter/ltx/3\nweb02/goatcounter/ltx/9\n❯ ls -lh web02/goatcounter/ltx/1\n29.1K Sep  5 01:25 0000000000003f2a-0000000000003f2b.ltx\n72.4K Sep  5 02:03 0000000000003f2c-0000000000003f2d.ltx\n63.3K Sep  5 02:24 0000000000003f2e-0000000000003f2f.ltx\n[…]\n❯ ls -lh web02/goatcounter/ltx/9\n 8.5M Sep  5 02:00 0000000000000001-0000000000003f2b.ltx\n 8.5M Sep  6 02:03 0000000000000001-0000000000004008.ltx\n 8.6M Sep  7 02:03 0000000000000001-00000000000043a8.ltx\n[…]\n```\n\nWe can restore the database from the backup with a few shell commands. First, we\nstop the containers. Then, we move the damaged database away, invoke ```\nlitestream\nrestore\n```\n from the right environment, and restart the containers.[8](#sidenote-slow)\n\n```\n# systemctl stop container@goatcounter container@litestream\n# mv /var/db/goatcounter/db.sqlite{,.old}\n# ( . /etc/nixos-containers/litestream.conf ; \n>   set -a ; . /var/keys/litestream.env ; set +a ;\n>   $SYSTEM_PATH/sw/bin/litestream \\\n>     restore -config $SYSTEM_PATH/etc/litestream.yml /var/db/goatcounter/db.sqlite)\n# ls -lh /var/db/goatcounter/db.sqlite\n-rw-r--r-- 1 root root 20M Sep 20 07:33 /var/db/goatcounter/db.sqlite\n# systemctl start container@goatcounter container@litestream\n```\n\nTen years after [removing Google Analytics](https://vincent.bernat.ch/en/blog/2018-more-privacy-blog), JavaScript-based analytics is\nback on this blog, but without storing cookies or IP addresses, and without\ninvolving a third party. I still write for myself first, notably because it lets\nme dig into a topic and refer back to it years later. But knowing a bit more\nabout my fellow human readers is a nice bonus, even the ones disabling\nJavaScript. 🐐", "url": "https://wpnews.pro/news/bot-free-self-hosted-analytics-with-goatcounter-on-nixos", "canonical_source": "https://vincent.bernat.ch/en/blog/2026-goatcounter", "published_at": "2026-09-20 22:51:03+00:00", "updated_at": "2026-09-21 00:23:27.558681+00:00", "lang": "en", "topics": ["ai-crawlers", "ai-tools"], "entities": ["Vincent Bernat", "GoatCounter", "GoAccess", "NixOS", "Bear Blog", "Umami", "Plausible", "Rybbit"], "alternates": {"html": "https://wpnews.pro/news/bot-free-self-hosted-analytics-with-goatcounter-on-nixos", "markdown": "https://wpnews.pro/news/bot-free-self-hosted-analytics-with-goatcounter-on-nixos.md", "text": "https://wpnews.pro/news/bot-free-self-hosted-analytics-with-goatcounter-on-nixos.txt", "jsonld": "https://wpnews.pro/news/bot-free-self-hosted-analytics-with-goatcounter-on-nixos.jsonld"}}