Self-Hosting Umami: Privacy-First Analytics Behind a Cloudflare Tunnel
No cookies. No tracking. Full control - running on your own infrastructure in under 30 minutes

Google Analytics tracks your visitors. Umami tracks your traffic - without cookies, without GDPR banners, without sending data to anyone but yourself.
Here's how to run it on your own infrastructure, expose it cleanly via Cloudflare, and make sure the raw port never leaks to your LAN.
The Stack
- Proxmox VE, LXC container (Debian 12), provisioned via OpenTofu
- Docker Compose: Umami + PostgreSQL 17
- Cloudflare Tunnel →
https://analytics.yourdomain.com - SSH key per container - no shared keys
Provisioning the Container with OpenTofu
Rather than creating the LXC manually in the Proxmox UI, the container is declared in HCL and provisioned with OpenTofu using the bpg/proxmox provider. This keeps it reproducible and tracked in state.
resource "proxmox_virtual_environment_container" "umami" {
node_name = var.proxmox_node
vm_id = var.umami_vm_id
tags = ["umami", "docker", "analytics"]
unprivileged = true
start_on_boot = true
cpu { cores = 1 }
memory { dedicated = 2048; swap = 512 }
disk { datastore_id = var.default_datastore; size = 10 }
features { nesting = true }
initialization {
hostname = "umami"
ip_config {
ipv4 { address = var.umami_ip; gateway = var.network_gateway }
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/umami_key.pub")))]
}
}
}
nesting = true is required for Docker to run inside an unprivileged LXC.
Docker Compose Setup
Umami needs two services: the app and PostgreSQL. Keep it minimal.
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: always
ports:
- '127.0.0.1:3000:3000'
environment:
DATABASE_URL: postgresql://umami:YOUR_DB_PASSWORD@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: YOUR_APP_SECRET
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: always
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: YOUR_DB_PASSWORD
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U umami']
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres-data:
Never hardcode credentials. Use a secrets manager to inject DB_PASSWORD and APP_SECRET at deploy time - not stored in the compose file at rest.
The Port Binding Gotcha
The default Docker port binding is 0.0.0.0:3000 - meaning anything on your LAN can reach Umami directly at http://your-container-ip:3000.
If you're routing traffic through a Cloudflare tunnel, this creates a situation where both the custom domain and the raw LAN IP are accessible. To close the direct route:
ports:
- '127.0.0.1:3000:3000'
This binds the port to localhost only. The cloudflared process - running on the same container - can still reach localhost:3000. Everything else on the LAN is blocked.
Critical: Update your Cloudflare tunnel's service URL to localhost:3000 (not the container IP) before binding to localhost - otherwise the tunnel breaks with a 502.
Cloudflare Tunnel Setup
- Cloudflare Zero Trust → Networks → Connectors → Create a tunnel
- Name it, install the cloudflared connector on the container
- Under Public Hostnames, set:
- Subdomain:
analytics - Domain:
yourdomain.com - Service:
http://localhost:3000
- Subdomain:
- Cloudflare creates the DNS record automatically - no manual CNAME needed
The tunnel handles TLS termination. Umami runs plain HTTP internally; visitors get HTTPS externally.
First Login
Default credentials on first boot:
- Username:
admin - Password:
umami
Change the password immediately after first login under Settings → Profile. Store it in your secrets manager.
Key Lessons
nesting = trueis required in the LXC features block - without it Docker won't start inside an unprivileged container.- Bind to
127.0.0.1, not0.0.0.0- close the direct port before the tunnel goes live. - Update the tunnel service URL first - set it to
localhost:3000in the Cloudflare dashboard before changing the port binding, not after. - Cloudflare creates the DNS record automatically - don't add a CNAME placeholder manually, it will conflict.
- Two connectors under one tunnel is normal if cloudflared is installed twice - the duplicate disappears when the first process stops.
Want a hand setting up self-hosted analytics for your stack? Get in touch. We'd love to help.