Skip to content
Get Started

How to Self-Host Umami with Docker

Umami Analytics can keep website analytics and dashboard accounts in PostgreSQL on a VPS you control. A production installation still has several moving parts: the tracker must load over HTTPS, PostgreSQL must remain private and persistent, authentication needs a stable secret, and upgrades may run database migrations.

This tutorial installs the current Umami 3.2.0 release on Debian or Ubuntu with Docker Compose. Umami listens only on 127.0.0.1:3000; PostgreSQL has no host port at all. Caddy or Nginx terminates TLS at analytics.example.com.

This deployment pins the application image to ghcr.io/umami-software/umami:3.2.0, the newest release when checked on July 16, 2026. Umami publishes plain version tags on GHCR (3, 3.2, 3.2.0, and so on) alongside latest, so pinning the exact release keeps upgrades deliberate instead of accidental. The database follows the official Compose file’s choice of postgres:15-alpine.

Prerequisites: prepare Docker, DNS, and the firewall

You need an unmanaged Linux VPS with root or sudo access, Docker Engine, the Compose plugin, and a DNS A record that points analytics.example.com to the VPS. Publish an AAAA record only when the complete HTTPS path works over IPv6. Umami requires PostgreSQL 12.14 or newer; this deployment follows the official Compose choice of the PostgreSQL 15 Alpine series and configures it for UTC.

Our VPS plans are unmanaged, so you run the containers, database, firewall, backups, and upgrades yourself and keep full control. If you are comparing several services for one server, our self-hosted apps guide is a good place to start.

Install Docker Engine and the Compose plugin with Docker’s convenience script; it is worth skimming the script before running it so you know what it changes:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo docker compose version

The script runs as root and adds Docker’s package repository. If that does not suit your host, use your distribution’s own Docker packages instead. The commands below assume your user can run Docker. Otherwise prefix them with sudo or follow Docker’s documented post-install procedure.

If UFW is active, allow HTTP and HTTPS. Keep the current SSH rule in place:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status

Apply equivalent rules at the hosting-provider firewall. Do not expose ports 3000 or 5432. Confirm DNS with getent ahosts analytics.example.com before requesting a certificate.

Generate the environment file

Create the project directory:

sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/umami
cd /opt/umami

Generate URL-safe random values without printing them to the terminal. Hex keeps the PostgreSQL password valid inside DATABASE_URL without percent-encoding:

umask 077
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
chmod 600 .env

Do not paste the contents of .env into tickets or logs. DATABASE_URL tells Umami which PostgreSQL user, password, database, host, and port to use. In Compose, the hostname is the service name db, not localhost. APP_SECRET is a unique random value used to secure authentication tokens. Keep it unchanged across restarts and restores; changing it invalidates existing login sessions.

Create the Umami and PostgreSQL services

Create /opt/umami/compose.yaml. It is based on Umami’s official Compose file; the loopback-only publication, required APP_SECRET, and bounded health waits are choices made for this tutorial.

