- Rust 56.5%
- TypeScript 21.1%
- Nix 15.9%
- Shell 3%
- Hurl 2.5%
- Other 1%
|
|
||
|---|---|---|
| .claude | ||
| .forgejo | ||
| clients/ts | ||
| docs | ||
| nix | ||
| openspec | ||
| src | ||
| tests/e2e | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| openapi.json | ||
| README.md | ||
| result | ||
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 asopenapi.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.