Spec 0004: Umami Analytics
- Save this spec to
.sdd/specification/spec-0004-umami.mdin the repo (create the.sdd/specification/directory if it does not exist). - Implement all Terraform and Kubernetes manifest changes described below.
- After completing all work, create a provenance record at
.sdd/provenance/spec-0004-umami.provenance.md(create the.sdd/provenance/directory if it does not exist). See the Provenance Record section for the required format.
Prerequisites
Section titled “Prerequisites”- Spec 0002 deployed: PostgreSQL Flexible Server running,
umami_dbdatabase created, credentials in Key Vault (pg-admin-password,pg-fqdn,pg-admin-username) - Spec 0003 deployed: ESO running, ClusterSecretStore
azure-keyvaultisValidandReady - Read ADR-003 (
docs/adr/adr-003-self-host-umami-analytics.md) — the architectural decision to self-host Umami
Context
Section titled “Context”ADR-003 mandates self-hosted Umami analytics backed by PostgreSQL. The database (umami_db) and secret pipeline (Key Vault -> ESO -> K8s Secret) are already in place from Specs 0002 and 0003. This spec deploys Umami as a Kubernetes workload, creates its ExternalSecret for database credentials, wires up DNS and ingress, and adds APP_SECRET to Key Vault.
Current state (read these files before making changes)
Section titled “Current state (read these files before making changes)”| File / Directory | What it does |
|---|---|
k8s/kevinryan-io/deployment.yaml | Example deployment pattern — follow resource limits and probe style |
k8s/kevinryan-io/ingress.yaml | Example IngressRoute — Traefik CRD with websecure entryPoint and tls: {} |
k8s/kevinryan-io/service.yaml | Example service pattern |
k8s/kevinryan-io/namespace.yaml | Example namespace pattern |
k8s/flux-system/kustomization.yaml | Resource list — needs new entry |
k8s/flux-system/kevinryan-io-sync.yaml | Pattern for Flux Kustomization CR |
k8s/external-secrets/clustersecretstore.yaml | ClusterSecretStore azure-keyvault already deployed |
infra/main.tf | Root Terraform — needs small additions for APP_SECRET and DNS |
Key facts
Section titled “Key facts”- Umami image:
ghcr.io/umami-software/umami:postgresql-latest(public GHCR image, PostgreSQL-optimized build) - Container port:
3000 - Health check endpoint:
/api/heartbeat - Required env vars:
DATABASE_URL,APP_SECRET,DISABLE_TELEMETRY - DATABASE_URL format:
postgresql://<user>:<password>@<host>:5432/umami_db?sslmode=require - Subdomain:
analytics.kevinryan.io - Key Vault secrets already available:
pg-admin-password,pg-fqdn,pg-admin-username - Key Vault secret to add:
umami-app-secret(random hex hash for data anonymisation)
1. Terraform changes (small)
Section titled “1. Terraform changes (small)”Add APP_SECRET to Key Vault
Section titled “Add APP_SECRET to Key Vault”Add to infra/main.tf (after the existing azurerm_key_vault_secret.pg_admin_username block):
resource "random_password" "umami_app_secret" { length = 64 special = false}
resource "azurerm_key_vault_secret" "umami_app_secret" { name = "umami-app-secret" value = random_password.umami_app_secret.result key_vault_id = module.keyvault.key_vault_id}Add DNS record for analytics.kevinryan.io
Section titled “Add DNS record for analytics.kevinryan.io”Add to infra/main.tf (after the existing module.cloudflare block, NOT inside it):
resource "cloudflare_record" "analytics" { zone_id = var.cloudflare_zone_id name = "analytics" content = module.network.public_ip_address type = "A" proxied = true ttl = 1}Why a standalone record instead of adding to subdomains: The Cloudflare module’s subdomains list also includes subdomains in an aggressive cache rule (edge TTL override). Umami serves dynamic API responses that must not be cached. A standalone cloudflare_record creates the DNS A record with Cloudflare proxy (DDoS protection, SSL) but WITHOUT the custom cache rule.
No other Terraform changes
Section titled “No other Terraform changes”No changes to variables, outputs, or any modules.
2. Kubernetes manifests
Section titled “2. Kubernetes manifests”Create k8s/umami/ with the following files:
namespace.yaml
Section titled “namespace.yaml”apiVersion: v1kind: Namespacemetadata: name: umamiexternalsecret.yaml
Section titled “externalsecret.yaml”apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: umami-db namespace: umamispec: refreshInterval: 1h secretStoreRef: kind: ClusterSecretStore name: azure-keyvault target: name: umami-db creationPolicy: Owner template: engineVersion: v2 data: DATABASE_URL: "postgresql://{{ .pg_admin_username }}:{{ .pg_admin_password }}@{{ .pg_fqdn }}:5432/umami_db?sslmode=require" APP_SECRET: "{{ .umami_app_secret }}" data: - secretKey: pg_admin_username remoteRef: key: pg-admin-username - secretKey: pg_admin_password remoteRef: key: pg-admin-password - secretKey: pg_fqdn remoteRef: key: pg-fqdn - secretKey: umami_app_secret remoteRef: key: umami-app-secretDesign notes:
template.dataconstructsDATABASE_URLfrom individual Key Vault secrets — no connection string stored in Key Vault, reducing secret sprawl.sslmode=requirebecause Azure PostgreSQL Flexible Server enforces SSL by default.refreshInterval: 1hmeans credential rotation in Key Vault propagates within an hour.- The resulting K8s Secret
umami-dbcontains two keys:DATABASE_URLandAPP_SECRET.
deployment.yaml
Section titled “deployment.yaml”apiVersion: apps/v1kind: Deploymentmetadata: name: umami namespace: umamispec: replicas: 1 selector: matchLabels: app: umami template: metadata: labels: app: umami spec: containers: - name: umami image: ghcr.io/umami-software/umami:postgresql-latest ports: - containerPort: 3000 envFrom: - secretRef: name: umami-db env: - name: DISABLE_TELEMETRY value: "1" livenessProbe: httpGet: path: /api/heartbeat port: 3000 initialDelaySeconds: 30 periodSeconds: 30 readinessProbe: httpGet: path: /api/heartbeat port: 3000 initialDelaySeconds: 10 periodSeconds: 10 resources: requests: cpu: 50m memory: 128Mi limits: cpu: 500m memory: 512MiDesign notes:
envFrom.secretRefinjects all keys from theumami-dbSecret as env vars (DATABASE_URL,APP_SECRET).DISABLE_TELEMETRY=1set directly as an env var (not a secret, not sensitive).- Resource limits are generous for initial startup (Umami runs Prisma migrations on first boot, which can be CPU/memory intensive). Steady-state usage is ~200MB RAM.
initialDelaySeconds: 30for liveness gives Umami time to run migrations on first startup.- Image is from public GHCR — no ACR credentials needed.
service.yaml
Section titled “service.yaml”apiVersion: v1kind: Servicemetadata: name: umami namespace: umamispec: selector: app: umami ports: - port: 80 targetPort: 3000ingress.yaml
Section titled “ingress.yaml”apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: umami namespace: umamispec: entryPoints: - websecure routes: - match: Host(`analytics.kevinryan.io`) kind: Rule services: - name: umami port: 80 tls: {}Design notes:
- Follows the exact same Traefik IngressRoute pattern as
kevinryan-io,brand-kevinryan-io, etc. tls: {}uses Traefik’s default TLS certificate (Cloudflare handles SSL termination at the edge; Traefik handles it between Cloudflare and the pod since Cloudflare is set to Full SSL mode).
3. Flux sync
Section titled “3. Flux sync”Create k8s/flux-system/umami-sync.yaml:
apiVersion: kustomize.toolkit.fluxcd.io/v1kind: Kustomizationmetadata: name: umami namespace: flux-systemspec: dependsOn: - name: external-secrets-store interval: 10m0s path: ./k8s/umami prune: true sourceRef: kind: GitRepository name: flux-systemWhy dependsOn: external-secrets-store: The Umami ExternalSecret references the azure-keyvault ClusterSecretStore. While the ExternalSecret CRD exists (ESO is installed), the ClusterSecretStore must be Ready for the ExternalSecret to sync. The dependsOn ensures the Umami manifests are only applied after the ClusterSecretStore is healthy.
4. Update k8s/flux-system/kustomization.yaml
Section titled “4. Update k8s/flux-system/kustomization.yaml”Add umami-sync.yaml to the resources list:
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - gotk-components.yaml - gotk-sync.yaml - kevinryan-io-sync.yaml - brand-kevinryan-io-sync.yaml - aiimmigrants-com-sync.yaml - specmcp-ai-sync.yaml - sddbook-com-sync.yaml - distributedequity-org-sync.yaml - docs-kevinryan-io-sync.yaml - external-secrets-sync.yaml - external-secrets-store-sync.yaml - umami-sync.yamlManual steps (not performed by the agent)
Section titled “Manual steps (not performed by the agent)”Terraform apply (before or after merge — the K8s manifests won’t work until Key Vault has umami-app-secret)
Section titled “Terraform apply (before or after merge — the K8s manifests won’t work until Key Vault has umami-app-secret)”cd infraterraform plan # Expect: 1 random_password + 1 KV secret + 1 Cloudflare record = 3 new resourcesterraform applyVerify:
az keyvault secret list --vault-name kv-kevinryan-io --query "[].name" -o tsv# Should include: umami-app-secret (alongside existing pg-* secrets)
nslookup analytics.kevinryan.io# Should resolve to node1 public IP (40.67.240.128) via Cloudflare proxyAfter merge to main — Flux reconciliation
Section titled “After merge to main — Flux reconciliation”az vm run-command invoke \ --resource-group rg-kevinryan-io \ --name vm-kevinryan-node1 \ --command-id RunShellScript \ --scripts "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml && flux reconcile kustomization flux-system --with-source"Wait 2-3 minutes for Umami to start (first boot runs Prisma migrations), then verify:
az vm run-command invoke \ --resource-group rg-kevinryan-io \ --name vm-kevinryan-node1 \ --command-id RunShellScript \ --scripts "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml && echo '=== ExternalSecret ===' && kubectl get externalsecret -n umami && echo '=== Pods ===' && kubectl get pods -n umami && echo '=== Service ===' && kubectl get svc -n umami && echo '=== IngressRoute ===' && kubectl get ingressroute -n umami"Final check — hit the health endpoint:
curl -k https://analytics.kevinryan.io/api/heartbeatShould return ok.
Default admin login: admin / umami — change immediately after first login.
Provenance Record
Section titled “Provenance Record”After completing the work, create .sdd/provenance/spec-0004-umami.provenance.md with the following structure:
# Provenance: Spec 0004 — Umami Analytics
**Spec:** `.sdd/specification/spec-0004-umami.md`**Executed:** <timestamp>**Agent:** <agent identifier if available>
## Actions Taken
Chronological list of every action performed (files created, files modified, commands run).
## Decisions Made
Any decisions the agent made during execution that were not explicitly specified in the spec. For each:
| Decision | Options Considered | Chosen | Rationale ||----------|-------------------|--------|-----------|| ... | ... | ... | ... |
If no autonomous decisions were required, state: "No autonomous decisions were required — all actions were explicitly specified in the spec."
## Deviations from Spec
Any points where the agent deviated from the spec, and why. If none, state: "No deviations from spec."
## Artifacts Produced
| File | Status ||------|--------|| ... | Created / Modified |
## Validation Results
Results of each validation step from the spec (pass/fail with details).Validation steps
Section titled “Validation steps”After completing all work, confirm:
- This spec has been saved to
.sdd/specification/spec-0004-umami.md infra/main.tfcontainsrandom_password.umami_app_secret,azurerm_key_vault_secret.umami_app_secret, andcloudflare_record.analytics- No other Terraform files (variables, outputs, modules) were modified
k8s/umami/exists with exactly 5 files:namespace.yaml,externalsecret.yaml,deployment.yaml,service.yaml,ingress.yaml- The ExternalSecret uses
template.datato constructDATABASE_URLfrom individual Key Vault secrets (not a pre-built connection string) - The ExternalSecret references
ClusterSecretStorenamedazure-keyvault - The Deployment uses
ghcr.io/umami-software/umami:postgresql-latestwithenvFrom.secretRefforumami-db - The Deployment includes
DISABLE_TELEMETRY=1, liveness/readiness probes on/api/heartbeat:3000, and resource limits - The Service maps port 80 -> 3000
- The IngressRoute matches
Host(\analytics.kevinryan.io`)withwebsecureentryPoint andtls: {}` k8s/flux-system/umami-sync.yamlexists withdependsOn: [{name: external-secrets-store}]k8s/flux-system/kustomization.yamlincludesumami-sync.yamlterraform fmt -check -recursive infra/passespnpm lintpasses- The provenance record exists at
.sdd/provenance/spec-0004-umami.provenance.mdand contains all required sections - All files (spec, Terraform changes, K8s manifests, provenance) are committed together