Identity and access management is often the first service that breaks during rapid growth: ad-hoc OAuth clients, brittle SSO integrations, and no clear audit trail for admin changes. If your team runs multiple internal apps and customer-facing services, you need an IAM control plane that supports OpenID Connect, SAML, delegated administration, and production-grade operations.
This guide shows how to deploy ZITADEL on Ubuntu with Docker Compose + Traefik + PostgreSQL in a way that is repeatable and supportable in production. We focus on practical operations: strict secret handling, idempotent configuration, health checks, TLS termination, backup strategy, and day-2 troubleshooting. The goal is to give platform teams a deployment they can trust, not just a quick demo.
Architecture and flow overview
In this pattern, PostgreSQL stores IAM state, ZITADEL serves the identity APIs and console, and Traefik terminates TLS and routes external traffic to the ZITADEL container. A dedicated Docker network isolates east-west traffic, and only Traefik is exposed publicly. Backups capture PostgreSQL dumps plus encrypted configuration snapshots for fast recovery.
- Edge: Traefik with automatic HTTPS and secure headers
- Core service: ZITADEL API and management console
- Database: PostgreSQL 16 with persistent volume
- Secrets: Stored in
.envwith strict file permissions and rotation plan - Operations: Health checks, log inspection, smoke tests, and restore drills
For high availability, you can later move PostgreSQL to a managed or clustered backend and run multiple ZITADEL replicas behind Traefik. Start with this single-node baseline to stabilize workflows before scaling out.
Prerequisites
- Ubuntu 22.04/24.04 host with sudo access
- A DNS record (for example
auth.example.com) pointing to your server - Docker Engine 24+ and Compose plugin
- Ports 80/443 allowed from the internet, port 5432 blocked publicly
- A mailbox for admin notifications and reset flows
Before deployment, verify server time synchronization (NTP) and baseline hardening (SSH key auth, fail2ban, and a host firewall). IAM services are security-critical and should never be deployed on an unmaintained host.
Step-by-step deployment
1) Install Docker runtime and create directories
Create a predictable layout so operations and handoffs stay clean. We keep runtime definitions in /opt/zitadel and persistence in /srv/zitadel.
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
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-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo mkdir -p /opt/zitadel /srv/zitadel/postgres /srv/zitadel/backups
sudo chown -R $USER:$USER /opt/zitadel /srv/zitadelIf the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
2) Create environment secrets and lock permissions
ZITADEL and PostgreSQL require strong credentials. Use long random values and keep .env readable only by the deploy user.
cd /opt/zitadel
cat > .env << 'EOF'
DOMAIN=auth.example.com
TZ=UTC
POSTGRES_DB=zitadel
POSTGRES_USER=zitadel
POSTGRES_PASSWORD=REPLACE_WITH_A_32_CHAR_RANDOM_SECRET
ZITADEL_MASTERKEY=REPLACE_WITH_32_BYTES_HEX_OR_BASE64
ZITADEL_FIRSTINSTANCE_ORG=Example Corp
ZITADEL_FIRSTINSTANCE_HUMAN_USERNAME=admin
[email protected]
ZITADEL_FIRSTINSTANCE_HUMAN_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
EOF
chmod 600 .envIf the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
Secret handling guidance: never commit this file to Git, rotate credentials after team changes, and move to a secret manager (Vault/1Password/SSM) once the environment is stable.
3) Write Docker Compose stack
The Compose file runs PostgreSQL with a health check, then starts ZITADEL once the database is ready. Traefik labels expose the service on your DNS hostname with TLS enabled.
version: "3.9"
services:
postgres:
image: postgres:16
container_name: zitadel-postgres
env_file: .env
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- /srv/zitadel/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
networks: [identity]
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: zitadel
env_file: .env
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled
depends_on:
postgres:
condition: service_healthy
environment:
- ZITADEL_DATABASE_POSTGRES_HOST=postgres
- ZITADEL_DATABASE_POSTGRES_PORT=5432
- ZITADEL_DATABASE_POSTGRES_DATABASE=${POSTGRES_DB}
- ZITADEL_DATABASE_POSTGRES_USER_USERNAME=${POSTGRES_USER}
- ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=${POSTGRES_PASSWORD}
- ZITADEL_EXTERNALDOMAIN=${DOMAIN}
- ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME=${ZITADEL_FIRSTINSTANCE_HUMAN_USERNAME}
- ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS=${ZITADEL_FIRSTINSTANCE_HUMAN_EMAIL}
- ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=${ZITADEL_FIRSTINSTANCE_HUMAN_PASSWORD}
- ZITADEL_FIRSTINSTANCE_ORG_NAME=${ZITADEL_FIRSTINSTANCE_ORG}
labels:
- traefik.enable=true
- traefik.http.routers.zitadel.rule=Host(`${DOMAIN}`)
- traefik.http.routers.zitadel.entrypoints=websecure
- traefik.http.routers.zitadel.tls.certresolver=letsencrypt
- traefik.http.services.zitadel.loadbalancer.server.port=8080
restart: unless-stopped
networks: [identity, edge]
networks:
identity:
name: identity
edge:
external: trueIf the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
4) Attach to Traefik edge network and deploy
If your host already runs Traefik, ensure the external network exists and then bring up the stack.
docker network create edge || true
cd /opt/zitadel
docker compose pull
docker compose up -dIf the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
5) Confirm bootstrap and create first project/client
After containers are healthy, sign in to the admin console and create your first project and OIDC application for an internal service. Keep redirect URIs exact and minimal.
docker compose ps
docker compose logs --tail=100 zitadel
curl -I https://auth.example.comIf the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
Configuration and secrets handling
Production IAM reliability depends less on first deployment and more on disciplined configuration management. Keep a versioned operations repository with sanitized templates (.env.example, compose files, and runbooks) while storing real secrets outside Git.
- Use at least 32-character random secrets for DB and admin credentials
- Rotate secrets quarterly or after personnel transitions
- Restrict shell history capture when handling sensitive values
- Enable MFA for all administrative ZITADEL accounts
- Pin images to tested tags during change windows, then upgrade intentionally
For teams with compliance requirements, add an encrypted backup destination, immutable backup retention, and audit-friendly change approvals for identity configuration updates.
Automated backups (PostgreSQL dump)
The following script keeps timestamped encrypted dumps in /srv/zitadel/backups. Schedule it with cron or systemd timers and replicate off-host.
#!/usr/bin/env bash
set -euo pipefail
source /opt/zitadel/.env
TS=$(date +%Y%m%d_%H%M%S)
OUT="/srv/zitadel/backups/zitadel_${TS}.sql"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" zitadel-postgres \
pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > "$OUT"
gzip "$OUT"
find /srv/zitadel/backups -name 'zitadel_*.sql.gz' -mtime +14 -deleteIf the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
Verification checklist
Use a repeatable checklist after every deployment and every significant upgrade:
- Routing:
https://auth.example.comreturns expected login page over valid TLS - Container health: PostgreSQL healthy, ZITADEL running without restart loops
- OIDC flow: test login from one real client and verify callback completes
- Audit: admin actions are visible in logs/events
- Backup integrity: latest dump exists and can be decompressed
cd /opt/zitadel
docker compose ps
docker compose exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select now();"
docker compose logs --tail=200 zitadel | grep -Ei "error|warn|migration|ready" || true
ls -lh /srv/zitadel/backups | tail -n 5If the copy button does not work in your browser, manually select the code block and copy with Ctrl/Cmd+C.
Common issues and fixes
Traefik route returns 404
Usually caused by hostname mismatch between DNS record, Traefik rule, and DOMAIN value. Confirm all three match exactly and reload the stack.
Login page loads but authentication fails
Check system clock skew and redirect URI configuration in your OIDC client. Time drift can break token validation silently.
Database connection refused on startup
PostgreSQL may still be initializing. Ensure health checks are configured and depends_on.condition=service_healthy is in place. Also verify the DB credentials in .env were not changed without recreating containers.
Certificate issuance fails
Verify ports 80/443 are reachable publicly, DNS is propagated, and Traefik certificate resolver is configured correctly. Review Traefik logs for ACME rate-limit or challenge errors.
Unexpected latency under load
Start by checking host CPU steal, disk I/O wait, and database query timing. Identity workloads often degrade when storage is saturated. Move database volume to faster disks and tune PostgreSQL memory parameters.
FAQ
Can I run ZITADEL without Traefik?
Yes, but in production you still need a robust TLS ingress layer and predictable routing. Traefik is convenient for certificate automation; NGINX or HAProxy are also valid if managed consistently.
Should PostgreSQL run in the same host?
For small teams and early production, yes. As traffic and security requirements grow, move PostgreSQL to a managed or clustered service and apply network segmentation and backup policies accordingly.
How do I rotate the ZITADEL master key safely?
Treat master key rotation as a planned maintenance operation with tested rollback. Snapshot database and configuration first, validate in staging, then promote to production with change approval.
What is the minimum backup policy?
At least daily encrypted dumps, 14–30 days retention, and one off-site copy. More importantly, test restore quarterly so backups are proven, not assumed.
Can I integrate external identity providers?
Yes. ZITADEL supports federation patterns including social and enterprise providers. Start with one provider, validate claim mapping and role assignment, then scale integration incrementally.
How do I harden admin access?
Enforce MFA for administrators, limit allowed IPs at the edge where possible, and separate break-glass accounts from normal operators. Review admin audit events regularly.
How do I upgrade with low risk?
Pin a tested image tag, stage upgrades in a pre-production environment, run smoke tests, then schedule production rollout with backup and rollback checkpoints.
Related guides
- Production Guide: Deploy Vaultwarden with Docker Compose + NGINX + PostgreSQL on Ubuntu
- Production Guide: Deploy Authentik with Docker Compose + Traefik on Ubuntu
- Production Guide: Deploy Keycloak with Docker Compose + Caddy on Ubuntu
Talk to us
If you need help designing resilient identity architecture, integrating SSO across multiple apps, or planning a secure migration from legacy IAM, our team can help.