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.shruntime/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
| Container | Image | Port | Role |
|---|---|---|---|
zitadel | ghcr.io/zitadel/zitadel:v4.15.2 | 8080 (h2c) | Core API + console |
zitadel-login | ghcr.io/zitadel/zitadel-login:v4.15.2 | 3000 | Login V2 UI (Next.js) |
- The two images must share the same version tag.
- Networks: both join
proxy(Traefik); the core also joinspostgres-network. - The core auto-creates database
zitadel_dband userzitadel_db_usrvia the Postgres admin connection on first init. - The login app authenticates to the core with a
login-clientPAT that the core mints at init into the sharedzitadel-bootstrapvolume. - 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
proxynetwork 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
-
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_EXTERNALDOMAINto the dev host (the template defaults toauth.perspective-v.com).
-
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 -dThe compose
env_file:field is hardcoded to the VPS path. For dev, either point it at the dev template, or run with--env-fileas above and adjust theenv_file:line to match. -
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 usernameset-org-primary-domain.shreplaces the auto-generated<org>.<instance-domain>org domain with a clean custom primary domain (auto-verified becauseDOMAINPOLICY_VALIDATEORGDOMAINS=false).normalize-admin-username.shfixes the first admin's username: seeding a human viaFIRSTINSTANCE_ORG_HUMAN_*whileDOMAINPOLICY_USERLOGINMUSTBEDOMAIN=falsebakes 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-clientservice user. It backs the entire Login V2 UI; disabling it locks out all logins (the UI returnsInternal 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. Thezitadel-admin-saIAM_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_MASTERKEYmust 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