Perspective V Docs

Zitadel Guide

Zitadel identity provider — two-container (core + Login V2) deployment behind the shared edge Traefik, backed by the shared Postgres.

Zitadel is the identity provider. It runs as two containers — the core API/console and the separate Login V2 UI — behind the shared edge Traefik, using the shared Postgres. This page covers standing it up in a dev environment; the VPS deployment is identical except for env values and domain.

Source Of Truth

  • Active compose: runtime/stacks/infrastructure/zitadel/docker-compose.yml
  • VPS env: runtime/environments/vps/infrastructure/zitadel/.env
  • Dev template (placeholder secrets): runtime/environments/dev/infrastructure/zitadel/zitadel.dev.env
  • Post-init helpers:
    • runtime/stacks/infrastructure/zitadel/set-org-primary-domain.sh
    • runtime/stacks/infrastructure/zitadel/normalize-admin-username.sh

There is no runtime/scripts/infrastructure/zitadel.{sh,bat} launcher yet; Zitadel is driven directly with docker compose.

Architecture

ContainerImagePortRole
zitadelghcr.io/zitadel/zitadel:v4.15.28080 (h2c)Core API + console
zitadel-loginghcr.io/zitadel/zitadel-login:v4.15.23000Login V2 UI (Next.js)
  • The two images must share the same version tag.
  • Networks: both join proxy (Traefik); the core also joins postgres-network.
  • The core auto-creates database zitadel_db and user zitadel_db_usr via the Postgres admin connection on first init.
  • The login app authenticates to the core with a login-client PAT that the core mints at init into the shared zitadel-bootstrap volume.
  • TLS terminates at Traefik (ExternalSecure=true, TLS_ENABLED=false); the backend is h2c. Traefik splits the single host: /ui/v2/login/* and / → login container; /api (prefix-stripped) and everything else → core.

Prerequisites

  • Shared proxy network and the edge Traefik running (see Edge).
  • Shared Postgres running on postgres-network (see Databases).
  • A DNS record for the chosen external domain resolving to the host.

Dev Setup

  1. Fill in the dev env template — replace every placeholder in runtime/environments/dev/infrastructure/zitadel/zitadel.dev.env:

    • ZITADEL_MASTERKEY — must be exactly 32 bytes (e.g. tr -dc 'A-Za-z0-9' </dev/urandom | head -c32).
    • ZITADEL_DATABASE_POSTGRES_USER_PASSWORD — password Zitadel will create its DB user with.
    • ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD — the dev Postgres superuser password.
    • ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD — first admin login.
    • Set ZITADEL_EXTERNALDOMAIN to the dev host (the template defaults to auth.perspective-v.com).
  2. Bring the stack up, pointing the env file at the dev template:

    cd runtime/stacks/infrastructure/zitadel
    docker compose --env-file ../../../environments/dev/infrastructure/zitadel/zitadel.dev.env up -d

    The compose env_file: field is hardcoded to the VPS path. For dev, either point it at the dev template, or run with --env-file as above and adjust the env_file: line to match.

  3. Wait for both containers to report healthy:

    docker compose ps

Post-Init Steps

Run these once after a fresh init (they use the admin service-account PAT minted at init). Both are idempotent.

cd runtime/stacks/infrastructure/zitadel
./set-org-primary-domain.sh        # org primary domain -> ZITADEL_EXTERNALDOMAIN
./normalize-admin-username.sh      # strip init-baked domain suffix from the seeded admin username
  • set-org-primary-domain.sh replaces the auto-generated <org>.<instance-domain> org domain with a clean custom primary domain (auto-verified because DOMAINPOLICY_VALIDATEORGDOMAINS=false).
  • normalize-admin-username.sh fixes the first admin's username: seeding a human via FIRSTINSTANCE_ORG_HUMAN_* while DOMAINPOLICY_USERLOGINMUSTBEDOMAIN=false bakes the org domain into the username; this renames it back to the bare form.

SMTP

The FIRSTINSTANCE_SMTPCONFIGURATION_* keys in the env file only seed at the first init. On an existing instance, set SMTP via the Admin API or the console (Instance → Notifications → SMTP):

PAT=$(docker run --rm -v zitadel_zitadel-bootstrap:/b:ro busybox cat /b/admin-sa.pat)
B=https://<external-domain>
curl -fsS -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
  -X POST "$B/admin/v1/smtp" \
  -d '{"senderAddress":"no-reply@example.com","senderName":"Name","tls":true,"host":"smtp.example.com:465","user":"no-reply@example.com","password":"..."}'
# then activate with the returned id:
curl -fsS -H "Authorization: Bearer $PAT" -X POST "$B/admin/v1/smtp/<id>/_activate"

Validate

curl -s -o /dev/null -w "%{http_code}\n" https://<external-domain>/ui/console            # 200
curl -s -o /dev/null -w "%{http_code}\n" https://<external-domain>/ui/v2/login/healthy    # 200
curl -s https://<external-domain>/.well-known/openid-configuration | python3 -c "import sys,json;print(json.load(sys.stdin)['issuer'])"

A bare visit to / redirects to the login page; /ui/v2/login/login without an authRequest returns 400 (expected — it needs the OIDC flow).

Important Rules

  • Never deactivate the login-client service user. It backs the entire Login V2 UI; disabling it locks out all logins (the UI returns Internal server error). Recovery is hard: event-store reactivation only updates one of Zitadel's three projection schemas (projections / auth / adminapi), so token validation stays broken and a re-init is required. The zitadel-admin-sa IAM_OWNER account is the one safe to disable when no API-driven config is in progress.
  • Keep the core and login images on the same version tag.
  • The ZITADEL_MASTERKEY must be exactly 32 bytes and must not change after first boot, or existing encrypted data becomes unreadable.

Re-Init (Destructive)

A clean re-init wipes all instance data (users, branding/logos, SMTP) but reliably resets a broken instance. The seeded admin is re-created from the env; other users and uploaded assets are not.

cd runtime/stacks/infrastructure/zitadel
docker compose down -v
docker exec -e PGPASSWORD='<admin-pw>' postgres psql -U postgres \
  -c "DROP DATABASE IF EXISTS zitadel_db WITH (FORCE);" -c "DROP ROLE IF EXISTS zitadel_db_usr;"
docker compose --env-file <env-file> up -d
# then re-run the post-init steps above

On this page