ZITADEL is an open-source IAM platform supporting OIDC, OAuth 2.0, and SAML, with MFA, passkeys, and SSO built in. This guide deploys it via Docker Compose with PostgreSQL and Traefik-managed TLS, then walks through creating a test user and an OIDC application.
Prerequisites: a Linux server (4 vCPU / 8GB RAM minimum), Docker + Docker Compose, a domain A record (e.g.
zitadel.example.com).
Set Up the Project
$ sudo usermod -aG docker $USER
$ newgrp docker
$ mkdir -p ~/zitadel/{letsencrypt,postgres,zitadel-bootstrap}
$ cd ~/zitadel
-
letsencrypt— TLS certs -
postgres— database files -
zitadel-bootstrap— shares the machine user's personal access token between the API and login containers
Generate a 32-char masterkey:
$ tr -dc A-Za-z0-9 </dev/urandom | head -c 32
Create the environment file:
$ nano .env
ZITADEL_DOMAIN=zitadel.example.com
LETSENCRYPT_EMAIL=admin@example.com
ZITADEL_MASTERKEY=YOUR_32_CHARACTER_MASTERKEY
POSTGRES_DB=zitadel
POSTGRES_USER=postgres
POSTGRES_PASSWORD=STRONG_DATABASE_PASSWORD
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ADMIN_PASSWORD_VALUE
ZITADEL_VERSION=v4.15.1
TRAEFIK_VERSION=v3.7.0
POSTGRES_VERSION=17.2-alpine
Replace the placeholders with real values. Two gotchas:
- The admin password needs 8+ chars with upper/lower/number/symbol, or ZITADEL fails to initialize.
- Avoid
$in.envpasswords — Docker Compose treats it as a variable reference and silently strips what follows.
Check the ZITADEL releases page if you want a version other than the pinned v4.15.1.
Deploy with Docker Compose
Four services: Traefik (TLS), PostgreSQL, the ZITADEL API, and the Login UI.
$ nano docker-compose.yml
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
- traefik — TLS termination, HTTP→HTTPS redirect, ACME certs
- postgres — identity data, users, orgs, apps
- zitadel-api — auth, user management, admin operations
- zitadel-login — the Next.js login/registration/recovery UI
$ docker compose up -d
$ docker compose ps -a
$ docker compose logs
postgres, zitadel-api, and zitadel-login should report healthy; traefik shows Up (no health check configured on it).
If zitadel-api won't start, check docker compose logs zitadel-api — a PasswordComplexityPolicy error means your admin password doesn't meet requirements. ZITADEL writes a partial migration that can't be fixed by just restarting: docker compose down -v, fix .env, then docker compose up -d for a clean start. Also note docker compose restart doesn't re-read .env — always use up -d after config changes.
First-Run Setup
Open https://zitadel.example.com/ui/console:
- Login name format:
USERNAME@zitadel.DOMAIN(e.g.admin@zitadel.zitadel.example.com) — the default org is namedzitadel. - Enter your
ADMIN_PASSWORDfrom.env. - Set a new password when prompted (first-login change is mandatory).
Create a Test User and OIDC App
1. Create a user: Users → + New → email, username, first/last name, set an initial password, Create.
2. Create a project and application:
-
Projects → Create New Project, name it (e.g.
Test Application), Continue. - Under Applications, + New → name it, type Web, Continue.
- Pick an auth method, Continue.
- Add a redirect URI (e.g.
https://example.com/callback), Continue. - Review on Overview, Create.
- Copy the Client ID — use your ZITADEL domain as the issuer URL in your app's OIDC config.
Next Steps
ZITADEL is running with TLS, a bootstrapped admin org, and a working OIDC app. From here:
- Add identity providers (Google, GitHub, generic OIDC/SAML) for federated login
- Enable MFA and passkeys for the org
- Customize branding on the login UI before rolling out to end users
For the full guide, visit the original article on Vultr Docs.
Top comments (0)