DevOps3 min read

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

Liban Abdullahi·
174 views
Umami self-hosted analytics running on a Proxmox LXC container, provisioned with OpenTofu and exposed via a Cloudflare tunnel with no open ports.

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

  1. Cloudflare Zero Trust → Networks → Connectors → Create a tunnel
  2. Name it, install the cloudflared connector on the container
  3. Under Public Hostnames, set:
    • Subdomain: analytics
    • Domain: yourdomain.com
    • Service: http://localhost:3000
  4. 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 = true is required in the LXC features block - without it Docker won't start inside an unprivileged container.
  • Bind to 127.0.0.1, not 0.0.0.0 - close the direct port before the tunnel goes live.
  • Update the tunnel service URL first - set it to localhost:3000 in 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.

Tags

#self-hosting#umami#analytics#proxmox#opentofu#docker#cloudflare