Runbook: Install Umami Web Analytics on mailcow server

This runbook documents how to install Umami — a self-hosted, privacy-focused web analytics platform — on a server already running mailcow, using mailcow’s nginx as a reverse proxy.

Overview

  • Umami runs in Docker Compose with its own PostgreSQL container
  • Mailcow’s nginx acts as a reverse proxy, terminating SSL
  • Let’s Encrypt SSL is managed by mailcow’s acme container
  • Umami uses its own built-in authentication (no SSO/Authentik integration available in v3.x)

Prerequisites

  • A working mailcow installation at /opt/mailcow-dockerized
  • Docker and Docker Compose installed
  • A DNS CNAME record for umami.yourdomain.org pointing to mail.yourdomain.org

Step 1 — Add the subdomain to the SSL certificate

Edit mailcow.conf and add the subdomain to ADDITIONAL_SAN:

nano /opt/mailcow-dockerized/mailcow.conf

Add umami.yourdomain.org to the comma-separated list:

ADDITIONAL_SAN=umami.yourdomain.org,outline.yourdomain.org,auth.yourdomain.org,...

Force a cert renewal:

cd /opt/mailcow-dockerized
docker compose up -d --force-recreate acme-mailcow
docker compose logs acme-mailcow -f

Wait for:

umami.yourdomain.org verified!
Certificate signed!

The AUTH failed: ERR AUTH messages in the acme logs are a harmless mailcow quirk — Redis has no password configured. Ignore them.


Step 2 — Find a free IP on the mailcow network

docker network inspect mailcowdockerized_mailcow-network \
  | jq '.[0].Containers | to_entries[] | {name: .value.Name, ip: .value.IPv4Address}'

Pick two unused IPs in the 172.22.1.x subnet — one for the Umami app, one for its PostgreSQL container. In this runbook we use:

  • 172.22.1.23 → umami app
  • 172.22.1.24 → umami-db

Step 3 — Create the Umami directory and docker-compose.yml

mkdir /opt/umami
cd /opt/umami
nano docker-compose.yml

Generate secrets first:

openssl rand -hex 32  # for APP_SECRET
openssl rand -hex 32  # for POSTGRES_PASSWORD / DATABASE_URL
services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    restart: unless-stopped
    ports:
      - "3001:3000"
    environment:
      DATABASE_URL: postgresql://umami:YOUR_DB_PASSWORD@umami-db:5432/umami
      DATABASE_TYPE: postgresql
      APP_SECRET: YOUR_APP_SECRET
    depends_on:
      umami-db:
        condition: service_healthy
    networks:
      mailcow-network:
        ipv4_address: 172.22.1.23

  umami-db:
    image: postgres:15
    container_name: umami-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: YOUR_DB_PASSWORD
      POSTGRES_DB: umami
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      mailcow-network:
        ipv4_address: 172.22.1.24

volumes:
  postgres-data:

networks:
  mailcow-network:
    external: true
    name: mailcowdockerized_mailcow-network

:warning: Make sure YOUR_DB_PASSWORD is identical in both the DATABASE_URL and POSTGRES_PASSWORD fields — a mismatch is the most common error and will cause Umami to crash-loop with password authentication failed.


Step 4 — Start Umami

cd /opt/umami
docker compose up -d
docker compose logs umami -f

You should see all 19 migrations applied and then:

▲ Next.js 16.x.x
- Network: http://0.0.0.0:3000
✓ Ready in 0ms

Step 5 — Configure mailcow nginx

nano /opt/mailcow-dockerized/data/conf/nginx/umami.conf
server {
    listen 80;
    listen [::]:80;
    server_name umami.yourdomain.org;

    location ^~ /.well-known/acme-challenge/ {
        allow all;
        default_type "text/plain";
        root /web;
    }

    location / {
        return 301 https://$server_name$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name umami.yourdomain.org;

    ssl_certificate /etc/ssl/mail/cert.pem;
    ssl_certificate_key /etc/ssl/mail/key.pem;

    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-XSS-Protection "1; mode=block";

    location / {
        proxy_pass http://172.22.1.23:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

proxy_pass uses the container’s static IP directly rather than the container name, because Umami runs in a different Docker Compose project than mailcow’s nginx.

Test and reload nginx:

cd /opt/mailcow-dockerized
docker compose exec nginx-mailcow nginx -t && \
docker compose exec nginx-mailcow nginx -s reload

Step 6 — Test and first login

curl -s -o /dev/null -w "%{http_code}" https://umami.yourdomain.org
# expect: 200

Open https://umami.yourdomain.org in your browser and log in with the default credentials:

  • Username: admin
  • Password: umami

:warning: Change the password immediately — Settings → Profile → Change Password.


Step 7 — Add your first website

  1. Go to Settings → Websites → Add Website
  2. Enter your site name and domain
  3. Click Get Tracking Code and copy the script tag
  4. Paste it into your site’s <head>

Troubleshooting

Umami crash-loops with password authentication failed

The most likely cause is a password mismatch between DATABASE_URL and POSTGRES_PASSWORD. The fix is to tear down including the volume and start fresh:

cd /opt/umami
docker compose down -v
docker compose up -d

The -v flag deletes the postgres-data volume so PostgreSQL reinitialises with the correct password.

502 Bad Gateway after restart

The container IP may have changed. Check:

docker inspect umami | grep IPAddress

Update /opt/mailcow-dockerized/data/conf/nginx/umami.conf with the new IP and reload nginx.

Check Umami logs

cd /opt/umami
docker compose logs umami -f

Check nginx logs for errors

cd /opt/mailcow-dockerized
docker compose logs nginx-mailcow | grep -i error | tail -20

Test connectivity from inside nginx to Umami

cd /opt/mailcow-dockerized
docker compose exec nginx-mailcow wget -qO- http://172.22.1.23:3000

Test on IPv4 and IPv6

curl -v --max-time 10 -4 https://umami.yourdomain.org 2>&1 | head -20
curl -v --max-time 10 -6 https://umami.yourdomain.org 2>&1 | head -20