Analytics setup
Why analytics matters early
You can't build engagement features — newsletters, comments, community — without first knowing whether anyone is visiting. Analytics is the prerequisite. Without traffic data, you're building in the dark: adding share buttons nobody clicks, email forms nobody fills in, comment systems nobody uses.
The goal here was simple: know which pages get visited, where visitors come from, and which content resonates — before investing in anything interactive.
Requirements
- Works across all five domains from a single installation
- Runs on the existing Hetzner CX22 VPS (2 vCPU, 4 GB RAM, ~$4/month) without crowding out the engine
- Privacy-friendly — no cookies, no consent banners, no tracking pixels
- Self-hosted — no third-party dependency for core analytics data
- Provides a dashboard and an API for querying data
- Cheap — ideally free beyond the existing server cost
Alternatives considered
| Option | Dependencies | RAM | Cost | Verdict |
|---|---|---|---|---|
| Plausible CE | Elixir + PostgreSQL + ClickHouse | ~1.5–2 GB | Free | Too heavy. ClickHouse alone would eat half the VPS. |
| Umami | Node + PostgreSQL | ~256 MB | Free | Lighter, but still adds a database server to maintain and back up. |
| Cloudflare Analytics | None | 0 | Free | Already in the stack, but limited: no per-path detail, no API, no self-hosted option. |
| Server-side logging | None | ~0 | Free | Zero dependencies but no dashboard, no referrer parsing. Would need to build everything from scratch. |
| GoatCounter | Single Go binary + SQLite | ~50 MB | Free | Fits perfectly. One binary, file-based storage, privacy-first, has an API. |
GoatCounter won on every criterion. Single binary, SQLite for storage, ~50 MB RAM, no cookies, clean dashboard, REST API. It's the only option that fits the budget, the server, and the principles.
Setup
Install GoatCounter
Download the binary and create a system user:
curl -L https://github.com/arp242/goatcounter/releases/download/v2.5.0/goatcounter-v2.5.0-linux-amd64.gz | gunzip > /usr/local/bin/goatcounter
chmod +x /usr/local/bin/goatcounter
useradd --system --no-create-home --shell /usr/sbin/nologin goatcounter
mkdir -p /var/lib/goatcounter
chown goatcounter:goatcounter /var/lib/goatcounter
Create the database and admin account
goatcounter db create site \
-vhost stats.kda.zone \
-user.email YOUR_EMAIL \
-db sqlite3+/var/lib/goatcounter/goatcounter.db \
-createdb
Note: the connection string format is sqlite3+/path (not sqlite3:///path). The older :// syntax is deprecated.
Run as a systemd service
[Unit]
Description=GoatCounter analytics
After=network.target
[Service]
Type=simple
User=goatcounter
Group=goatcounter
ExecStart=/usr/local/bin/goatcounter serve \
-listen localhost:8081 \
-tls proxy \
-db sqlite3+/var/lib/goatcounter/goatcounter.db
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Key flags:
-listen localhost:8081— only binds locally. Public traffic arrives through the engine's reverse proxy.-tls proxy— tells GoatCounter it's behind a TLS-terminating reverse proxy (Cloudflare). Without this, session cookies and CSRF tokens break.-db sqlite3+/path— SQLite, no external database server.
systemctl daemon-reload
systemctl enable goatcounter
systemctl start goatcounter
DNS and routing
Add a Cloudflare DNS record: stats → A record → VPS IP, proxied (orange cloud).
The engine's Hono server handles routing. When it sees Host: stats.kda.zone, it proxies the entire request to GoatCounter on localhost:8081. No nginx, no Caddy, no new services. The proxy is configured in sites.yaml under analytics.goatcounter.endpoint.
Tracking script
The engine injects a GoatCounter <script> tag into every page at build time. The tag points to stats.kda.zone/count and loads count.js from the same origin. Since all five domains include the same tracking endpoint, a single GoatCounter installation captures traffic across the entire presence.
No cookies are set. No personal data is collected. The script is ~3.5 KB.
Permissions gotcha
If GoatCounter reports "attempt to write a readonly database", the SQLite file was created with root ownership but GoatCounter runs as user goatcounter:
chown goatcounter:goatcounter /var/lib/goatcounter/goatcounter.db
systemctl restart goatcounter
What this costs
Nothing beyond the existing $4/month VPS. GoatCounter is free, self-hosted, and uses resources the server already has. The SQLite database will stay small — even at thousands of pageviews per day, it grows by kilobytes.
What's next
GoatCounter gives us the baseline: are people visiting, what are they reading, where did they come from. Once there's enough data to see patterns, the next step is share-token tracking — unique URL parameters that attribute visits to specific share events — followed by engagement features like email capture and comments.
But analytics comes first. You measure before you build.