personal-presence-os

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

  1. Works across all five domains from a single installation
  2. Runs on the existing Hetzner CX22 VPS (2 vCPU, 4 GB RAM, ~$4/month) without crowding out the engine
  3. Privacy-friendly — no cookies, no consent banners, no tracking pixels
  4. Self-hosted — no third-party dependency for core analytics data
  5. Provides a dashboard and an API for querying data
  6. 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.