How to Deploy ZITADEL - Open-Source Identity and Access Management Platform

Updated on 06 August, 2026
Deploy ZITADEL on a Linux server using Docker Compose with PostgreSQL, Traefik, Let’s Encrypt HTTPS, and OIDC authentication.
How to Deploy ZITADEL - Open-Source Identity and Access Management Platform header image

ZITADEL is an open-source identity and access management platform that provides authentication, authorization, and user management for applications. It supports standardized protocols including OpenID Connect (OIDC), OAuth 2.0, and SAML, along with features like multi-factor authentication, passkeys, and single sign-on (SSO).

This article explains how to deploy ZITADEL on a Linux server using Docker Compose with PostgreSQL as the database and Traefik as the reverse proxy for TLS termination using Let's Encrypt.

Prerequisites

Before you begin, you need to:

Set Up the Directory Structure, Configuration, and Environment Variables

ZITADEL requires a project directory with persistent storage for the database and Let's Encrypt certificates. Environment variables control the domain, database credentials, and security settings.

  1. Add your user to the docker group.

    console
    $ sudo usermod -aG docker $USER
    
  2. Apply the group membership to the current session.

    console
    $ newgrp docker
    
  3. Create the project directory with subdirectories for persistent data.

    console
    $ mkdir -p ~/zitadel/{letsencrypt,postgres,zitadel-bootstrap}
    
    • letsencrypt: Stores SSL/TLS certificates.
    • postgres: Persists PostgreSQL database files.
    • zitadel-bootstrap: Shares the machine user's personal access token (PAT) between the zitadel-api and zitadel-login containers.
  4. Navigate to the project directory.

    console
    $ cd ~/zitadel
    
  5. Generate a 32-character masterkey for encrypting sensitive data.

    console
    $ tr -dc A-Za-z0-9 </dev/urandom | head -c 32
    

    Copy the output for use in the environment file in the next step.

  6. Create the environment file.

    console
    $ nano .env
    
  7. Add the following configuration. Replace zitadel.example.com with your domain name, admin@example.com with your email address, YOUR_32_CHARACTER_MASTERKEY with the generated masterkey, STRONG_DATABASE_PASSWORD with a secure password, and ADMIN_PASSWORD_VALUE with a secure password that meets the complexity requirements.

    ini
    # Domain Configuration
    ZITADEL_DOMAIN=zitadel.example.com
    LETSENCRYPT_EMAIL=admin@example.com
    
    # Security
    ZITADEL_MASTERKEY=YOUR_32_CHARACTER_MASTERKEY
    
    # Database Configuration
    POSTGRES_DB=zitadel
    POSTGRES_USER=postgres
    POSTGRES_PASSWORD=STRONG_DATABASE_PASSWORD
    
    # Initial Admin User
    ADMIN_USERNAME=admin
    ADMIN_PASSWORD=ADMIN_PASSWORD_VALUE
    
    # Pinned Versions
    ZITADEL_VERSION=v4.15.1
    TRAEFIK_VERSION=v3.7.0
    POSTGRES_VERSION=17.2-alpine
    

    Save and close the file.

    Note
    • The default password complexity policy requires a minimum of 8 characters including at least one uppercase letter, one lowercase letter, one number, and one symbol. ZITADEL fails to initialize if the password does not meet these requirements. Avoid using $ in passwords stored in .env files. Docker Compose interprets $ as a variable reference and silently removes the characters that follow it, which changes the effective password.
    • ZITADEL_VERSION is pinned to v4.15.1 in this guide. To use the latest release, check the ZITADEL releases page and replace the value before starting the services.

Deploy with Docker Compose

