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:

ComponentPurposeImage
Zitadel CoreAPI, gRPC, admin console, OIDC/SAML endpointsghcr.io/zitadel/zitadel
Login UIUser-facing login flows (Next.js)ghcr.io/zitadel/zitadel-login
PostgreSQLDatabase for all identity datapostgres: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

  1. Access the admin console at http://your-server-ip:8080/ui/console
  2. Log in with the default admin credentials:
  3. Change the admin password immediately

First configuration steps:

  1. Settings → General: Set your instance name and default language
  2. Settings → Login Behavior: Configure password policies, MFA requirements
  3. Organizations: Your default org is created automatically. Rename it to match your organization.
  4. Projects: Create your first project to start adding OIDC/SAML applications

Adding an OIDC Application

To protect a self-hosted service with Zitadel authentication:

  1. Go to Projects → Your Project → Applications → New
  2. Select “Web” application type
  3. Set the redirect URI to your application’s callback URL (e.g., https://app.example.com/callback)
  4. Copy the Client ID and Client Secret
  5. 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

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.

Comments