services:
  umami:
    image: ghcr.io/umami-software/umami:3.2.0
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: "postgresql://umami:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/umami"
      APP_SECRET: "${APP_SECRET:?set APP_SECRET}"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test:
        ["CMD-SHELL", "curl -fsS http://localhost:3000/api/heartbeat || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 30s
    init: true
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
      TZ: UTC
      PGTZ: UTC
    volumes:
      - umami-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 6
    restart: unless-stopped

volumes:
  umami-db-data:

The db health check prevents Umami from starting before PostgreSQL accepts connections. All accounts, website definitions, shares, and analytics events live in the named umami-db-data volume. The application container is disposable and needs no data volume.

Start both services and check the heartbeat:

docker compose up -d --wait --wait-timeout 180
curl -fsS http://127.0.0.1:3000/api/heartbeat

If Umami loops during startup, read docker compose logs umami before changing the database. Common causes are a damaged DATABASE_URL, a missing .env, or PostgreSQL that never reached healthy state.

Change the default password through an SSH tunnel

Do not enable the public Caddy route yet. On first initialization, Umami creates the documented account admin with password umami. Publishing that login page before changing the password creates an avoidable takeover window.

From your workstation, forward the loopback-only listener and leave the tunnel running:

ssh -N -L 3000:127.0.0.1:3000 YOUR_USER@SERVER_IP

Browse to http://127.0.0.1:3000, sign in as admin with password umami, and replace it with a strong, unique password. Sign out, confirm the old password no longer works, then sign in with the new one. Close the tunnel only after that check passes.

Serve the dashboard and tracker over HTTPS

An HTTPS website will refuse to load an HTTP analytics script as mixed content. Put the dashboard, tracker, and collection endpoint behind the same valid TLS hostname.

With Caddy, add this block to /etc/caddy/Caddyfile:

analytics.example.com {
    reverse_proxy 127.0.0.1:3000
}

Caddy handles certificate issuance and forwards the original host, client address, and scheme. Validate, reload, and check the public heartbeat:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -fsS https://analytics.example.com/api/heartbeat

For Nginx, terminate TLS on 443, redirect port 80, and proxy to http://127.0.0.1:3000. Preserve Host, append X-Forwarded-For, and set X-Forwarded-Proto to the original scheme. Umami does not need an invented BASE_URL variable for this deployment; the proxy’s host and forwarded headers describe the public request. The Umami installation guide links to Nginx’s reverse-proxy documentation for this pattern.

Do not put blanket authentication in front of the whole hostname. The tracker script and /api/send collection endpoint must remain reachable from tracked websites. If you add another access layer for the dashboard, test its path rules carefully.

Record a real pageview

Browse to https://analytics.example.com and sign in with the password set through the SSH tunnel.

Add a website in the dashboard. Use the actual site hostname and a recognizable name, then open its tracking-code section. Umami generates the website ID and the exact snippet. It has this form:

<script
  defer
  src="https://analytics.example.com/script.js"
  data-website-id="WEBSITE-ID-FROM-UMAMI"
></script>

Copy the generated snippet into the tracked site’s <head>. Deploy that page and open it in a normal browser with tracker blocking disabled.

Use the browser’s Network panel to verify two requests: https://analytics.example.com/script.js loads successfully, and the collection request to /api/send returns a successful status. The Umami collection guide says the visit should appear immediately. Confirm the pageview in the dashboard’s realtime view, then repeat from another browser or device. This is the end-to-end test; a green container health check alone proves nothing about tracker delivery.

If the pageview does not appear, check the generated website ID, browser blockers, Content Security Policy, mixed-content errors, reverse-proxy logs, and Umami logs:

docker compose logs --tail=100 umami
curl -fsSI https://analytics.example.com/script.js

Back up PostgreSQL and configuration

Umami’s documentation has no application-specific backup procedure, so use PostgreSQL’s own tools. Use pg_dump rather than copying a live database volume; the custom format supports a controlled pg_restore:

cd /opt/umami
umask 077
install -d -m 0700 backups
docker compose exec -T db \
  pg_dump -U umami -d umami -Fc \
  > "backups/umami-$(date +%F).dump"
test -s "backups/umami-$(date +%F).dump"
chmod 600 "backups/umami-$(date +%F).dump"

Because compose.yaml pins exact image tags, the Compose file itself records which Umami and PostgreSQL versions produced the dump. Keep a copy of it with every dump and a restore can always use matching versions.

Preserve compose.yaml, the mode-600 .env, the reverse-proxy configuration, and any optional tracker endpoint settings in an encrypted, access-controlled backup. The dump contains analytics, users, teams, shares, and website definitions. The stable APP_SECRET and database password are configuration secrets, so do not store them in a public repository or beside an unencrypted public dump.

For the Caddy deployment in this guide, make a restricted configuration archive without displaying either secret:

sudo tar -czf "backups/umami-config-$(date +%F).tar.gz" \
  compose.yaml .env /etc/caddy/Caddyfile
sudo chmod 600 "backups/umami-config-$(date +%F).tar.gz"

Use the corresponding Nginx site file if you chose Nginx. Encrypt the configuration archive before copying it off the VPS.

Riven Cloud plans include daily provider backups as an extra safety net, but nothing replaces an application-consistent PostgreSQL dump that you have rehearsed restoring. Copy encrypted backups off the VPS. Database growth follows event volume and retention, so keep an eye on the volume and decide how long raw analytics should stay available.

Restore and prove the backup works

This restore drill uses a separate directory, Compose project, PostgreSQL volume, and loopback port. It never stops or drops the production database. Copy the production secrets and one dump into the isolated project without printing either one:

sudo install -d -m 0700 -o "$USER" -g "$USER" \
  /opt/umami-restore/backups
sudo install -m 0600 -o "$USER" -g "$USER" \
  /opt/umami/.env /opt/umami-restore/.env
sudo install -m 0600 -o "$USER" -g "$USER" \
  /path/to/umami-YYYY-MM-DD.dump \
  /opt/umami-restore/backups/umami-YYYY-MM-DD.dump
cd /opt/umami-restore

Create /opt/umami-restore/compose.yaml with the same pinned images as the production Compose file, a distinct database volume, and non-conflicting loopback port 3300:

services:
  umami:
    image: ghcr.io/umami-software/umami:3.2.0
    ports:
      - "127.0.0.1:3300:3000"
    environment:
      DATABASE_URL: "postgresql://umami:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/umami"
      APP_SECRET: "${APP_SECRET:?set APP_SECRET}"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test:
        ["CMD-SHELL", "curl -fsS http://localhost:3000/api/heartbeat || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 30s
    init: true
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
      TZ: UTC
      PGTZ: UTC
    volumes:
      - umami-restore-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 6
    restart: unless-stopped

volumes:
  umami-restore-db-data:

Start the isolated database. The new PostgreSQL volume contains a fresh, empty umami database, so no dropdb command is needed. Restore the dump, start the isolated application, and check its heartbeat:

cd /opt/umami-restore
test -s backups/umami-YYYY-MM-DD.dump
docker compose -p umami-restore up -d --wait --wait-timeout 120 db
docker compose -p umami-restore exec -T db pg_restore \
  -U umami -d umami \
  < backups/umami-YYYY-MM-DD.dump
docker compose -p umami-restore up -d --wait --wait-timeout 180 umami
curl -fsS http://127.0.0.1:3300/api/heartbeat

Inspect the restored dashboard without creating a public proxy route. From your workstation, open a second tunnel:

ssh -N -L 3300:127.0.0.1:3300 YOUR_USER@SERVER_IP

Browse to http://127.0.0.1:3300, log in with the restored account, and confirm the websites and historical reports exist. Keep production running while you compare a few known totals. Once a dump has passed this drill, you can rely on it.

Upgrade Umami safely

Umami updates can include database migrations. Read the release notes and official update guidance, and take a fresh logical dump first. The image tag is pinned, so pulling alone changes nothing; edit compose.yaml, set the Umami image tag to the release you reviewed, then pull and recreate:

docker compose pull umami
docker compose up -d --wait --wait-timeout 180
curl -fsS https://analytics.example.com/api/heartbeat

Wait for startup and migration logs to settle. On major upgrades or large migrated databases, Umami recommends refreshing PostgreSQL planner statistics:

docker compose exec -T db \
  psql -U umami -d umami -c 'ANALYZE;'

Log in, inspect an existing dashboard, load the tracker, and confirm a fresh pageview reaches /api/send and appears in realtime. Never use docker compose down -v; that removes the PostgreSQL volume.

Update the PostgreSQL 15 image separately

Keep database-image maintenance separate from an Umami application update, and take a fresh logical dump first. For a patch update within PostgreSQL major version 15:

cd /opt/umami
docker compose pull db
docker compose stop umami
docker compose up -d --wait --wait-timeout 120 db
docker compose up -d --wait --wait-timeout 180 umami
curl -fsS https://analytics.example.com/api/heartbeat

Log in and confirm an existing dashboard plus a new pageview. Keep the pre-update dump until those checks pass. A PostgreSQL major-version change is a database migration, not an image patch; follow PostgreSQL’s major-upgrade or logical dump/restore procedure instead of using the sequence above.

Choosing a Riven Cloud VPS for Umami

For most sites, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB transfer) is a comfortable starting point for Umami. Capacity follows event volume, retention, and report queries more than the number of tracked domains, so if the database grows or reports slow down, Ultra (8 GB RAM, 80 GB NVMe, 2 TB transfer) and Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) give you room to move up. A periodic look at PostgreSQL size, ingestion rate, and backup time will tell you when it is time.

