Skip to content

Umami Analytics

This platform uses Umami, a self-hosted, privacy-focused web analytics tool, to track visitor activity across all seven sites. Umami is deployed as a Kubernetes workload within the cluster, backed by Azure PostgreSQL, and accessible at analytics.kevinryan.io.

Umami was selected over third-party analytics services for several reasons:

  • Privacy by design. Umami does not use cookies, does not collect personal data, and does not track users across sites. This means no cookie consent banners are needed and the platform complies with GDPR, CCPA, and similar regulations without configuration.
  • Self-hosted. All analytics data stays within the platform’s own infrastructure — on the K3s cluster and Azure PostgreSQL database. No data is sent to third parties.
  • Lightweight. The tracking script (script.js) is ~2KB. It has no impact on page load performance and does not block rendering.
  • No sampling. Every page view is recorded. There is no data sampling or aggregation that loses fidelity at low traffic volumes.
  • Open source. Umami is MIT-licensed with an active community. There is no vendor lock-in or pricing tier to outgrow.

Every site in the platform includes the Umami tracking script. Each site has a unique website ID that maps to a distinct property in the Umami dashboard.

SiteDomainWebsite ID
Portfoliokevinryan.io155819eb-1dde-475a-95ed-9bc0b1b161b6
Brand Guidelinesbrand.kevinryan.ioc41e7b1b-81ea-422d-ba9b-9ac2e73f2192
Docsdocs.kevinryan.io7982fbc0-012b-4c04-8ec3-a9de42462351
AI Immigrantsaiimmigrants.comc9c48aa2-f7c6-495f-bbed-5837392834ba
SpecMCPspecmcp.ai372ebc20-e8e7-4cc4-8aed-5a692eed1491
SDD Booksddbook.com304d17ee-7587-4017-8060-2f8969646322
Distributed Equitydistributedequity.org0b17c94d-4711-4454-9c5b-ea437abaaf87

The tracking script is identical across all sites — only the data-website-id changes:

<script defer src="https://analytics.kevinryan.io/script.js"
data-website-id="<website-id>"></script>

The defer attribute ensures the script loads without blocking page rendering.

Next.js (kevinryan.io) — uses the next/script component in the root layout with strategy="afterInteractive":

<Script
src="https://analytics.kevinryan.io/script.js"
data-website-id="155819eb-1dde-475a-95ed-9bc0b1b161b6"
strategy="afterInteractive"
/>

Astro Starlight (docs.kevinryan.io) — injected via the Starlight head config in astro.config.mjs:

head: [
{
tag: 'script',
attrs: {
defer: true,
src: 'https://analytics.kevinryan.io/script.js',
'data-website-id': '7982fbc0-012b-4c04-8ec3-a9de42462351',
},
},
],

Static HTML sites — a plain <script> tag before the closing </body>:

<script defer src="https://analytics.kevinryan.io/script.js"
data-website-id="c41e7b1b-81ea-422d-ba9b-9ac2e73f2192"></script>
graph TD
    subgraph sites["Sites ×7"]
        browser["Visitor browser"]
    end

    subgraph cluster["K3s Cluster"]
        traefik["Traefik"]
        umami["Umami<br/>(analytics.kevinryan.io)"]
    end

    pg["Azure PostgreSQL<br/>(umami_db)"]
    kv["Azure Key Vault"]
    eso["External Secrets<br/>Operator"]

    browser -->|"script.js + page events"| traefik
    traefik --> umami
    umami -->|read/write| pg
    kv -->|secrets| eso
    eso -->|DATABASE_URL<br/>APP_SECRET| umami
  1. A visitor loads any site page. The browser fetches script.js from analytics.kevinryan.io.
  2. The script sends page view events (URL, referrer, browser, OS, screen size, language) to the Umami API. No cookies are set. No personal data is collected.
  3. Umami writes the event to the umami_db PostgreSQL database.
  4. The Umami dashboard reads from the same database to display real-time and historical analytics.

Umami runs in its own namespace with five manifests managed by Flux CD.

apiVersion: v1
kind: Namespace
metadata:
name: umami

A single replica running the official Umami PostgreSQL image:

apiVersion: apps/v1
kind: Deployment
metadata:
name: umami
namespace: umami
spec:
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: 512Mi

Key details:

  • Image: ghcr.io/umami-software/umami:postgresql-latest — the official PostgreSQL-backed build, pulled directly from GitHub Container Registry
  • Telemetry disabled: DISABLE_TELEMETRY=1 prevents Umami from sending usage data to its own analytics
  • Health probes: Both liveness and readiness use /api/heartbeat on port 3000
  • Resource limits: Capped at 500m CPU and 512Mi memory to prevent runaway consumption
apiVersion: v1
kind: Service
metadata:
name: umami
namespace: umami
spec:
selector:
app: umami
ports:
- port: 80
targetPort: 3000
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: umami
namespace: umami
spec:
entryPoints:
- websecure
routes:
- match: Host(`analytics.kevinryan.io`)
kind: Rule
services:
- name: umami
port: 80
tls: {}

Database credentials and the application secret are sourced from Azure Key Vault via the External Secrets Operator:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: umami-db
namespace: umami
spec:
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-secret

Four secrets are fetched from Key Vault and composed into two environment variables:

Environment VariableComposed From
DATABASE_URLpg-admin-username, pg-admin-password, pg-fqdn → PostgreSQL connection string
APP_SECRETumami-app-secret → used for session encryption

Secrets are refreshed every hour. If a Key Vault secret is rotated, the Kubernetes secret updates automatically.

Umami stores all analytics data in the umami_db database on the Azure PostgreSQL Flexible Server:

SettingValue
Database nameumami_db
ServerAzure PostgreSQL Flexible Server (v16)
ExtensionPGCRYPTO (required by Umami for UUID generation)
NetworkPrivate subnet, no public access
SSLRequired (sslmode=require)

The database is provisioned by Terraform as part of the PostgreSQL module. The PGCRYPTO extension is enabled server-wide.

Umami’s Kustomization has a dependency on the External Secrets store:

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: umami
namespace: flux-system
spec:
dependsOn:
- name: external-secrets-store
interval: 10m0s
path: ./k8s/umami
prune: true
sourceRef:
kind: GitRepository
name: flux-system

This ensures the ClusterSecretStore exists before Umami’s ExternalSecret is created, preventing startup failures where the secret cannot be resolved.

The analytics.kevinryan.io A record is managed by Terraform in the root module (not via the Cloudflare module, since it’s a service subdomain rather than a site):

resource "cloudflare_record" "analytics" {
zone_id = var.cloudflare_zone_id
name = "analytics"
content = module.network.public_ip_address
type = "A"
proxied = true
ttl = 1
}

Traffic is proxied through Cloudflare, providing CDN caching for the static tracking script and DDoS protection for the API endpoint.

To add Umami analytics to a new site:

  1. Create a new website in the Umami dashboard at analytics.kevinryan.io and copy the generated website ID
  2. Add the tracking script to the site’s HTML, using the appropriate method for the framework:
<script defer src="https://analytics.kevinryan.io/script.js"
data-website-id="<new-website-id>"></script>

No server-side changes are needed. The Umami instance, database, and DNS are already in place.