Skip to Content

Deploy Umami with Docker Compose + Cloudflare Tunnel on Ubuntu (Production Guide)

A practical, production-oriented guide for privacy-friendly web analytics without exposing your server publicly.

Primary keyword: Umami Docker Compose Cloudflare Tunnel deployment

Secondary keywords: self-hosted web analytics, privacy-first analytics, Ubuntu production deployment, Cloudflare Tunnel hardening

Introduction: real-world use case

Teams often want website analytics without handing visitor behavior to third-party trackers that profile users across the web. Umami is a lightweight, open-source analytics platform that gives you clean dashboards while preserving privacy and reducing compliance pressure. In real production environments, the challenge is not just running the container: it is building a secure and maintainable service with strong secret handling, resilient storage, controlled exposure, and clear verification steps.

This guide walks through a production-ready Umami deployment on Ubuntu using Docker Compose for orchestration, PostgreSQL for durable state, and Cloudflare Tunnel so you can publish the service without opening inbound ports on your server. This pattern is useful for startups, agencies, and internal platform teams that want modern observability for marketing and product pages while keeping infrastructure simple and auditable.

By the end, you will have a hardened Umami stack, automated backups, practical troubleshooting patterns, and an operations checklist your team can actually follow during upgrades and incidents.

Architecture and flow overview

Our architecture is intentionally minimal but production-oriented:

  • umami-app: the web application serving dashboards and ingest endpoints.
  • umami-db (PostgreSQL): persistent relational storage for websites, events, sessions, and reports.
  • cloudflared: outbound-only connector that creates a secure tunnel from your host to Cloudflare edge.

Traffic flow is: browser → Cloudflare edge → Cloudflare Tunnel → umami-app container. Because the server only establishes outbound connections, you reduce attack surface compared to exposing 80/443 directly. PostgreSQL remains private on the Docker network. This split is easy to reason about, supports incremental hardening, and avoids introducing a full reverse-proxy layer when your primary requirement is secure public access.

Prerequisites

  • Ubuntu 22.04 or 24.04 host with at least 2 vCPU, 4 GB RAM, and 30+ GB disk.
  • A domain or subdomain you control (example: analytics.example.com).
  • Cloudflare account with DNS managed for that domain.
  • SSH access with sudo privileges on the target host.
  • Basic familiarity with Docker and Linux file permissions.

Before starting, patch the system and verify accurate server time (NTP). Analytics timestamps and retention operations are sensitive to clock drift.

Step-by-step deployment with full commands

1) Install Docker Engine and Compose plugin

Use Docker’s official repository to keep pace with security updates.

sudo apt update
sudo apt install -y ca-certificates curl gnupg

curl -fsSL https://download.docker.com/linux/ubuntu/gpg |   sudo gpg --dearmor -o /usr/share/keyrings/docker.gpg

echo   "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg]   https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" |   sudo tee /etc/apt/sources.list.d/docker.list >/dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

2) Create project layout and generate secrets

Keep runtime data and backup artifacts under a dedicated directory owned by a non-root operator account.

sudo mkdir -p /opt/umami/{app,postgres,cloudflared,backups}
sudo chown -R $USER:$USER /opt/umami
cd /opt/umami

openssl rand -hex 32   # UMAMI_HASH_SALT
openssl rand -base64 36 # POSTGRES_PASSWORD

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

3) Define environment variables securely

Store operational secrets in a locked-down .env file. Never commit this file to Git.

cat > /opt/umami/.env <<'EOF'
POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=replace-with-strong-password
UMAMI_HASH_SALT=replace-with-long-random-hex
UMAMI_APP_SECRET=replace-with-long-random-secret
UMAMI_DOMAIN=analytics.example.com
EOF

chmod 600 /opt/umami/.env

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

4) Create a production Docker Compose stack

This compose file wires PostgreSQL health checks, Umami app dependencies, and the Cloudflare Tunnel sidecar.

cat > /opt/umami/docker-compose.yml <<'EOF'
services:
  db:
    image: postgres:16-alpine
    container_name: umami-db
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami-app
    env_file: .env
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      DATABASE_TYPE: postgresql
      HASH_SALT: ${UMAMI_HASH_SALT}
      APP_SECRET: ${UMAMI_APP_SECRET}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: umami-cloudflared
    command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
    env_file: .env
    depends_on:
      - umami
    restart: unless-stopped
EOF

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

5) Launch the stack and verify container health

Pull images, start services, and check that all containers transition to healthy/running states.

echo 'CLOUDFLARE_TUNNEL_TOKEN=replace-with-tunnel-token' >> /opt/umami/.env
chmod 600 /opt/umami/.env

cd /opt/umami
docker compose pull
docker compose up -d

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

