ADR-081 — Self-hosted Umami analytics (env-var-gated, no committed IDs)
Status · Accepted Date · 2026-07-22 Related ADRs · ADR-067 (Sentry self-hosted error tracking — the sibling pattern), ADR-016 (no client storage), ADR-057 / ADR-075 (functional-cookie exceptions) Reuses · the GlitchTip telemetry-vhost shape (browser → Cloudflare → origin-locked, tracking-only edge → homelab), applied to Umami
Context
Orrery has always run Umami for privacy-respecting, cookieless UX analytics (src/lib/analytics.ts — a typed event registry + custom-event helpers wired across ~15 components). Until now it pointed at the cloud.umami.is SaaS and was gated by a hostname allowlist pinned to the old GitHub Pages mirror (chipi.github.io/orrery).
Two things forced a change when the site moved to orrerylearn.com (VPS + CF):
- The hostname gate meant analytics never fired on the real production domain. It only counted the GitHub Pages mirror;
orrerylearn.comtraffic was invisible. - The estate went self-hosted. Logs moved to VictoriaLogs and errors to self-hosted GlitchTip (ADR-067). Analytics was the last SaaS dependency in the observability stack; keeping it on
cloud.umami.issplit the estate and left third-party data egress in place.
Decision
Move to self-hosted Umami and switch enablement from hostname-gating to env-var-gating, mirroring the Sentry pattern in ADR-067.
- Edge (owned by the infra repo, not Orrery):
analytics.orrerylearn.com— browser → Cloudflare (valid cert, origin-locked to CF IPs on :443) → a tracking-only edge exposing just/script.js+/api/send→ homelab Umami. Identical secure shape to the GlitchTiptelemetry.orrerylearn.comvhost. The admin dashboard (/login,/api/auth, …) is not publicly exposed. - App wiring (this repo):
src/lib/analytics.tsloads the tracker and fires events only when bothPUBLIC_UMAMI_HOSTandPUBLIC_UMAMI_WEBSITE_IDare baked into the build (and not invite dev). The deploy wiring mirrors Sentry (ADR-067) exactly: the fixed edge host is hardcoded in the workflow (likePUBLIC_SENTRY_ENVIRONMENT: prod), and the single operator-set value — the website id — lives in one secretPUBLIC_UMAMI_WEBSITE_ID, likePUBLIC_SENTRY_DSN. The id is public (it ships in the bundle); the secret only keeps it out of the repo. So the operator sets exactly one value, same as Sentry. - nginx CSP (
ops/docker/nginx.conf):script-src+connect-srcallowhttps://analytics.orrerylearn.com(replacingcloud.umami.is).
Why env-var-gated (not hostname-gated)
ADR-067 §"Env-var-gated, not hostname-gated" already argued this for Sentry and called out that Umami's hostname allowlist was the weaker pattern. This ADR finishes that alignment:
- Fires on the actual production domain without an allowlist to keep in sync.
- Fork-friendly by construction: a fork populates its own
PUBLIC_UMAMI_*in its CI and gets its own dashboard without editing source. - Silent by default everywhere else: empty vars → no script injected, every
track()a no-op. Local dev / vite preview / CI / screenshots stay silent;devis additionally hard-blocked. - No IDs committed: the website ID lives in a secret, not in the tree.
Consequences
- One value must be set before analytics goes live: register the Orrery website in the self-hosted Umami admin (over the tailnet) → set the
PUBLIC_UMAMI_WEBSITE_IDsecret (the host is already hardcoded in the deploy) → next deploy bakes it in. Until then the build ships analytics-silent (safe). - Historical
cloud.umami.isdata is not migrated — it was thin (the gating bug meant near-zeroorrerylearn.comdata anyway); the self-hosted instance starts fresh. - The privacy copy was updated to match the self-hosted reality: README §Privacy (Umami + Sentry paragraphs → self-hosted
analytics./telemetry.edges, env-gating,orrerylearn.com) and the/creditscredits_storage_introstring (reworded across all 14 locales — "no analytics" → "anonymous, cookieless usage analytics and crash reports, self-hosted and never used to identify you"). The honest posture is now stronger than before (fully self-hosted, no third-party SaaS), not weaker.
Not covered / out of scope
- The public-vhost hardening that hides the Umami dashboard (
/login) is the infra repo's responsibility, not this ADR. - No change to the event registry, the privacy posture (cookieless, PII-free, length-capped search text), or the typed-helper API — only the transport target and the enablement gate moved.
Amendment — 2026-07-24 (ADR-082, telemetry env ladder)
The "silent by default everywhere else / dev additionally hard-blocked" posture in §"Why env-var-gated" was extended into a three-rung ladder by ADR-082 — the current source of truth. Two additions this ADR did not cover:
- Staging rung. The
chipi.github.io/orrerygh-pages deploy now sets its ownPUBLIC_UMAMI_WEBSITE_ID(a separate Umami site,6e7ddfce…) from thestagingGH environment, so staging usage never mixes into the prod site. - Dev rung.
vite devis no longer hard-blocked — with no override it falls back to a dedicated dev Umami site (1d2f214c…) athttp://homelab:3001, reachable only over the Tailscalehomelabhost. Fork-silence is preserved by construction (that host doesn't resolve off the tailnet), not by thedevblock.vite preview/ CI / screenshots stay silent (notdev, no baked vars).