Zitadel Setup — Operator Runbook
This doc covers everything an operator needs to set up Zitadel for ShiftIt across three deployment shapes: local dev (one command via scripts/zitadel-up.sh), Zitadel Cloud (managed SaaS), and self-hosted Zitadel (the compose service block in deploy/docker/docker-compose.yml:61-76). It also documents the environment-variable contract between Zitadel and ShiftIt, how to onboard a new tenant, and a troubleshooting matrix of the common failure modes.
Story 2.0 ships the local-dev half (the up/down scripts + this doc); Story 2.2 will switch scripts/run-backend.sh and the frontend OIDC client off scripts/mock-jwks.py and onto whatever Zitadel the operator points it at.
1. Local dev quickstart
# Spin up Zitadel + its own Postgres (90 seconds, idempotent):
scripts/zitadel-up.sh
# Tear it down (idempotent):
scripts/zitadel-down.sh
# Or keep generated artifacts for inspection:
scripts/zitadel-down.sh --keep-vars
What the up script produces:
| URL | What it serves |
|---|---|
|
Zitadel admin console (web UI) |
|
OIDC issuer (matches |
|
JWKS endpoint the backend’s |
|
OIDC discovery doc |
Bootstrap admin login (created on first boot via ZITADEL_FIRSTINSTANCE_* env vars): admin / Admin123! (login name resolves to admin@<orgdomain> — Zitadel appends the org’s primary domain when you log in via the console).
The script also writes a downstream-consumable env file at dev/zitadel-vars.env:
ZITADEL_ISSUER_URL=http://localhost:8090
ZITADEL_DOMAIN=http://localhost:8090
ZITADEL_AUTH_URL=http://localhost:8090
ZITADEL_JWKS_URL=http://localhost:8090/oauth/v2/keys
ZITADEL_DEMO_ORG_ID=... # zitadel-generated org UUID
ZITADEL_DEMO_PROJECT_ID=... # zitadel-generated project UUID
ZITADEL_ADMIN_CLIENT_ID=...
ZITADEL_EMPLOYEE_CLIENT_ID=...
ZITADEL_CUSTOMER_CLIENT_ID=...
ZITADEL_EXPECTED_AUDIENCE=... # = the project_id; see §3 + the backend TODO
The trailing comment block in the file contains a copy-pasteable UPDATE tenants ... statement that reconciles tenants.zitadel_org_id (placeholder demo-org in scripts/seed-demo.sql:11) against the real Zitadel-generated org UUID. The up script attempts this UPDATE automatically when shiftit-pg is running and reachable.
Port allocation in dev
| Port | Service |
|---|---|
8080 |
Frontend Trunk dev server ( |
8090 |
Zitadel (this story’s container) |
5433 |
shiftit-pg (application Postgres) |
5434 |
shiftit-zitadel-pg (Zitadel’s own Postgres) |
3010 |
Backend dev server ( |
Zitadel was originally specced on :8080 but that collides with Trunk. The deviation is documented in Story 2.0’s Change Log.
Seed data parity
The up script seeds Zitadel to match scripts/seed-demo.sql:11, 20-23 so the backend’s tenant + user FKs resolve cleanly once Story 2.2 wires real JWT validation in:
| Username | Role | Password | |
|---|---|---|---|
|
admin |
|
|
|
employee |
|
|
|
customer |
|
All seed users have email.isVerified=true and password.changeRequired=false; MFA is not enforced (default org login policy). Every user has a tenant_id metadata key set to the demo tenant UUID (00000000-0000-0000-0000-000000000001), copied into a tenant_id claim on token issuance via a complement-token action (forward-compat for Story 2.2 — see §3).
2. Environment-variable contract
Every variable below appears in dev/zitadel-vars.env. Story 2.2 will pick which subset the backend / frontend read from at runtime vs. build time.
| Variable | Read by | Consumed at | Production equivalent |
|---|---|---|---|
|
backend |
|
|
|
backend |
|
same as above (alias) |
|
frontend (build-time) |
|
injected at frontend build by CI |
|
informational |
not directly read; derived from |
derived |
|
one-shot SQL reconcile |
|
set per-tenant during tenant onboarding (see §5) |
|
backend (audience check) |
future |
same key, different value per environment |
|
frontend (build-time) |
|
one value per frontend build; Story 2.2 swaps the const |
|
backend |
future audience claim assertion — same TODO as above |
= project_id |
The backend’s JwksCache::validate_token (backend/src/auth/jwks.rs:77-122) is the canonical JWT validation flow today. It sets issuer (set_issuer) but does NOT yet validate audience. Story 2.2 closes that gap by wiring ZITADEL_EXPECTED_AUDIENCE.
3. Setting up Zitadel Cloud
For most production deployments, Zitadel Cloud (the managed SaaS) is the path of least friction. Steps below are precise UI paths; screenshots intentionally omitted (they rot fast).
-
Create a Zitadel Cloud account at
https://zitadel.com/signinif you don’t have one. You get one free instance. -
Create an instance. From the Customer Portal:
Customer Portal > New Instance > pick a region close to your backend. Note the instance URL — it’s the value ofZITADEL_ISSUER_URL(e.g.https://shiftit-prod-abc123.zitadel.cloud). -
Create an organization for the customer.
Zitadel Console > Organizations > Default > New. Name = your customer’s legal name (e.g.Acme GmbH). Copy the org’s ID from the URL bar after creation; this istenants.zitadel_org_idfor that tenant. -
Create the ShiftIt project.
Zitadel Console > [pick the org] > Projects > New. NameShiftIt. After creation, open the project’s General settings and toggle ON both:-
Assert Roles on Authentication(=projectRoleAssertion) -
Check Roles at Authentication(=projectRoleCheck)
-
-
Create the three OIDC applications under the project (matches §1’s table). For each:
-
Project > [ShiftIt] > General > New Application > Web > continue -
Name:
shiftit-admin/shiftit-employee/shiftit-customer -
Authentication method:
PKCE(NOTBasic/Post/None) -
Redirect URIs:
-
admin →
https://app.shiftit.example/auth/admin/callback -
employee →
https://app.shiftit.example/auth/employee/callback -
customer →
https://app.shiftit.example/auth/callback
-
-
Post-logout URIs: same hostname, paths
/admin,/employee,/ -
Save → copy the client ID. These go into
ZITADEL_ADMIN_CLIENT_IDetc.
-
-
Create the three project roles.
Project > [ShiftIt] > Roles > New. Three roles, keys exactlyadmin,employee,customer. -
Onboard the first admin user.
Users > New > Human > username + email + password. Mark email verified (skip the verification mail flow for the bootstrap user). Then `Authorizations > New > pick the user, the ShiftIt project, the admin roleSave`.
-
Copy the values into the operator’s
.env(used by your deploy tool of choice —deploy/docker/docker-compose.ymlreadsZITADEL_DOMAINfrom there).
4. Setting up self-hosted Zitadel
The production compose at deploy/docker/docker-compose.yml:61-76 already includes a zitadel: service block ready to run. Two operator-side prerequisites:
-
Generate a masterkey. This is the symmetric key Zitadel uses to encrypt secrets at rest in its own database. It is unchangeable post-install; losing it means re-initializing Zitadel and losing every user / org / token.
openssl rand -base64 24 | head -c 32Store the output in your secrets manager and export it as
ZITADEL_MASTERKEYfor the compose stack. -
Set
ZITADEL_DOMAINto the external hostname Zitadel will be reached at (e.g.https://auth.shiftit.example). The compose service reads it asZITADEL_EXTERNALDOMAINand bakes it into every issuer claim.
Once the compose stack is up, the rest of the setup mirrors §3 (create org → project → 3 apps → 3 roles → first admin user). You can drive that via the console UI, or replay the management-API calls from scripts/zitadel-up.sh — the calls are the same; only the issuer URL and the bootstrap PAT path differ.
5. Onboarding a new tenant
For every new ShiftIt tenant (i.e. every new customer organization on a SaaS deployment, or the only org on a single-tenant self-host):
-
Create a Zitadel organization for the tenant (UI or API).
-
Note the org’s ID (UI: bottom of the org’s detail page; API:
POST /v2/organizationsreturn value,organizationId). -
Insert the
tenantsrow in shiftit-pg withzitadel_org_idset to that UUID:INSERT INTO tenants (id, zitadel_org_id, name, slug, settings) VALUES (gen_random_uuid(), '<the-zitadel-org-id>', 'Acme GmbH', 'acme', '{"time_tracking_enabled": true, "default_claiming_mode": "open", ...}'); -
Under the ShiftIt project, the three OIDC apps and three project roles created at install time are shared across all orgs. Per-org admin onboarding is just
Users > New > Humanunder the org, plus anAuthorizationsgrant for the admin role.
After step 3 the backend’s auth/middleware.rs:101-104 will resolve the new tenant from any JWT issued for a user under that Zitadel org.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Every API call returns 401 with no further info |
JWKS URL is wrong, or the JWKS cache failed to populate. |
|
Backend log: |
The token was signed by a key the backend doesn’t know about. Either the issuer URL is misconfigured ( |
Verify |
Backend log: |
Clock skew, expired token, or a wrong-issuer token. |
Check |
Backend log: |
The |
For local dev: rerun |
Backend log: |
Token’s |
Story 2.2 reconciles claim shape. Until then, the complement-token action seeded by |
Token endpoint returns |
Frontend’s |
Customer OAuth against this dev Zitadel will return |
Refresh token rejected |
|
Confirm both: (a) every |
JWKS empty ( |
The web-key feature is disabled at the instance level (v2.71 default). |
|
|
Trying to manually create a web key without enabling the feature first. |
Enable the feature first (see above). |
7. Other dev dependencies
ShiftIt’s dev environment has a few moving parts beyond Zitadel:
-
Mailpit STARTTLS smoke (
docker run -d --name shiftit-mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit) — currently an outstanding manual task before Story 3.2 (per-locale email templates). SeeCLAUDE.md"Outstanding manual task" section. -
scripts/mock-jwks.py— still used byscripts/run-backend.shfor backend-only unit work that doesn’t need a real OIDC issuer. Story 2.2 will switchrun-backend.shto point at the real Zitadel from this story. -
_bmad-output/implementation-artifacts/2-3-customer-auth-substrate.md— Story 2.3’s "Manual Smoke Plan" describes the customer-side login smoke this dev environment unblocks. Once Story 2.2 lands, that runbook is the end-to-end verification.