docker compose ps
docker compose logs --tail=80 umami
docker compose logs --tail=80 cloudflared
curl -I https://analytics.example.com

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

Configuration and secret-handling best practices

  • Rotate tunnel and app secrets: Define a quarterly secret rotation policy and test rotations in staging first.
  • Restrict file permissions: Ensure .env remains mode 600 and owned by a least-privileged user.
  • Separate backup path: Keep compressed SQL dumps in a dedicated directory with retention cleanup.
  • Pin maintenance window: Schedule upgrades during low-traffic windows and announce expected dashboard downtime.
  • Enable host firewall defaults: Even with tunnel-only ingress, lock down unnecessary host services.
  • Document recovery steps: Treat restoration as code; rehearse at least monthly.

For environments with strict compliance requirements, move secrets from .env into an external secret manager and inject them at runtime through your CI/CD pipeline.

Verification checklist (commands + expected output)

  • docker compose ps shows umami-db, umami-app, and umami-cloudflared as running.
  • curl -I https://analytics.example.com returns HTTP 200/302 and TLS handshake succeeds.
  • Umami admin login works, and a test website can be created.
  • A synthetic pageview appears in the dashboard within a minute.
  • Backup script creates a fresh compressed dump and rotates old backups.

Deploy backup automation and test restoration before calling the environment production-ready.

cat > /opt/umami/backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/umami
source .env
STAMP=$(date +%F-%H%M)
docker exec umami-db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "./backups/umami-${STAMP}.sql.gz"
find ./backups -type f -name 'umami-*.sql.gz' -mtime +14 -delete
EOF
chmod +x /opt/umami/backup.sh
( crontab -l 2>/dev/null; echo "30 2 * * * /opt/umami/backup.sh" ) | crontab -

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

# restore example
gunzip -c /opt/umami/backups/umami-2026-04-12-0230.sql.gz |   docker exec -i umami-db psql -U umami -d umami

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

cat > /opt/umami/healthcheck.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
URL="https://analytics.example.com/api/health"
if ! curl -fsS --max-time 8 "$URL" >/dev/null; then
  echo "$(date -Is) umami health check failed" >&2
  exit 1
fi
echo "$(date -Is) umami healthy"
EOF
chmod +x /opt/umami/healthcheck.sh
/opt/umami/healthcheck.sh

Manual copy fallback: If the copy button does not work in your browser/editor, select the block and copy with Ctrl/Cmd+C.

Common issues and fixes

Cloudflare Tunnel is connected, but site returns 502

Usually the tunnel cannot reach umami-app on the expected internal port. Verify compose service names, container status, and that Umami is listening before cloudflared retries exhaust.

Umami container restarts repeatedly at startup

Check database credentials in .env and validate PostgreSQL healthcheck status. Restart loops commonly indicate mismatched password values between app and DB services.

No events appear in dashboards

Confirm the tracking script is installed on your website and points to the public Umami URL. Also verify ad blockers and CSP rules are not blocking analytics script execution.

Backups are created but restore fails

Ensure your restore target database exists, and the PostgreSQL major version is compatible. Run restore in a staging clone first and document exact operational steps in your runbook.

FAQ

1) Is Cloudflare Tunnel mandatory for Umami?

No. You can expose Umami through Caddy, Nginx, or Traefik. We chose Cloudflare Tunnel here to reduce inbound exposure and simplify TLS edge management.

2) Can I run Umami with SQLite instead of PostgreSQL?

You can for very small environments, but PostgreSQL is recommended for production because it scales better, supports safer backup/restore workflows, and handles concurrent load more predictably.

3) How much server capacity do I need?

For modest traffic, 2 vCPU and 4 GB RAM is a practical starting point. Scale based on write volume, query load, and retention policies. Monitor DB disk growth from day one.

4) How should I handle upgrades safely?

Pin image tags when possible, take a fresh SQL backup, pull images in staging, run smoke tests, then roll forward in production during a maintenance window with rollback criteria pre-defined.

5) Can multiple websites share one Umami instance?

Yes. Umami supports multiple tracked websites in one deployment. Use clear naming conventions and role-based access practices if different teams share the same analytics cluster.

6) What is the minimum security baseline?

Strong secrets, regular patching, restricted file permissions, backup verification, and least-privilege access are minimum requirements. Add SSO and centralized logging as your environment matures.

Related guides

Talk to us

If you want support implementing Umami with Docker Compose and Cloudflare Tunnel in production, we can help with architecture reviews, security hardening, rollout planning, and operational runbook design.

Contact Us

How to Deploy Netdata on Kubernetes with Helm for Production-Grade Monitoring
A long-form practical guide with security, verification, troubleshooting, and day-2 operations.