A small, purpose-built gateway service in front of a private GoatCounter instance — the only publicly reachable piece for site analytics, translating and rate-limiting requests so the underlying analytics backend never gets public exposure. Built for transparency (visitors can verify what's collected), not to promote web analytics.
  • Rust 56.5%
  • TypeScript 21.1%
  • Nix 15.9%
  • Shell 3%
  • Hurl 2.5%
  • Other 1%
Find a file
synapse 0928e0c55a
All checks were successful
ci / gate (push) Successful in 28s
release / release (push) Successful in 5m10s
Merge pull request 'chore: v0.3.2 — current-day fix release prep' (#21) from chore/v0.3.2 into develop
Reviewed-on: #21
2026-08-22 10:12:34 +00:00
.claude docs: Init OS 2026-08-21 01:25:43 +02:00
.forgejo chore: v0.2.0 — first release carrying the TS client 2026-08-21 21:59:38 +02:00
clients/ts docs: kill version rot; npm README learns about panels 2026-08-22 01:43:56 +02:00
docs docs: exposure is not collection — fix the verification framing 2026-08-22 03:46:57 +02:00
nix feat: panel exposure policy — rich stats, opt-in per deployment 2026-08-22 00:51:05 +02:00
openspec chore: bind repo to openspec store via store pointer 2026-08-21 02:22:53 +02:00
src fix: summary/pages no longer miss the current day 2026-08-22 11:52:06 +02:00
tests/e2e feat: panel exposure policy — rich stats, opt-in per deployment 2026-08-22 00:51:05 +02:00
.envrc feat: nix flake with dev shell (tasks 1.1, 1.2) 2026-08-21 04:02:59 +02:00
.gitignore feat: mdBook product docs skeleton (packages.docs) 2026-08-21 05:17:52 +02:00
Cargo.lock chore: v0.3.2 — current-day fix release 2026-08-22 11:55:44 +02:00
Cargo.toml chore: v0.3.2 — current-day fix release 2026-08-22 11:55:44 +02:00
CLAUDE.md feat: TypeScript client + <goatcounter-stats> web component (clients/ts) 2026-08-21 21:43:29 +02:00
flake.lock feat: release pipeline — static binaries, version identity, tag-driven releases 2026-08-21 17:54:44 +02:00
flake.nix docs: package README for the npm page 2026-08-21 22:47:35 +02:00
LICENSE Initial commit 2026-08-20 22:32:28 +00:00
openapi.json chore: v0.3.2 — current-day fix release 2026-08-22 11:55:44 +02:00
README.md docs: kill version rot; npm README learns about panels 2026-08-22 01:43:56 +02:00
result feat: binary cache wiring — CI-verified builds substitute on consumers 2026-08-21 19:32:02 +02:00

goatcounter-stats-gateway

A small gateway that sits in front of a private GoatCounter instance and exposes just enough to power a public stats page — without ever exposing GoatCounter itself.

The problem

GoatCounter is great, privacy-friendly self-hosted analytics with no cookies and no personal data. It even ships a built-in "public site" toggle that makes a site's dashboard viewable without login — no code required. That's not what this project uses, on purpose.

Turning that toggle on means GoatCounter's own web server has to sit on the public internet, at least for that one route. Keeping the rest private then comes down to a reverse-proxy rule that only forwards that one path — which means trusting that GoatCounter's own routing never grows a new endpoint under that prefix, never links to something non-public from that page, and no proxy-matcher edge case (trailing slash, encoding, a redirect) ever lets a request through it shouldn't. That's not a boundary you fully control; it's a bet on an app you don't maintain staying scoped the way you assumed, release after release. There's also no way to restyle that dashboard or shape its API to anything other than what GoatCounter itself decides to expose (including an explicitly unversioned one).

This gateway exists to remove that bet entirely. GoatCounter never gets a public listener, not even for one route — it stays reachable only from its own private network. The gateway is the only thing that's actually public, and it exposes a small, stable API it fully owns, not a pass-through — so a public stats page gets a contract that doesn't shift under it, and GoatCounter's process itself never has to handle a request from the internet.

How it works

visitor ──▶ gateway (public) ──▶ GoatCounter (private, internal-only)
  • Which stats are public is policy, not capability: the gateway implements the full panel set (referrers, browsers, locations, …) and each deployment opts in per panel — inspectable by anyone via /api/v1/panels.
  • The gateway owns its own versioned API contract (/api/v1, served and build-gated as openapi.json), independent of GoatCounter's unversioned one.
  • It caches its backend calls (with single-flight refresh and stale-serving through outages), so public traffic can never turn into request pressure on GoatCounter.
  • GoatCounter is never reachable from the outside, not even indirectly.
  • A typed TypeScript client and a framework-agnostic <goatcounter-stats> web component ship from this repo as @byteflavour/goatcounter-stats-client — version-locked to the gateway, usable from npm or straight from a CDN in plain HTML.

Install

NixOS (recommended — the repo is a flake exporting a hardened NixOS module, and a public binary cache means you never compile):

inputs.goatcounter-stats-gateway.url =
  "git+https://git.byteflavour.dev/synapse/goatcounter-stats-gateway?ref=refs/tags/vX.Y.Z";
imports = [ inputs.goatcounter-stats-gateway.nixosModules.default ];
services.goatcounter-stats-gateway = {
  enable = true;
  upstream = {
    url = "http://127.0.0.1:8081";
    host = "stats.example.com";
    tokenFile = "/run/secrets/gc-api-token";
  };
};

Any Linux (replace vX.Y.Z with the latest release tag): every release ships truly static binaries (x86_64/aarch64) with checksums — download, chmod +x, configure via GCSG_* env vars, run.

The stats page: npm add @byteflavour/goatcounter-stats-client, or zero-toolchain via CDN:

<script type="module" src="https://esm.sh/@byteflavour/goatcounter-stats-client/element"></script>
<goatcounter-stats gateway="https://stats.example.com" period="7d"></goatcounter-stats>

The full walkthrough — token creation (GoatCounter's CLI has a permission gap worth knowing about), systemd unit for non-NixOS hosts, binary cache trust, reverse-proxy notes — lives in the documentation book: the Installation and Configuration chapters. The rendered book ships with every release (docs-vX.Y.Z.tar.gz) and builds locally with nix build .#docs.

Status

In production. The v1 API — summary, top pages, and seven further opt-in panels (referrers, browsers, systems, sizes, locations, languages, campaigns) — is cached, stale-tolerant, and fully observable (Prometheus metrics, native journald log priorities). Every change passes a gate that includes a NixOS VM booting the real service topology — gateway beside a live seeded GoatCounter — with an end-to-end hurl contract suite, and both the server and the TypeScript client are build-gated against the same committed OpenAPI snapshot.

License

MIT, see LICENSE.