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

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:
- Have access to a Linux-based server (with at least 4 CPU cores and 8 GB of RAM) as a non-root user with sudo privileges.
- Install Docker and Docker Compose.
- Create a DNS A record pointing to your server's IP address (for example,
zitadel.example.com).
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.
Add your user to the
dockergroup.console$ sudo usermod -aG docker $USER
Apply the group membership to the current session.
console$ newgrp docker
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 thezitadel-apiandzitadel-logincontainers.
Navigate to the project directory.
console$ cd ~/zitadel
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.
Create the environment file.
console$ nano .env
Add the following configuration. Replace
zitadel.example.comwith your domain name,admin@example.comwith your email address,YOUR_32_CHARACTER_MASTERKEYwith the generated masterkey,STRONG_DATABASE_PASSWORDwith a secure password, andADMIN_PASSWORD_VALUEwith 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.envfiles. Docker Compose interprets$as a variable reference and silently removes the characters that follow it, which changes the effective password. ZITADEL_VERSIONis pinned tov4.15.1in this guide. To use the latest release, check the ZITADEL releases page and replace the value before starting the services.
- 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
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.
Create the Docker Compose manifest.
console$ nano docker-compose.yml
Add the following configuration.
yamlservices: 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.
Start the services.
console$ docker compose up -d
Verify that all containers are running.
console$ docker compose ps -a
Verify that
postgres,zitadel-api, andzitadel-loginshow ahealthystatus in the STATUS column, and thattraefikshowsUp, because it has no configured health check.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.
- If
zitadel-apifails to start, rundocker compose logs zitadel-apito check for initialization errors. APasswordComplexityPolicyerror 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 withdocker compose down -v, fix the password in.env, then rundocker compose up -dto start fresh. docker compose restartdoes not re-read the.envfile. Always usedocker compose up -dto 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.
Open the ZITADEL Console in your web browser at
https://zitadel.example.com/ui/console, replacingzitadel.example.comwith your domain.
Enter the full administrator login name in the format
USERNAME@zitadel.DOMAIN(for example,admin@zitadel.zitadel.example.com).ZITADEL login names follow the formatNoteUSERNAME@ORG_NAME.DOMAIN. The default organization name iszitadel.Enter the password you configured in the
.envfile (ADMIN_PASSWORD) and click Login.Enter a new password when prompted and click Continue. ZITADEL requires a password change on the first login.

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.
In the ZITADEL Console, click Users in the top navigation bar.
Click + New.
Enter the user details:
- User Name
- First Name
- Last Name
Select Set an initial password for the User and enter a password.
Click Create.
Create an Application
A project groups one or more registered applications under a shared configuration.
Click Projects in the top navigation bar.
Click Create New Project.
Enter a project name such as
Test Applicationand click Continue.In the project view, click + New under the Applications section.
Enter an application name such as
Web App, select Web as the application type, and click Continue.Select an authentication method and click Continue.
Add a redirect URI for your application (for example,
https://example.com/callback) and click Continue.On the Overview page, review the settings and click Create.
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.
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.