Tools: Self-Hosting Zitadel with Docker Compose

Tools: Self-Hosting Zitadel with Docker Compose

What Is Zitadel?

Prerequisites

Architecture Overview

Docker Compose Configuration

Initial Setup

Adding an OIDC Application

Production Configuration

Reverse Proxy

Backup

Troubleshooting

Login Page Shows "Something Went Wrong"

OIDC Discovery Returns Connection Refused

High CPU During Login Storms

Verdict

Related Zitadel is an open-source identity management platform that handles authentication, authorization, and user management. It supports OIDC, SAML, passkeys, multi-factor authentication, and multi-tenancy out of the box. Think of it as a modern alternative to Keycloak — built with a cleaner API, a more intuitive console, and first-class support for machine-to-machine auth. Zitadel v4 uses a two-container architecture: The Login UI runs in the same network namespace as the core service (via network_mode: service:zitadel), sharing its ports. PostgreSQL is the sole supported database — CockroachDB support was dropped. Create a docker-compose.yml file: First startup takes 30-60 seconds for database initialization and schema creation. The start-from-init command handles everything automatically. First configuration steps: To protect a self-hosted service with Zitadel authentication: For production, update the Login V2 URLs to use your actual domain instead of localhost: Also generate a proper 32-character masterkey: The masterkey encrypts secrets stored in the database. Losing it means losing access to encrypted data. Zitadel needs both port 8080 (API/console) and port 3000 (login UI) accessible. Configure your reverse proxy to forward both, or use path-based routing to consolidate them behind a single domain. The /ui/v2/login/ path prefix routes to the login UI service. All other paths go to the core API. For a dedicated reverse proxy setup, see Reverse Proxy Guide. Back up the PostgreSQL database: Also preserve the zitadel-data volume (contains the login client PAT file) and your Docker Compose file (contains the masterkey). Critical: The masterkey in your Compose file is essential for decrypting database secrets. Back it up securely. For general backup strategies, see Backup Strategy. Symptom: Clicking login shows a generic error page. Fix: The login UI depends on the PAT file generated by the core service during first init. Check that the zitadel-data volume is shared between both containers and that the login container can read the login-client.pat file. Symptom: Applications can't reach /.well-known/openid-configuration. Fix: Verify ZITADEL_EXTERNALDOMAIN matches your actual domain and ZITADEL_EXTERNALSECURE matches whether you're using HTTPS. If behind a reverse proxy with SSL termination, set ZITADEL_TLS_ENABLED=false and ZITADEL_EXTERNALSECURE=true. Symptom: CPU spikes to 100% when many users log in simultaneously. Fix: Password hashing (bcrypt/argon2) is CPU-intensive by design. Allocate at least 4 CPU cores for production instances handling concurrent logins. This is a feature, not a bug — faster hashing would weaken security. Zitadel is the best modern alternative to Keycloak for self-hosted identity management. The API-first design, built-in multi-tenancy, passkey support, and clean admin console make it significantly more developer-friendly than Keycloak's XML-heavy configuration. The v4 two-container architecture adds complexity compared to Keycloak's single JAR, but the operational experience is smoother. Choose Zitadel if you're building new applications and want a modern identity provider. Choose Keycloak if you need maximum protocol compatibility or have existing Keycloak deployments. Choose Authentik if you want a simpler setup for protecting existing applications with a reverse proxy auth layer. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ services: zitadel: image: ghcr.io/zitadel/zitadel:v4.12.2 container_name: zitadel -weight: 500;">restart: unless-stopped command: -weight: 500;">start-from-init --masterkey "YourExactly32CharacterMasterKey!" # CHANGE THIS — must be exactly 32 chars environment: # External access ZITADEL_EXTERNALDOMAIN: auth.example.com # CHANGE to your domain ZITADEL_EXTERNALSECURE: "false" # Set true if Zitadel handles TLS directly ZITADEL_TLS_ENABLED: "false" # Set false when behind a reverse proxy # Database — admin connection (for schema setup) ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db ZITADEL_DATABASE_POSTGRES_PORT: "5432" ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: change-postgres-password # CHANGE THIS ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: -weight: 500;">disable # Database — application connection ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: change-zitadel-password # CHANGE THIS ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: -weight: 500;">disable # Login V2 integration ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-data/login-client.pat ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login Client Service Account ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2029-01-01T00:00:00Z" ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/ ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest= ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect= ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest= ports: - "8080:8080" - "3000:3000" volumes: - zitadel-data:/zitadel-data healthcheck: test: ["CMD", "/app/zitadel", "ready"] interval: 10s timeout: 60s retries: 5 start_period: 10s depends_on: zitadel-db: condition: service_healthy networks: - zitadel login: image: ghcr.io/zitadel/zitadel-login:v4.12.2 container_name: zitadel-login -weight: 500;">restart: unless-stopped environment: - ZITADEL_API_URL=http://localhost:8080 - NEXT_PUBLIC_BASE_PATH=/ui/v2/login - ZITADEL_SERVICE_USER_TOKEN_FILE=/zitadel-data/login-client.pat network_mode: -weight: 500;">service:zitadel volumes: - zitadel-data:/zitadel-data:ro depends_on: zitadel: condition: service_healthy zitadel-db: image: postgres:17 container_name: zitadel-db -weight: 500;">restart: unless-stopped environment: PGUSER: postgres POSTGRES_PASSWORD: change-postgres-password # Must match ADMIN_PASSWORD above healthcheck: test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"] interval: 10s timeout: 30s retries: 5 start_period: 20s volumes: - zitadel-postgres:/var/lib/postgresql/data networks: - zitadel volumes: zitadel-data: zitadel-postgres: networks: zitadel: driver: bridge services: zitadel: image: ghcr.io/zitadel/zitadel:v4.12.2 container_name: zitadel -weight: 500;">restart: unless-stopped command: -weight: 500;">start-from-init --masterkey "YourExactly32CharacterMasterKey!" # CHANGE THIS — must be exactly 32 chars environment: # External access ZITADEL_EXTERNALDOMAIN: auth.example.com # CHANGE to your domain ZITADEL_EXTERNALSECURE: "false" # Set true if Zitadel handles TLS directly ZITADEL_TLS_ENABLED: "false" # Set false when behind a reverse proxy # Database — admin connection (for schema setup) ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db ZITADEL_DATABASE_POSTGRES_PORT: "5432" ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: change-postgres-password # CHANGE THIS ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: -weight: 500;">disable # Database — application connection ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: change-zitadel-password # CHANGE THIS ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: -weight: 500;">disable # Login V2 integration ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-data/login-client.pat ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login Client Service Account ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2029-01-01T00:00:00Z" ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/ ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest= ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect= ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest= ports: - "8080:8080" - "3000:3000" volumes: - zitadel-data:/zitadel-data healthcheck: test: ["CMD", "/app/zitadel", "ready"] interval: 10s timeout: 60s retries: 5 start_period: 10s depends_on: zitadel-db: condition: service_healthy networks: - zitadel login: image: ghcr.io/zitadel/zitadel-login:v4.12.2 container_name: zitadel-login -weight: 500;">restart: unless-stopped environment: - ZITADEL_API_URL=http://localhost:8080 - NEXT_PUBLIC_BASE_PATH=/ui/v2/login - ZITADEL_SERVICE_USER_TOKEN_FILE=/zitadel-data/login-client.pat network_mode: -weight: 500;">service:zitadel volumes: - zitadel-data:/zitadel-data:ro depends_on: zitadel: condition: service_healthy zitadel-db: image: postgres:17 container_name: zitadel-db -weight: 500;">restart: unless-stopped environment: PGUSER: postgres POSTGRES_PASSWORD: change-postgres-password # Must match ADMIN_PASSWORD above healthcheck: test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"] interval: 10s timeout: 30s retries: 5 start_period: 20s volumes: - zitadel-postgres:/var/lib/postgresql/data networks: - zitadel volumes: zitadel-data: zitadel-postgres: networks: zitadel: driver: bridge services: zitadel: image: ghcr.io/zitadel/zitadel:v4.12.2 container_name: zitadel -weight: 500;">restart: unless-stopped command: -weight: 500;">start-from-init --masterkey "YourExactly32CharacterMasterKey!" # CHANGE THIS — must be exactly 32 chars environment: # External access ZITADEL_EXTERNALDOMAIN: auth.example.com # CHANGE to your domain ZITADEL_EXTERNALSECURE: "false" # Set true if Zitadel handles TLS directly ZITADEL_TLS_ENABLED: "false" # Set false when behind a reverse proxy # Database — admin connection (for schema setup) ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db ZITADEL_DATABASE_POSTGRES_PORT: "5432" ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: change-postgres-password # CHANGE THIS ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: -weight: 500;">disable # Database — application connection ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: change-zitadel-password # CHANGE THIS ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: -weight: 500;">disable # Login V2 integration ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-data/login-client.pat ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login Client Service Account ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2029-01-01T00:00:00Z" ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/ ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest= ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect= ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest= ports: - "8080:8080" - "3000:3000" volumes: - zitadel-data:/zitadel-data healthcheck: test: ["CMD", "/app/zitadel", "ready"] interval: 10s timeout: 60s retries: 5 start_period: 10s depends_on: zitadel-db: condition: service_healthy networks: - zitadel login: image: ghcr.io/zitadel/zitadel-login:v4.12.2 container_name: zitadel-login -weight: 500;">restart: unless-stopped environment: - ZITADEL_API_URL=http://localhost:8080 - NEXT_PUBLIC_BASE_PATH=/ui/v2/login - ZITADEL_SERVICE_USER_TOKEN_FILE=/zitadel-data/login-client.pat network_mode: -weight: 500;">service:zitadel volumes: - zitadel-data:/zitadel-data:ro depends_on: zitadel: condition: service_healthy zitadel-db: image: postgres:17 container_name: zitadel-db -weight: 500;">restart: unless-stopped environment: PGUSER: postgres POSTGRES_PASSWORD: change-postgres-password # Must match ADMIN_PASSWORD above healthcheck: test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"] interval: 10s timeout: 30s retries: 5 start_period: 20s volumes: - zitadel-postgres:/var/lib/postgresql/data networks: - zitadel volumes: zitadel-data: zitadel-postgres: networks: zitadel: driver: bridge -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://auth.example.com:3000/ui/v2/login/ ZITADEL_OIDC_DEFAULTLOGINURLV2: https://auth.example.com:3000/ui/v2/login/login?authRequest= ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://auth.example.com:3000/ui/v2/login/logout?post_logout_redirect= ZITADEL_SAML_DEFAULTLOGINURLV2: https://auth.example.com:3000/ui/v2/login/login?samlRequest= ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://auth.example.com:3000/ui/v2/login/ ZITADEL_OIDC_DEFAULTLOGINURLV2: https://auth.example.com:3000/ui/v2/login/login?authRequest= ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://auth.example.com:3000/ui/v2/login/logout?post_logout_redirect= ZITADEL_SAML_DEFAULTLOGINURLV2: https://auth.example.com:3000/ui/v2/login/login?samlRequest= ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://auth.example.com:3000/ui/v2/login/ ZITADEL_OIDC_DEFAULTLOGINURLV2: https://auth.example.com:3000/ui/v2/login/login?authRequest= ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://auth.example.com:3000/ui/v2/login/logout?post_logout_redirect= ZITADEL_SAML_DEFAULTLOGINURLV2: https://auth.example.com:3000/ui/v2/login/login?samlRequest= python3 -c "import secrets; print(secrets.token_urlsafe(24))" python3 -c "import secrets; print(secrets.token_urlsafe(24))" python3 -c "import secrets; print(secrets.token_urlsafe(24))" -weight: 500;">docker exec zitadel-db pg_dump -U postgres zitadel > zitadel-backup.sql -weight: 500;">docker exec zitadel-db pg_dump -U postgres zitadel > zitadel-backup.sql -weight: 500;">docker exec zitadel-db pg_dump -U postgres zitadel > zitadel-backup.sql - A Linux server (Ubuntu 22.04+ recommended) - Docker and Docker Compose installed (guide) - 2 GB of RAM (minimum) - 10 GB of free disk space - A domain name (required for production — OIDC doesn't work properly on IP addresses) - Access the admin console at http://your-server-ip:8080/ui/console - Log in with the default admin credentials: Email: zitadel-admin@zitadel.localhost Password: Password1! - Email: zitadel-admin@zitadel.localhost - Password: Password1! - Change the admin password immediately - Email: zitadel-admin@zitadel.localhost - Password: Password1! - Settings → General: Set your instance name and default language - Settings → Login Behavior: Configure password policies, MFA requirements - Organizations: Your default org is created automatically. Rename it to match your organization. - Projects: Create your first project to -weight: 500;">start adding OIDC/SAML applications - Go to Projects → Your Project → Applications → New - Select "Web" application type - Set the redirect URI to your application's callback URL (e.g., https://app.example.com/callback) - Copy the Client ID and Client Secret - Your OIDC endpoints are: Discovery: https://auth.example.com/.well-known/openid-configuration Authorization: https://auth.example.com/oauth/v2/authorize Token: https://auth.example.com/oauth/v2/token - Discovery: https://auth.example.com/.well-known/openid-configuration - Authorization: https://auth.example.com/oauth/v2/authorize - Token: https://auth.example.com/oauth/v2/token - Discovery: https://auth.example.com/.well-known/openid-configuration - Authorization: https://auth.example.com/oauth/v2/authorize - Token: https://auth.example.com/oauth/v2/token - RAM: ~512 MB for Zitadel, ~4-6 GB total with PostgreSQL caching - CPU: 2 cores minimum, 4 cores recommended for password hashing under load - Disk: 10 GB minimum for database and Docker images - Zitadel vs Keycloak: Self-Hosted IAM Compared - How to Self-Host Keycloak - Zitadel vs Authentik - How to Self-Host Authentik - How to Self-Host Authelia - Best Self-Hosted Authentication & SSO Tools - Docker Compose Basics - Reverse Proxy Guide - Backup Strategy