Umami¶
Umami counts the visits to every site: the two
documentation sites, the GitHub Pages projects and Home Assistant.
It sets no cookie and stores no address, so the sites need no consent banner.
Upstream publishes an image and no chart, so umami/ is plain manifests
around the official image: deployment.yaml, service.yaml, pdb.yaml,
httproute.yaml, database.yaml with backup.yaml, secrets.yaml,
networkpolicy.yaml and namespace.yaml, per
Conventions → Official upstream sources only.
At a glance¶
| URL | analytics.k8s.wlkr.ch |
| Authentication | Authentik proxy outpost, in front of Umami's own login; /script.js and /api/send pass through |
| Storage | None outside the database |
| Database | CloudNativePG Cluster umami-db |
| Secrets | kv/umami/config in OpenBao |
| Files | umami/ |
Configuration¶
| Setting | Why |
|---|---|
| Plain manifests, no chart | One stateless process with one port and one database; the community charts are repackagers |
The namespace enforces restricted |
The image runs as uid 1001, needs no capability, and writes only Next.js' cache, which is an emptyDir next to /tmp |
automountServiceAccountToken: false |
Umami never talks to the API server |
DATABASE_URL from umami-db-app, key uri |
Umami wants one connection string, and CloudNativePG assembles it; see the contract |
APP_SECRET and TWO_FACTOR_ENCRYPTION_KEY from OpenBao |
The first signs every dashboard session and the second encrypts TOTP secrets; both must survive a rebuild, so the PushSecret writes each to OpenBao once and a rebuilt cluster reads the same value back. See Secrets |
DISABLE_UPDATES, DISABLE_TELEMETRY, PRIVATE_MODE |
No phone-home: no version check, no usage report, no favicon fetch for each tracked site. The egress policy allows nothing outside the cluster, so these are what keep the log free of drops |
Two replicas, a hard spread across nodes, a PodDisruptionBudget |
Nothing lives on the pod and the sessions are JWTs signed by the shared APP_SECRET, so a second instance keeps the tracker up through a Kured drain and an image bump; the migrations at start take a Postgres advisory lock, so two pods starting together do not race |
Probes on /api/heartbeat, a long startupProbe |
The first start runs the Prisma migrations before the server answers |
revisionHistoryLimit: 2 |
Superseded ReplicaSets keep their Trivy config-audit reports until garbage collection; the default keeps ten |
HTTPRoute targets authentik-server, not the app |
The outpost authenticates and forwards to umami.umami.svc:3000. The provider's skip_path_regex, in the platform repository, exempts exactly /script.js and /api/send: the tracker every visitor loads and the endpoint it posts to. Everything else, the dashboard and the rest of the API, is behind the login. The cross-namespace backendRef works only because the platform's referencegrant.yaml names this namespace; pointing the route at the Umami Service removes the authentication layer, so it deserves a second look in review |
No CLIENT_IP_HEADER |
The Gateway's Envoy appends the visitor to X-Forwarded-For and the outpost passes it on; Umami reads that header by default. Only the country is stored, from the bundled GeoLite database, never the address |
Network policy¶
The rules in umami/networkpolicy.yaml, and why each is there. The shape is
Conventions → Ship a CiliumNetworkPolicy;
rollout and debugging are in
Security Policies.
| Rule | Why |
|---|---|
Ingress from the namespace, from host and remote-node; egress to the namespace |
The rules every policy carries: What every policy contains |
| Ingress on 3000 from the Authentik server pods only | The outpost proxies the tracker paths as well as the dashboard, so the Gateway never talks to the pod. No fromEntities: ingress, so a request straight from the Gateway cannot skip the login |
Ingress from Prometheus on 9187, GET /metrics only |
The database's metrics. Umami itself exposes none |
Ingress from cnpg-system on 8000 |
The operator polls the instance manager there; without the rule the Cluster never goes Healthy and the sync deadlocks. See Sync stuck on the database |
| Egress to the namespace only | The database. With the three phone-home switches off, nothing else is dialled |
Egress from the database pod to kube-apiserver |
The instance manager reports its status there; its liveness check fails otherwise. The object store for WAL and base backups is the platform's cnpg-backups policy, see CloudNativePG → Backups |
Secrets¶
kv/umami/config holds two values, 64 hex characters each as Umami documents
them, written once by the PushSecret in secrets.yaml and never
overwritten — see Generated secrets.
| Key | Read as |
|---|---|
app-secret |
APP_SECRET: signs the dashboard sessions |
two-factor-encryption-key |
TWO_FACTOR_ENCRYPTION_KEY: encrypts TOTP secrets at rest |
The ExternalSecret sits at sync wave -1, with the database and ahead of
the Deployment that reads it, and keeps its Secret when deleted
(deletionPolicy: Retain). The database password is not here; CloudNativePG
generates it and nothing outside the cluster needs it.
Usage¶
First login¶
Umami creates admin with the password umami on its first start. Change it
under Profile before adding a website; it is the only account, and the only
credential Umami keeps that OpenBao does not.
Adding a site¶
- Settings → Websites → Add website: a name and the hostname. The website ID it shows is not a secret; it goes into Git with the snippet.
-
Put the snippet in the site's
<head>, withdata-domainsset to the hostname it should report from:
data-domains is what keeps a local zensical serve or npm run dev out of
the numbers: the tracker checks the page's hostname before it sends anything.
A PR preview under the production hostname is not excluded and counts as a
page there.
Where the snippet lives per site:
| Site | Where |
|---|---|
| This site and the platform documentation | overrides/partials/integrations/analytics/custom.html, wired by [project.extra.analytics] provider = "custom" in zensical.toml |
| A SvelteKit or Vite site on GitHub Pages | src/app.html or index.html |
| Home Assistant | frontend.extra_module_url in configuration.yaml, pointing at a module in the ConfigMap that appends the <script> tag; a module is loaded through import(), which is not how the tracker expects to be run |
Health check¶
kubectl -n umami get pods
kubectl -n umami get cluster umami-db
curl -sI https://analytics.k8s.wlkr.ch/script.js | head -1
The database should report Cluster in healthy state and the script should
answer 200 without a redirect to auth.k8s.wlkr.ch. The dashboard's
Realtime view is the end-to-end check: open one of the sites and the visit
appears within seconds.
Pitfalls¶
Two logins, and the tracker paths behind neither
Browser users authenticate twice, and /script.js and /api/send are behind neither login; the collect endpoint accepts nothing but page views and events for a known website ID. See Authentik → Pitfalls.
- A visit shows the cluster's country, or none. Umami takes the visitor
from
X-Forwarded-For. If every visit resolves to the same place, the header is not reaching the pod: see Behind the Gateway. - Deleting
kv/umami/configlogs everyone out. ThePushSecretwrites both keys anew: a rotatedAPP_SECRETinvalidates every session and a rotated 2FA key every second factor. data-domainsis the only guard against noise. A snippet without it reports from every copy of the site: a laptop, a preview, a fork.