Skip to content

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):

  1. The hostname gate meant analytics never fired on the real production domain. It only counted the GitHub Pages mirror; orrerylearn.com traffic was invisible.
  2. 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.is split 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 GlitchTip telemetry.orrerylearn.com vhost. The admin dashboard (/login, /api/auth, …) is not publicly exposed.
  • App wiring (this repo): src/lib/analytics.ts loads the tracker and fires events only when both PUBLIC_UMAMI_HOST and PUBLIC_UMAMI_WEBSITE_ID are baked into the build (and not in vite dev). The deploy wiring mirrors Sentry (ADR-067) exactly: the fixed edge host is hardcoded in the workflow (like PUBLIC_SENTRY_ENVIRONMENT: prod), and the single operator-set value — the website id — lives in one secret PUBLIC_UMAMI_WEBSITE_ID, like PUBLIC_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-src allow https://analytics.orrerylearn.com (replacing cloud.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; dev is 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_ID secret (the host is already hardcoded in the deploy) → next deploy bakes it in. Until then the build ships analytics-silent (safe).
  • Historical cloud.umami.is data is not migrated — it was thin (the gating bug meant near-zero orrerylearn.com data 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 /credits credits_storage_intro string (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/orrery gh-pages deploy now sets its own PUBLIC_UMAMI_WEBSITE_ID (a separate Umami site, 6e7ddfce…) from the staging GH environment, so staging usage never mixes into the prod site.
  • Dev rung. vite dev is no longer hard-blocked — with no override it falls back to a dedicated dev Umami site (1d2f214c…) at http://homelab:3001, reachable only over the Tailscale homelab host. Fork-silence is preserved by construction (that host doesn't resolve off the tailnet), not by the dev block. vite preview / CI / screenshots stay silent (not dev, no baked vars).

Orrery — architecture documentation · MIT · No tracking