The deployment stack runs four services: Traefik as the reverse proxy for TLS termination, PostgreSQL for data persistence, and two ZITADEL components (API and Login UI). This configuration is based on the official ZITADEL Docker Compose setup.

  1. Create the Docker Compose manifest.

    console
    $ nano docker-compose.yml
    
  2. Add the following configuration.

    yaml
    services:
      traefik:
        image: traefik:${TRAEFIK_VERSION}
        container_name: zitadel-traefik
        restart: unless-stopped
        command:
          - "--providers.docker=true"
          - "--providers.docker.exposedbydefault=false"
          - "--providers.docker.network=zitadel"
          - "--entrypoints.web.address=:80"
          - "--entrypoints.websecure.address=:443"
          - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
          - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
          - "--certificatesresolvers.le.acme.httpchallenge=true"
          - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
          - "--certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL}"
          - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
        ports:
          - "80:80"
          - "443:443"
        networks:
          - zitadel
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock:ro
          - ./letsencrypt:/letsencrypt
    
      postgres:
        image: postgres:${POSTGRES_VERSION}
        container_name: zitadel-postgres
        restart: unless-stopped
        environment:
          POSTGRES_DB: ${POSTGRES_DB}
          POSTGRES_USER: ${POSTGRES_USER}
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
        volumes:
          - ./postgres:/var/lib/postgresql/data
        networks:
          - zitadel
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}"]
          interval: 10s
          timeout: 5s
          retries: 10
          start_period: 20s
    
      zitadel-api:
        image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
        container_name: zitadel-api
        restart: unless-stopped
        user: "0"
        command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"
        environment:
          ZITADEL_PORT: 8080
          ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN}
          ZITADEL_EXTERNALPORT: 443
          ZITADEL_EXTERNALSECURE: true
          ZITADEL_TLS_ENABLED: false
          ZITADEL_DATABASE_POSTGRES_DSN: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
          ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ADMIN_USERNAME}
          ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ADMIN_PASSWORD}
          ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: true
          ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
          ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
          ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
          ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
          ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
          ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${ZITADEL_DOMAIN}/ui/v2/login/
          ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?authRequest=
          ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/logout?post_logout_redirect=
          ZITADEL_SAML_DEFAULTLOGINURLV2: https://${ZITADEL_DOMAIN}/ui/v2/login/login?samlRequest=
        volumes:
          - ./zitadel-bootstrap:/zitadel/bootstrap:rw
        networks:
          - zitadel
        depends_on:
          postgres:
            condition: service_healthy
        healthcheck:
          test: ["CMD", "/app/zitadel", "ready"]
          interval: 10s
          timeout: 30s
          retries: 12
          start_period: 20s
        labels:
          - "traefik.enable=true"
          - "traefik.docker.network=zitadel"
          - "traefik.http.services.zitadel-api.loadbalancer.server.port=8080"
          - "traefik.http.services.zitadel-api.loadbalancer.server.scheme=h2c"
          - "traefik.http.middlewares.zitadel-strip-api.stripprefix.prefixes=/api"
          - "traefik.http.middlewares.zitadel-strip-api.stripprefix.forceSlash=false"
          - "traefik.http.routers.zitadel-api-alias.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/api`)"
          - "traefik.http.routers.zitadel-api-alias.entrypoints=websecure"
          - "traefik.http.routers.zitadel-api-alias.tls.certresolver=le"
          - "traefik.http.routers.zitadel-api-alias.middlewares=zitadel-strip-api"
          - "traefik.http.routers.zitadel-api-alias.service=zitadel-api"
          - "traefik.http.routers.zitadel-api-alias.priority=200"
          - "traefik.http.routers.zitadel-api.rule=Host(`${ZITADEL_DOMAIN}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)"
          - "traefik.http.routers.zitadel-api.entrypoints=websecure"
          - "traefik.http.routers.zitadel-api.tls.certresolver=le"
          - "traefik.http.routers.zitadel-api.service=zitadel-api"
          - "traefik.http.routers.zitadel-api.priority=100"
    
      zitadel-login:
        image: ghcr.io/zitadel/zitadel-login:${ZITADEL_VERSION}
        container_name: zitadel-login
        restart: unless-stopped
        user: "0"
        environment:
          ZITADEL_API_URL: http://zitadel-api:8080
          NEXT_PUBLIC_BASE_PATH: /ui/v2/login
          ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat
          CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:https
        volumes:
          - ./zitadel-bootstrap:/zitadel/bootstrap:ro
        networks:
          - zitadel
        depends_on:
          zitadel-api:
            condition: service_healthy
        healthcheck:
          test: ["CMD", "/bin/sh", "-c", "node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy"]
          interval: 10s
          timeout: 30s
          retries: 12
          start_period: 20s
        labels:
          - "traefik.enable=true"
          - "traefik.docker.network=zitadel"
          - "traefik.http.services.zitadel-login.loadbalancer.server.port=3000"
          - "traefik.http.middlewares.zitadel-root-rewrite.replacepath.path=/ui/v2/login/"
          - "traefik.http.routers.zitadel-root.rule=Host(`${ZITADEL_DOMAIN}`) && Path(`/`)"
          - "traefik.http.routers.zitadel-root.entrypoints=websecure"
          - "traefik.http.routers.zitadel-root.tls.certresolver=le"
          - "traefik.http.routers.zitadel-root.middlewares=zitadel-root-rewrite"
          - "traefik.http.routers.zitadel-root.service=zitadel-login"
          - "traefik.http.routers.zitadel-root.priority=400"
          - "traefik.http.routers.zitadel-login.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/ui/v2/login`)"
          - "traefik.http.routers.zitadel-login.entrypoints=websecure"
          - "traefik.http.routers.zitadel-login.tls.certresolver=le"
          - "traefik.http.routers.zitadel-login.service=zitadel-login"
          - "traefik.http.routers.zitadel-login.priority=250"
    
    networks:
      zitadel:
        name: zitadel
    

    Save and close the file.

    In the above manifest:

    • traefik: Serves as the reverse proxy and TLS termination point. It listens on ports 80 and 443, automatically redirects HTTP to HTTPS, and provisions Let's Encrypt certificates.
    • postgres: Runs PostgreSQL 17 as the primary database for storing identity data, users, organizations, and applications.
    • zitadel-api: Runs the main ZITADEL API server that handles authentication requests, user management, and administrative operations.
    • zitadel-login: Runs the ZITADEL Login UI (Next.js) that provides the user-facing authentication pages for login, registration, and account recovery.
  3. Start the services.

    console
    $ docker compose up -d
    
  4. Verify that all containers are running.

    console
    $ docker compose ps -a
    

    Verify that postgres, zitadel-api, and zitadel-login show a healthy status in the STATUS column, and that traefik shows Up, because it has no configured health check.

  5. View the service logs to confirm that ZITADEL started successfully.

    console
    $ docker compose logs
    

    The output displays database migrations completing and both ZITADEL services reporting healthy status.

