Self-Hosting Zitadel with Docker Compose
What Is Zitadel?
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.
Prerequisites
- 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)
Architecture Overview
Zitadel v4 uses a two-container architecture:
| Component | Purpose | Image |
|---|---|---|
| Zitadel Core | API, gRPC, admin console, OIDC/SAML endpoints | ghcr.io/zitadel/zitadel |
| Login UI | User-facing login flows (Next.js) | ghcr.io/zitadel/zitadel-login |
| PostgreSQL | Database for all identity data | postgres:17 |
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.
Docker Compose Configuration
Create a docker-compose.yml file:
services:
zitadel:
image: ghcr.io/zitadel/zitadel:v4.11.0
container_name: zitadel
restart: unless-stopped
command: 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: 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: 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.11.0
container_name: zitadel-login
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: service:zitadel
volumes:
- zitadel-data:/zitadel-data:ro
depends_on:
zitadel:
condition: service_healthy
zitadel-db:
image: postgres:17
container_name: zitadel-db
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
Start the stack:
docker compose up -d
First startup takes 30-60 seconds for database initialization and schema creation. The start-from-init command handles everything automatically.
Initial Setup
- Access the admin console at
http://your-server-ip:8080/ui/console - Log in with the default admin credentials:
- Email:
[email protected] - Password:
Password1!
- Email:
- Change the admin password immediately
First configuration steps:
- 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 start adding OIDC/SAML applications
Adding an OIDC Application
To protect a self-hosted service with Zitadel authentication:
- 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:
Production Configuration
For production, update the Login V2 URLs to use your actual domain instead of localhost:
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=
Also generate a proper 32-character masterkey:
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
The masterkey encrypts secrets stored in the database. Losing it means losing access to encrypted data.
Reverse Proxy
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.
Backup
Back up the PostgreSQL database:
docker exec zitadel-db pg_dump -U postgres zitadel > zitadel-backup.sql
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.
Troubleshooting
Login Page Shows “Something Went Wrong”
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.
OIDC Discovery Returns Connection Refused
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.
High CPU During Login Storms
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.
Resource Requirements
- 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
Verdict
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.
Related
Get self-hosting tips in your inbox
New guides, comparisons, and setup tutorials — delivered weekly. No spam.
Comments