DEV Community

Cover image for Deploying ZITADEL – Open-Source Identity and Access Management Platform
Sanskriti Harmukh for Vultr

Posted on with Aashish Chaurasiya • Originally published at docs.vultr.com

Deploying ZITADEL – Open-Source Identity and Access Management Platform

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

Create the environment file:

$ nano .env
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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 .env passwords — 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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Login name format: USERNAME@zitadel.DOMAIN (e.g. admin@zitadel.zitadel.example.com) — the default org is named zitadel.
  2. Enter your ADMIN_PASSWORD from .env.
  3. 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:

  1. Projects → Create New Project, name it (e.g. Test Application), Continue.
  2. Under Applications, + New → name it, type Web, Continue.
  3. Pick an auth method, Continue.
  4. Add a redirect URI (e.g. https://example.com/callback), Continue.
  5. Review on Overview, Create.
  6. 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)