How-to Guides¶
Tasks covered
Deploy Zitadel with Docker Compose or Helm, connect it to managed PostgreSQL, put it behind a proxy, enable caching and observability, upgrade (including v3 to v4 and CockroachDB to PostgreSQL), back up, call the v2 APIs, use the SDKs and Terraform, wire up Actions V2, and troubleshoot. Commands follow the official docs as of v4.19 (2026-09). Background is in Explanation; versions, paths, and config keys are in Reference.
Pin a patched version
Several critical and high advisories were fixed in July to September 2026. Use v4.19.1 or later, skip v4.18.0 (setup step fails), and do not deploy new v3 installs (end of life). See Reference.
Deployment¶
Docker Compose (Quick Start)¶
The official compose pack runs Traefik, the Zitadel API, the Login V2 container, and PostgreSQL:
mkdir zitadel-compose && cd zitadel-compose
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
cp .env.example .env
docker compose up -d --wait
Open http://localhost:8080. The first admin is zitadel-admin@zitadel.localhost with password Password1! unless you set ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD before the first start.
Pinned versions
.env.example pins ZITADEL_VERSION (it was v4.16.0 when checked), plus Traefik, PostgreSQL 17, Redis 7.4, and the OTel collector. Bump ZITADEL_VERSION to the latest patched release before you go past a laptop test.
Harden the Compose Deployment¶
Generate secrets before the first start. The masterkey must be exactly 32 characters and cannot be changed later:
ZITADEL_MASTERKEY=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)
echo "ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY" >> .env
echo "POSTGRES_ADMIN_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
echo "POSTGRES_ZITADEL_PASSWORD=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" >> .env
echo "ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD='Change-Me-1!'" >> .env
Set your domain and a TLS overlay (mode-letsencrypt, mode-external-tls, or mode-local-tls):
# in .env: ZITADEL_DOMAIN=auth.example.com, ZITADEL_EXTERNALPORT=443, ZITADEL_EXTERNALSECURE=true
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
docker compose --env-file .env \
-f docker-compose.yml \
-f docker-compose.mode-letsencrypt.yml \
up -d --wait
For controlled upgrades and scaling, add the prodlike overlay, which splits zitadel-init, zitadel-setup, and zitadel-api:
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.prodlike.yml
docker compose --env-file .env -f docker-compose.yml -f docker-compose.prodlike.yml \
up -d --scale zitadel-api=3
Kubernetes via Helm (Quickstart)¶
Requires Kubernetes 1.30+, Helm 3 or 4, and an ingress or Gateway API controller. The chart can deploy a bundled PostgreSQL for testing:
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel-charts/main/examples/0-quickstart/quickstart-values.yaml
# edit ExternalDomain, ExternalPort and both ingress className values
helm repo add zitadel https://charts.zitadel.com
helm repo update
helm upgrade --install zitadel zitadel/zitadel --values quickstart-values.yaml --wait
Kubernetes via Helm (Production)¶
Create the masterkey and database secrets first:
kubectl create namespace zitadel
kubectl -n zitadel create secret generic zitadel-masterkey \
--from-literal=masterkey="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
kubectl -n zitadel create secret generic zitadel-db-credentials \
--from-literal=dsn="postgresql://zitadel:REPLACE_ME@postgres.database.svc.cluster.local:5432/postgres?sslmode=verify-full"
Minimal production values.yaml (from the official Kubernetes guide), with ingress for both the API and Login V2:
replicaCount: 2
zitadel:
masterkeySecretName: zitadel-masterkey
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-credentials
key: dsn
configmapConfig:
ExternalDomain: "auth.example.com"
ExternalSecure: true
ExternalPort: 443
TLS:
Enabled: false # TLS terminated at the ingress
FirstInstance:
Org:
Human:
UserName: "admin"
Email: "admin@example.com"
PasswordChangeRequired: true
podDisruptionBudget:
enabled: true
minAvailable: 1
ingress:
enabled: true
className: nginx
hosts:
- host: auth.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: zitadel-tls
hosts: [auth.example.com]
login:
ingress:
enabled: true
className: nginx
hosts:
- host: auth.example.com
paths:
- path: /ui/v2/login
pathType: Prefix
tls:
- secretName: zitadel-tls
hosts: [auth.example.com]
helm install zitadel zitadel/zitadel -n zitadel --values values.yaml --wait
kubectl -n zitadel get pods --watch # zitadel-init and zitadel-setup Jobs complete, then Deployments go Ready
Ingress must speak HTTP/2 to the API
The Console and gRPC clients need end-to-end HTTP/2. With ingress-nginx this means the backend protocol for the zitadel service must be gRPC/h2c (for example the nginx.ingress.kubernetes.io/backend-protocol: "GRPC" annotation). Traefik handles h2c when the service scheme is h2c. With Gateway API, route /ui/v2/login to zitadel-login and / to zitadel, and make sure the controller supports h2c backends.
Lifecycle Phases¶
Zitadel separates one-time initialization, per-version setup, and runtime:
flowchart LR
Init["zitadel init<br/>(once: role, database,<br/>eventstore/projections/system schemas)"]
Setup["zitadel setup<br/>(every new version:<br/>migrations, optional --init-projections)"]
Runtime["zitadel start<br/>(serves traffic, stateless,<br/>scale horizontally)"]
Init --> Setup --> Runtime
Setup -->|"next upgrade"| Setup
start-from-init and start-from-setup combine the phases for simple setups. The Helm chart runs init and setup as Jobs.
Configuration¶
Connect to Managed PostgreSQL (No Superuser)¶
On RDS, Cloud SQL, or Azure Database, provision the role and database yourself, then run only the schema step:
export ZITADEL_DATABASE_POSTGRES_DSN='postgresql://zitadel:REPLACE_ME@db.example.com:5432/zitadel?sslmode=require'
zitadel init schema
zitadel start-from-setup --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
Supported PostgreSQL versions are 14 to 18 (18 needs Zitadel v4.11.0+). Running init again with a new user does not migrate object ownership; rotate credentials by changing the password or reassigning ownership manually.
External URL Settings¶
ExternalDomain, ExternalPort, and ExternalSecure must match what users type in the browser. They are applied during setup. A mismatch is the most common cause of "Instance not found" errors.
| Setting | Env var | Example |
|---|---|---|
ExternalDomain |
ZITADEL_EXTERNALDOMAIN |
auth.example.com |
ExternalPort |
ZITADEL_EXTERNALPORT |
443 |
ExternalSecure |
ZITADEL_EXTERNALSECURE |
true |
More keys are listed in Reference.
TLS Modes¶
--tlsMode |
Use when |
|---|---|
disabled |
Local development only (HTTP everywhere) |
external |
A proxy or ingress terminates TLS and forwards h2c to Zitadel (most common) |
enabled |
Zitadel itself serves TLS (TLS.KeyPath/TLS.CertPath or inline TLS.Key/TLS.Cert) |
NGINX Reverse Proxy Configuration¶
Official example for TLS mode external: grpc_pass sends h2c to the API, and plain proxy_pass sends the login path to the Next.js container.
server {
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/tls/tls.crt;
ssl_certificate_key /etc/nginx/tls/tls.key;
location /ui/v2/login {
proxy_pass http://zitadel-login:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location / {
grpc_pass grpc://zitadel:8080;
grpc_set_header Host $host;
grpc_set_header X-Forwarded-Proto https;
}
}
Scaling¶
Horizontal Scaling¶
The binary is stateless; all shared state is in PostgreSQL. Scale replicas freely once init and setup run separately:
kubectl -n zitadel scale deployment zitadel --replicas=5
kubectl -n zitadel autoscale deployment zitadel --min=3 --max=10 --cpu-percent=70
Plan CPU for password hashing (bcrypt cost 14 by default): the production guide recommends 4 cores available for hashing spikes. Scale-to-zero platforms (Knative, Cloud Run) work if startup stays fast, which is another reason to run setup separately.
Database Scaling¶
| Strategy | Notes |
|---|---|
| Vertical first | Roughly 1 PostgreSQL core per 100 req/s and 4 GB RAM per core (production guide) |
| Connection pool | Tune MaxOpenConns/MaxIdleConns in the DSN config; add PgBouncer if many replicas |
| HA | Patroni, CloudNativePG, or a managed service with failover |
| Multi-region | Independent clusters with PostgreSQL read replicas; steer traffic by path or host |
Enable Caching¶
Caches are off by default. Pick a connector per object (instance, milestones, organization). With Redis (standalone only):
Caches:
Connectors:
Redis:
Enabled: true
URL: redis://redis.zitadel.svc.cluster.local:6379 # rediss:// for TLS
Instance:
Connector: redis
Milestones:
Connector: redis
Organization:
Connector: redis
Without Redis, the docs suggest memory for instance and organization objects (short max age) and postgres (unlogged tables) for milestones. A circuit breaker bypasses Redis if it fails.
Monitoring¶
Health Checks¶
curl -fsS http://zitadel:8080/debug/healthz # liveness
curl -fsS http://zitadel:8080/debug/ready # readiness, checks the database
curl -fsS http://zitadel-login:3000/ui/v2/login/healthy
zitadel ready # CLI check used by the compose healthcheck
Traces, Metrics, and Logs¶
Configure the Instrumentation section (the older Tracing and Metrics sections are deprecated):
Instrumentation:
ServiceName: zitadel
Trace:
Fraction: 0.1
Exporter:
Type: grpc
Endpoint: otel-collector.observability.svc.cluster.local:4317
Insecure: true
Metric:
Exporter:
Type: prometheus # or grpc / http to push OTLP
Log:
Level: INFO
Enable access logs with LogStore.Access.Stdout.Enabled: true. The compose pack has an observability profile with an OTel collector (ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=grpc).
What to Watch¶
| Signal | Why |
|---|---|
| API p99 latency and error rate | User-facing health |
| Login failures and lockouts | Attacks or misconfigured policies |
Rows in projections.failed_events |
Projections that skipped events |
| Projection lag after upgrades | Stale reads until catch-up completes |
| PostgreSQL CPU and connections | Database is the bottleneck in most deployments |
| Redis circuit breaker state | Cache disabled, more database load |
Thresholds depend on your traffic; Zitadel publishes no official alert values.
Upgrades¶
Minor and Patch Upgrades¶
Read the release notes, back up the database, then let setup migrate before new pods take traffic:
helm repo update zitadel
helm upgrade zitadel zitadel/zitadel -n zitadel --values values.yaml --dry-run
helm upgrade zitadel zitadel/zitadel -n zitadel --values values.yaml --wait
With compose, bump ZITADEL_VERSION in .env, then docker compose pull and docker compose up -d --wait. Add --init-projections=true to setup on large installations to avoid a long catch-up after start.
Upgrade from v3 to v4¶
- Upgrade to the latest v3.4.x (at least v3.4.1, which enables web keys by default).
- Wait until tokens signed with legacy keys expire (technical advisory A-10017), or accept a re-login window.
- Upgrade to v4 (
zitadel setup, thenzitadel start). Chart v8 to v9 corresponds to Zitadel v4. - Existing instances stay on Login V1. Newer charts deploy a Login V2 workload by default; set
login.enabled: falseif you are not adopting it yet.
Migrate CockroachDB to PostgreSQL¶
v3 and later refuse to start on CockroachDB. Use the mirror command (see the mirror guide for which binary version to run it with):
zitadel init --config new-postgres.yaml
zitadel setup --for-mirror --config new-postgres.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
zitadel mirror --system --config mirror.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
zitadel setup --for-mirror --config new-postgres.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
zitadel mirror verify --system --config mirror.yaml --masterkey "$ZITADEL_MASTERKEY" --tlsMode external
Version Upgrade Compatibility¶
| Path | Notes |
|---|---|
| v4.x to later v4.x | Setup then rolling start; zero downtime supported. Skip v4.18.0 |
| v3.x to v4.x | Via v3.4.1+ and web keys (A-10017); Login V2 optional |
| v2.x on CockroachDB to v3+ | Mirror to PostgreSQL first |
| v4.x to next major | Not released; roadmap promises migration tooling, production rollout 2027 |
Backup and Recovery¶
Database Backup¶
The database is the only state (plus the masterkey and your config). Back it up before every setup run:
pg_dump -h postgres-host -U zitadel -d zitadel -Fc -f zitadel-$(date +%F).dump
pg_restore -h postgres-host -U zitadel -d zitadel --clean zitadel-2026-09-25.dump
Event Store Recovery¶
After a restore, start Zitadel with the same masterkey and version (or run setup for a newer one). Projections resume from their recorded positions and replay missing events; a full replay on a large event log can take a long time.
Master Key Handling¶
The masterkey cannot be rotated in place
Once initialized, changing the masterkey means losing access to every encrypted secret (IdP client secrets, SMTP credentials, signing keys). Store it in a secret manager with the same care as the database backups; a backup without its masterkey is unusable.
API Calls¶
The examples use a PAT from a service account with the needed administrator role (for example ORG_OWNER).
OIDC Discovery¶
Create a Human User (v2 REST)¶
curl -X POST https://auth.example.com/v2/users/human \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{
"username": "alice@example.com",
"profile": { "givenName": "Alice", "familyName": "Smith" },
"email": { "email": "alice@example.com", "sendCode": {} }
}'
Create a Service Account (Machine User)¶
CreateUser handles both user types; the /new path avoids a clash with ListUsers:
curl -X POST https://auth.example.com/v2/users/new \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{
"organizationId": "'"${ORG_ID}"'",
"username": "billing-api-sa",
"machine": {
"name": "Billing API service account",
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT"
}
}'
Generate a Personal Access Token¶
curl -X POST https://auth.example.com/v2/users/${USER_ID}/pats \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{ "expirationDate": "2027-01-01T00:00:00Z" }'
Create a Project (Connect RPC)¶
ProjectService has no REST mapping; call the Connect endpoint with JSON:
curl -X POST https://auth.example.com/zitadel.project.v2.ProjectService/CreateProject \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{
"organizationId": "'"${ORG_ID}"'",
"name": "billing-api",
"projectRoleAssertion": true
}'
Create an OIDC Application (Connect RPC)¶
curl -X POST https://auth.example.com/zitadel.application.v2.ApplicationService/CreateApplication \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d '{
"projectId": "'"${PROJECT_ID}"'",
"name": "billing-web",
"oidcConfiguration": {
"redirectUris": ["https://billing.example.com/auth/callback"],
"postLogoutRedirectUris": ["https://billing.example.com/"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"applicationType": "OIDC_APP_TYPE_USER_AGENT",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE"
}
}'
OIDC_AUTH_METHOD_TYPE_NONE makes a public client that must use PKCE; use BASIC or PRIVATE_KEY_JWT for confidential web backends.
Token Introspection¶
Authenticate the API application with Basic auth (or a client_assertion JWT):
curl -X POST https://auth.example.com/oauth/v2/introspect \
-u "${API_CLIENT_ID}:${API_CLIENT_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=${ACCESS_TOKEN}"
Actions V2 Recipes¶
Add Claims with a Function Execution¶
Create a target, then bind it to the preaccesstoken (or preuserinfo) function:
curl -X POST https://auth.example.com/v2/actions/targets \
-H "Authorization: Bearer ${PAT}" -H "Content-Type: application/json" \
-d '{
"name": "claims-enricher",
"restCall": { "interruptOnError": true },
"endpoint": "https://hooks.example.com/zitadel/claims",
"timeout": "10s"
}'
curl -X PUT https://auth.example.com/v2/actions/executions \
-H "Authorization: Bearer ${PAT}" -H "Content-Type: application/json" \
-d '{
"condition": { "function": { "name": "preaccesstoken" } },
"targets": ["'"${TARGET_ID}"'"]
}'
Your endpoint receives the function payload and returns the claims to append. Verify the ZITADEL-Signature header with the signing key returned when the target was created.
Migrate from Actions V1¶
Rewrite each V1 JavaScript flow as an HTTP endpoint and an execution: "complement token" flows become preaccesstoken/preuserinfo function executions, "pre creation" flows become request executions on the user creation method, and "post" flows become response or event executions. V1 and V2 run side by side, so migrate one flow at a time, then delete the V1 action. V1 APIs are scheduled for removal in the next major version.
SDK Integration Patterns¶
Go: Service Account with JWT Profile¶
zitadel-go/v3 (from the SDK README):
package main
import (
"context"
"log"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel-go/v3/pkg/client"
"github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/management"
"github.com/zitadel/zitadel-go/v3/pkg/zitadel"
)
func main() {
ctx := context.Background()
authOption := client.DefaultServiceUserAuthentication(
"path/to/jwt-key.json",
oidc.ScopeOpenID,
client.ScopeZitadelAPI(),
)
api, err := client.New(ctx, zitadel.New("auth.example.com"), client.WithAuth(authOption))
if err != nil {
log.Fatal(err)
}
resp, err := api.ManagementService().GetMyOrg(ctx, &management.GetMyOrgRequest{})
if err != nil {
log.Fatal(err)
}
log.Printf("org: %s", resp.GetOrg().GetName())
}
client.PasswordAuthentication(clientID, clientSecret, ...) switches to client credentials. For protecting your own Go web app, use the SDK's authentication helpers (built on zitadel/oidc).
Python: Service Account¶
zitadel-client (PyPI, 4.1.9, marked incubating; for service accounts only, not end-user login):
import zitadel_client as zitadel
from zitadel_client.models import (
UserServiceAddHumanUserRequest,
UserServiceSetHumanEmail,
UserServiceSetHumanProfile,
)
client = zitadel.Zitadel.with_private_key("https://auth.example.com", "path/to/jwt-key.json")
request = UserServiceAddHumanUserRequest(
username="john.doe",
profile=UserServiceSetHumanProfile(givenName="John", familyName="Doe"),
email=UserServiceSetHumanEmail(email="john@example.com"),
)
print(client.users.add_human_user(request))
React: OIDC Login¶
@zitadel/react wraps oidc-client-ts and defaults to code flow with PKCE:
import { createZitadelAuth } from "@zitadel/react";
const zitadel = createZitadelAuth({
authority: "https://auth.example.com",
client_id: "BILLING_WEB_CLIENT_ID",
project_resource_id: "BILLING_PROJECT_ID", // adds the project role scopes
});
export function LoginButton() {
return <button onClick={() => zitadel.authorize()}>Login</button>;
}
zitadel.signout() logs out, and zitadel.userManager exposes the underlying oidc-client-ts UserManager (for example getUser() to read claims). The 1.1.1 type definitions export only authorize, signout, and userManager, although the README also mentions a role helper. For other frameworks use any certified OIDC client library.
Terraform¶
Provider Configuration¶
terraform {
required_providers {
zitadel = {
source = "zitadel/zitadel"
version = "~> 3.8"
}
}
}
provider "zitadel" {
domain = "auth.example.com"
port = "443"
insecure = false
jwt_profile_file = "zitadel-admin-sa.json" # or access_token = var.zitadel_pat
}
Create Organization, Project, Roles, and Application¶
resource "zitadel_org" "billing" {
name = "Billing Team"
}
resource "zitadel_project" "billing_api" {
name = "billing-api"
org_id = zitadel_org.billing.id
project_role_assertion = true
project_role_check = true
}
resource "zitadel_project_role" "admin" {
project_id = zitadel_project.billing_api.id
org_id = zitadel_org.billing.id
role_key = "admin"
display_name = "Administrator"
group = "billing"
}
resource "zitadel_project_role" "viewer" {
project_id = zitadel_project.billing_api.id
org_id = zitadel_org.billing.id
role_key = "viewer"
display_name = "Viewer"
group = "billing"
}
resource "zitadel_application_oidc" "web" {
project_id = zitadel_project.billing_api.id
org_id = zitadel_org.billing.id
name = "billing-web"
redirect_uris = ["https://billing.example.com/auth/callback"]
post_logout_redirect_uris = ["https://billing.example.com/"]
response_types = ["OIDC_RESPONSE_TYPE_CODE"]
grant_types = ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"]
app_type = "OIDC_APP_TYPE_USER_AGENT"
auth_method_type = "OIDC_AUTH_METHOD_TYPE_NONE"
}
Kubernetes Operations¶
kubectl -n zitadel port-forward svc/zitadel 8080:8080 # API and Console
kubectl -n zitadel logs job/zitadel-setup # migration output
kubectl -n zitadel exec deploy/zitadel -- /app/zitadel ready # readiness from inside the pod
helm -n zitadel status zitadel
helm -n zitadel uninstall zitadel # the database and secrets are not removed
Common Issues¶
| Issue | Cause | Resolution |
|---|---|---|
| "Instance not found" | ExternalDomain/ExternalPort/ExternalSecure do not match the URL, or were changed without rerunning setup |
Fix the values and run zitadel setup again |
| Console loads but API calls fail | Proxy does not forward HTTP/2 (h2c) | Use grpc_pass (NGINX), h2c scheme (Traefik), or GRPC backend protocol on ingress-nginx |
| Login V2 shows errors or 401 | Login container lacks a valid IAM_LOGIN_CLIENT PAT, or wrong ZITADEL_API_URL/Host header |
Check the PAT file path and CUSTOM_REQUEST_HEADERS Host value |
relation "eventstore.events" does not exist |
Database provisioned manually without schema bootstrap | Run zitadel init schema |
| Initial setup fails with password complexity error | ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD too weak |
Use 8+ chars with upper, lower, digit, symbol |
| JWTs rejected right after a v4 upgrade | Web keys were not active on v3 (A-10017) | Users re-login; next time upgrade via v3.4.1+ and soak |
| Setup job fails on v4.18.0 | Known broken setup step | Use v4.19.0 or later |
| Slow reads after an upgrade | Projections catching up | Use --init-projections=true in setup; check failed_events |
| MFA prompt loops | Organization login policy overrides the instance policy | Check which policy applies to the user's organization |