A visitor counter that never writes the raw address.
This small self-hosted service converts the request address into a secret-keyed HMAC in memory, stores only that hash in SQLite, handles browser CORS, excludes common bots and internal checks, and returns aggregate totals—not visitor records.
Request → volatile address → keyed hash → aggregate count.
- Accept only
POST /trackfrom the configured site origin. - Trust
X-Forwarded-Foronly when the immediate peer is explicitly allowlisted. - Drop empty and common bot user agents plus requests carrying the private internal-check token.
- Compute
HMAC-SHA256(secret, address)and discard the address variable. - Upsert the hash and expose only
unique_visitorsandpageviewsthroughGET /stats.
Small enough to audit before running.
server.mjs ↓Node HTTP server, HMAC hashing, bot exclusion, SQLite upsertPlain-text sourceDockerfile ↓Unprivileged Node 22 container with a persistent /data volumePlain-text sourcecompose.yaml ↓Loopback-only port, secrets, trusted-proxy allowlist, named volumePlain-text sourceLICENSE ↓MIT licensePlain-text sourceSet the secrets and proxy address deliberately.
mkdir hmac-counter && cd hmac-counter
# Download the four files above into this directory.
# Edit compose.yaml: set ALLOWED_ORIGIN, two random secrets,
# and the exact address of your trusted reverse proxy.
docker compose up -d --build
curl http://127.0.0.1:8787/health
curl http://127.0.0.1:8787/statsThe compose example binds the service to loopback. Put it behind your existing TLS reverse proxy; do not expose the tracking endpoint directly without rate limiting.
Pseudonymous is not anonymous.
A stable HMAC still links repeated requests that share an address. Rotate the secret when you no longer need historical continuity, restrict access to the SQLite volume, document the measurement, and use a shorter retention window if your purpose permits it. Shared NATs also collapse multiple people into one hash; changing addresses may split one person into several.
What the included checks cover.
- Confirm the database schema contains
visitor_hashbut no raw-address column. - Send the same address twice and expect one unique visitor with two pageviews.
- Send a common crawler and the internal-check token and confirm totals do not change.
- Send a forged forwarded address from an untrusted peer and confirm it cannot create another visitor.
- Send a forwarded address through an allowlisted IPv4-mapped proxy and confirm it is counted.