For more information on managing a Docker Compose stack, see the How to Use Docker Compose article.

Note
  • If zitadel-api fails to start, run docker compose logs zitadel-api to check for initialization errors. A PasswordComplexityPolicy error means the admin password does not meet the requirements. ZITADEL writes a partial migration to the database that cannot be recovered by restarting alone. Stop the stack, remove all volumes with docker compose down -v, fix the password in .env, then run docker compose up -d to start fresh.
  • docker compose restart does not re-read the .env file. Always use docker compose up -d to apply configuration changes.

Access and Configure ZITADEL

ZITADEL automatically creates a default organization and admin user during the first startup using the credentials from your .env file.

  1. Open the ZITADEL Console in your web browser at https://zitadel.example.com/ui/console, replacing zitadel.example.com with your domain.

    ZITADEL Login Page

  2. Enter the full administrator login name in the format USERNAME@zitadel.DOMAIN (for example, admin@zitadel.zitadel.example.com).

    Note
    ZITADEL login names follow the format USERNAME@ORG_NAME.DOMAIN. The default organization name is zitadel.
  3. Enter the password you configured in the .env file (ADMIN_PASSWORD) and click Login.

  4. Enter a new password when prompted and click Continue. ZITADEL requires a password change on the first login.

    ZITADEL Dashboard

Application Use Case

An OIDC application in ZITADEL represents a client that authenticates users through the OpenID Connect protocol. Creating a test user and a registered application validates that ZITADEL's authentication and authorization flows are operational.

Create a User

A user account is required before an application can authenticate against it.

  1. In the ZITADEL Console, click Users in the top navigation bar.

  2. Click + New.

  3. Enter the user details:

    • E-mail
    • User Name
    • First Name
    • Last Name
  4. Select Set an initial password for the User and enter a password.

  5. Click Create.

Create an Application

A project groups one or more registered applications under a shared configuration.

  1. Click Projects in the top navigation bar.

  2. Click Create New Project.

  3. Enter a project name such as Test Application and click Continue.

  4. In the project view, click + New under the Applications section.

  5. Enter an application name such as Web App, select Web as the application type, and click Continue.

  6. Select an authentication method and click Continue.

  7. Add a redirect URI for your application (for example, https://example.com/callback) and click Continue.

  8. On the Overview page, review the settings and click Create.

  9. Copy the Client ID from the Client Details popup for use in your application's OIDC configuration. Use your ZITADEL domain as the issuer URL.

  10. Click Close.

This confirms that ZITADEL can create users and applications, and is ready to provide identity services for your applications.

Conclusion

You have deployed ZITADEL on a Linux server using Docker Compose with Traefik for TLS termination and PostgreSQL for persistent data storage. For advanced configuration including custom branding, identity providers, and production hardening, refer to the official ZITADEL documentation.

Comments