If your team needs privacy-respecting product analytics without handing user data to a third party, self-hosting Docmost is a strong option. The common failure mode is not installationβit is weak production hygiene after installation. Teams skip secret isolation, fail to define backup routines, and treat reverse-proxy TLS as an afterthought. This guide focuses on the production path from day one: controlled configuration, explicit security boundaries, and verification steps that prevent silent breakage after updates.
We will deploy Docmost with Docker Compose, Traefik, and PostgreSQL on Ubuntu. The pattern is built for small teams and internal platforms that need a maintainable stack: reproducible infrastructure-as-code, predictable upgrade windows, and enough observability to troubleshoot incidents quickly. If you have ever inherited a "works on my VM" setup with no rollback path, this runbook is designed to avoid that outcome.
Architecture and request flow overview
The stack has three layers: Traefik as entry point and TLS terminator, application containers for the web and worker processes, and PostgreSQL as the state layer. External traffic lands on Traefik over 443, is routed by host rule to the application service on the internal Docker network, and then reads/writes telemetry data to PostgreSQL.
We keep all services on a private bridge network and expose only Traefik ports publicly. That boundary simplifies firewall policy and reduces accidental service exposure. Persistent volumes are mounted only where state is required (database and app data), making backups deliberate and restore drills testable.
# Expected traffic path
# Client -> Traefik (443) -> Umami app (3000) -> PostgreSQL (5432 internal)
docker network create analytics_net || true
docker network inspect analytics_net --format '{{.Name}} {{len .Containers}} containers'If the copy button does not work in your browser/editor, manually select and copy the command block.
Prerequisites
- Ubuntu 22.04 or 24.04 server with at least 2 vCPU, 4 GB RAM, and 40 GB SSD.
- DNS A record for your analytics domain (for example,
analytics.example.com). - Sudo-capable shell account and basic familiarity with Docker logs.
- Open inbound ports 22, 80, and 443 at the firewall/security group layer.
- A maintenance window for first deploy and a second short window for validation after DNS/TLS.
Step-by-step deployment
1) Install Docker Engine and Compose plugin
sudo apt update
sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg lsb-release jq
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/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 -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now dockerIf the copy button does not work in your browser/editor, manually select and copy the command block.
2) Create project directory and strict file permissions
sudo mkdir -p /opt/umami/{traefik,database,backups}
sudo chown -R $USER:$USER /opt/umami
chmod 750 /opt/umami
cd /opt/umamiIf the copy button does not work in your browser/editor, manually select and copy the command block.
3) Generate secrets and write environment file
cd /opt/umami
POSTGRES_PASSWORD=$(openssl rand -base64 36 | tr -d '
')
APP_SECRET=$(openssl rand -hex 32)
cat > .env <<EOF
DOMAIN=analytics.example.com
TZ=UTC
POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
APP_SECRET=${APP_SECRET}
EOF
chmod 600 .envIf the copy button does not work in your browser/editor, manually select and copy the command block.
4) Create Docker Compose stack
cat > /opt/umami/docker-compose.yml <<'YAML'
services:
traefik:
image: traefik:v3.0
container_name: umami-traefik
restart: unless-stopped
command:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- [email protected]
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik:/letsencrypt
networks: [analytics_net]
db:
image: postgres:16-alpine
container_name: umami-db
restart: unless-stopped
env_file: .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./database:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 12
networks: [analytics_net]
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
container_name: umami-app
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
APP_SECRET: ${APP_SECRET}
depends_on:
db:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.umami.rule=Host(`${DOMAIN}`)
- traefik.http.routers.umami.entrypoints=websecure
- traefik.http.routers.umami.tls=true
- traefik.http.routers.umami.tls.certresolver=letsencrypt
- traefik.http.services.umami.loadbalancer.server.port=3000
networks: [analytics_net]
networks:
analytics_net:
external: true
YAML
touch /opt/umami/traefik/acme.json
chmod 600 /opt/umami/traefik/acme.jsonIf the copy button does not work in your browser/editor, manually select and copy the command block.
5) Launch and validate first boot
cd /opt/umami
docker compose pull
docker compose up -d
docker compose ps
docker logs --tail=80 umami-app
docker logs --tail=80 umami-dbIf the copy button does not work in your browser/editor, manually select and copy the command block.
6) Apply host hardening and baseline firewall policy
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status verboseIf the copy button does not work in your browser/editor, manually select and copy the command block.
Configuration and secrets handling best practices
Keep .env local to the server with mode 600. Never paste credentials into tickets, chat, or wiki pages. If multiple operators manage the stack, move secret distribution to a vault-backed workflow and render environment files just-in-time during deploy. Avoid editing secrets directly inside docker-compose.yml; this prevents accidental commits and makes rotation explicit.
For rotation, change one secret class at a time (database password, then app secret) and run a smoke test after each change. Coupling all secret changes in one window is a common source of ambiguous failures. Maintain a lightweight runbook entry for each rotation: date, operator, impacted services, and rollback instruction. This is boring work, but it is exactly what shortens incident recovery when authentication breaks at 2 AM.
# Example: rotate only APP_SECRET safely
cd /opt/umami
cp .env .env.bak.$(date +%F-%H%M)
sed -i "s/^APP_SECRET=.*/APP_SECRET=$(openssl rand -hex 32)/" .env
docker compose up -d umami
docker logs --tail=120 umami-appIf the copy button does not work in your browser/editor, manually select and copy the command block.
Verification checklist
Run these checks before announcing the service as production-ready. You are looking for deterministic signals: healthy containers, reachable HTTPS endpoint, successful database connectivity, and no crash-loop in logs.
cd /opt/umami
docker compose ps
curl -I https://analytics.example.com
docker exec umami-db pg_isready -U umami -d umami
docker logs --tail=120 umami-app | egrep -i 'error|exception|migrate|ready'
docker logs --tail=120 umami-traefik | tail -n 40If the copy button does not work in your browser/editor, manually select and copy the command block.
If these checks pass, complete one application smoke path in the UI: sign in, load dashboard, create a test website property, then remove it. Functional smoke testing catches errors that basic liveness checks miss, especially after image updates.
Common issues and fixes
Traefik serves 404 for the analytics hostname
Usually the router host rule does not match your DNS name, or the container labels were not reloaded. Confirm DOMAIN in .env, inspect labels with docker inspect umami-app, then restart app and proxy in sequence. Also verify that DNS record points to the same host where Traefik is running.
Container starts but app shows database connection errors
Check that DATABASE_URL references service name db (not localhost), and confirm the password in .env matches PostgreSQL container environment. If credentials changed, run controlled restart: database first, then app.
Let's Encrypt certificate is not issued
ACME HTTP challenge requires public reachability on port 80. If your cloud firewall blocks 80 or traffic is behind another reverse proxy, certificate issuance can fail silently. Open port 80 temporarily for challenge completion and ensure no parallel proxy consumes the same port.
High memory usage after traffic spikes
Set explicit container memory reservations/limits and observe behavior over a week. Some teams over-correct with aggressive limits that trigger OOM restarts. Tune incrementally and watch restart count plus p95 response time rather than relying on one-off snapshots.
FAQ
Can I run this behind Cloudflare?
Yes. Start direct first for baseline validation, then place Cloudflare in front once HTTPS and origin behavior are stable. Keep SSL mode set to Full (strict) and avoid masking origin misconfiguration with permissive modes.
How often should I back up PostgreSQL?
At minimum daily for low-volume setups; more frequently if analytics data is critical for reporting cadence. Keep at least one off-host copy and test restore monthly. A backup strategy without restore drills is only documentation.
What is the safest way to update images?
Pin versions, capture pre-upgrade backup, then pull and restart during a maintenance window. Validate with the checklist in this guide and keep rollback commands ready. Avoid unattended auto-updaters on business-critical stacks.
Do I need a separate staging environment?
If analytics is used for customer-facing decisions, yes. A minimal staging copy catches migration and proxy-rule mistakes before production impact. Even a small single-node staging host is better than testing directly in production.
How should I secure admin access?
Use unique admin credentials, restrict SSH with keys only, and keep host patching current. If your identity stack allows it, place admin access behind SSO and MFA. Rotate credentials on a schedule and on every operator offboarding event.
Can I use managed PostgreSQL instead of containerized PostgreSQL?
Absolutely. Many teams do this to reduce database operations burden. Update DATABASE_URL to managed endpoint, enforce TLS parameters where required, and review egress/firewall rules before cutover.
What metrics should I alert on first?
Start with service availability, TLS expiry window, container restart counts, host disk utilization, and database health checks. These baseline alerts provide high signal with low noise and catch most early operational regressions.
Related guides
- https://sysbrix.com/blog/guides-3/production-guide-deploy-grafana-loki-with-docker-compose-traefik-s3-on-ubuntu-395
- https://sysbrix.com/blog/guides-3/production-guide-deploy-redash-with-docker-compose-nginx-postgresql-on-ubuntu-390
- https://sysbrix.com/blog/guides-3/production-guide-deploy-netbird-with-kubernetes-helm-cert-manager-on-ubuntu-378
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.