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.orgpointing tomail.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 AUTHmessages 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 app172.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
Make sure
YOUR_DB_PASSWORDis identical in both theDATABASE_URLandPOSTGRES_PASSWORDfields — a mismatch is the most common error and will cause Umami to crash-loop withpassword 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_passuses 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
Change the password immediately — Settings → Profile → Change Password.
Step 7 — Add your first website
- Go to Settings → Websites → Add Website
- Enter your site name and domain
- Click Get Tracking Code and copy the script tag
- 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
-vflag deletes thepostgres-datavolume 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