One placement tip: put the collector near the visitors and the sites that send events. Latency for whoever opens the dashboard matters far less than latency on the tracking path. We offer Tokyo and Singapore on every plan, and if a meaningful share of your traffic comes from mainland China, comparing the Tokyo Looking Glass and Singapore Looking Glass from the relevant carriers at representative times is the quickest way to decide. The pricing page has the current plan details.

Security, privacy, and when not to self-host

Keep PostgreSQL private, retain the same APP_SECRET, patch both images, restrict backup access, and disable dashboard share URLs unless they are intentional. The collection endpoint is public by design. Anyone who obtains a website ID can submit fake events, so watch for report poisoning and disk growth. Apply proxy rate limits cautiously because a blunt limit can discard legitimate traffic bursts.

Analytics may involve consent, retention, deletion, data-location, or employee-monitoring obligations. IP-derived location and session identifiers may be personal data under applicable law. Review the current v3 session replay and heatmap features before enabling them because replay can capture sensitive page content or interactions. Umami sends anonymous product telemetry by default; the documented DISABLE_TELEMETRY=1 option disables it if your policy requires that.

Umami Cloud or another hosted analytics service is the better choice when nobody can own PostgreSQL backups, restores, migrations, monitoring, and privacy requests, or when you need vendor support, high availability, or a formal SLA. If basic request counts from web-server logs answer the question, a simpler single-binary log analyzer avoids a database, a tracker endpoint, and application migrations. Self-hosted Umami fits when its dashboard and event model are worth that ongoing operational work to you.

Share