references/deployment.md
Deployment
A verified recipe for ZITADEL behind a TLS-terminating reverse proxy, written against v4.16.0 on Dokploy/Traefik and reproduced from scratch. The Traefik specifics generalise to nginx, Caddy and Kubernetes ingress without change to the ZITADEL side.
The external settings
Straight from cmd/defaults.yaml in the upstream repository:
| Setting | Default | Purpose |
|---|---|---|
ExternalDomain | localhost | "the domain on which end users access ZITADEL" |
ExternalPort | 8080 | "the port on which end users access ZITADEL. It can differ from Port e.g. if a reverse proxy forwards the traffic" |
ExternalSecure | true | "specifies if ZITADEL is exposed externally using HTTPS or HTTP" |
These describe the outside, not the binding. What ZITADEL listens on is the separate Port setting, which stays at 8080 inside the container. The External* trio is how ZITADEL understands its own public identity, and behind a proxy it cannot discover that, so you have to tell it.
Everything below is derived from them:
- The OIDC issuer, composed as
{scheme}://{ExternalDomain}[:{ExternalPort}],
published in discovery and stamped into every token's iss claim. Clients validate iss strictly.
- Every endpoint in the discovery document: authorize, token, userinfo, jwks,
end_session.
- The console's API base URL in
environment.json. - The
Secureattribute on session cookies. - The instance domain, and from it the organization domain, and from that
every login name.
ZITADEL omits the port from generated URLs when it is the default for the scheme, so 443 with ExternalSecure=true produces clean https://auth.example.com/... with no port suffix.
Environment variable mapping is mechanical: the config path uppercased with underscore separators behind a ZITADEL_ prefix. TLS.Enabled becomes ZITADEL_TLS_ENABLED, Database.Postgres.Host becomes ZITADEL_DATABASE_POSTGRES_HOST. Anything in defaults.yaml is settable this way.
ExternalSecure is not TLS.Enabled
They are independent, and confusing them is the root of most reverse-proxy pain.
TLS.Enabledasks: does ZITADEL itself terminate TLS? If yes you must
supply a certificate and private key.
ExternalSecureasks: do end users reach it over HTTPS, regardless of who
terminates?
Behind a proxy you want both: TLS.Enabled=false because the proxy holds the certificate, and ExternalSecure=true because users are on HTTPS. That combination is what the docs call TLS mode external.
Set it with the flag, not the variable. --tlsMode sets both and overrides ZITADEL_EXTERNALSECURE. Having both in play is how the confusion starts, so pick the flag and delete ZITADEL_TLS_ENABLED from your compose entirely.
The domain is identity
ExternalDomain is fixed during the setup phase. From it ZITADEL derives the instance domain, the default organization's domain, and every login name in the form user@zitadel.<domain>.
Changing it later requires rerunning the setup phase, and existing login names keep the old domain. ZITADEL also routes virtual instances by Host header, so the domain is not cosmetic, it is how requests find an instance at all.
This makes platform-generated hostnames actively dangerous. Dokploy mints a new one per app deploy (<app>-<random>-<ip>.sslip.io), which is fundamentally incompatible with a system that treats its domain as permanent. Every redeploy orphans the instance's users, organizations, projects and application registrations.
Point a real A record at the host before first boot. This is not a polish step. It is the difference between a demo and something you can build against.
Compose
services:
zitadel:
restart: always
image: ghcr.io/zitadel/zitadel:v4.16.0
# --tlsMode external: the proxy terminates TLS, ZITADEL advertises https.
# This flag OVERRIDES ZITADEL_EXTERNALSECURE. Do not also set ZITADEL_TLS_ENABLED.
command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode external'
environment:
ZITADEL_DATABASE_POSTGRES_HOST: db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: "${POSTGRES_PASSWORD}"
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: "${POSTGRES_PASSWORD}"
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# PERMANENT after first boot. See "the domain is identity" above.
ZITADEL_EXTERNALDOMAIN: "${ZITADEL_EXTERNALDOMAIN}"
ZITADEL_EXTERNALPORT: "443"
ZITADEL_EXTERNALSECURE: "true"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: "${ZITADEL_ADMIN_USERNAME}"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: "${ZITADEL_ADMIN_EMAIL}"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_FIRSTNAME: "${ZITADEL_ADMIN_FIRSTNAME}"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_LASTNAME: "${ZITADEL_ADMIN_LASTNAME}"
# Built-in login UI, no separate login-v2 service required.
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
healthcheck:
test: ["CMD", "/app/zitadel", "ready"]
interval: 10s
timeout: 30s
retries: 12
start_period: 30s
depends_on:
db:
condition: service_healthy
expose:
- 8080
db:
restart: always
image: postgres:17-alpine
environment:
PGUSER: postgres
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: zitadel
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
# Must be ONE shell string. With CMD-SHELL only the first array element is
# the command; later elements become discarded positional args to `sh -c`.
test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"]
interval: 10s
timeout: 30s
retries: 5
start_period: 20s
volumes:
postgres_data:
Three details that bite:
- Quote your booleans. Bare
falseis a YAML boolean where Compose expects a
string.
expose: 8080is the internal listen port, unrelated toExternalPort
which describes what users reach.
- The Postgres healthcheck array is the classic
CMD-SHELLmistake. Written
as separate elements it silently runs bare pg_isready, which passes because PGUSER is set, so it looks correct and checks nothing you specified.
Environment
POSTGRES_PASSWORD=<generated>
ZITADEL_MASTERKEY=<generated, exactly 32 characters>
# The only value that changes per deployment. A real domain, DNS already
# resolving, never a platform-generated hostname.
ZITADEL_EXTERNALDOMAIN=auth.client.com
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=<generated, changed on first login>
ZITADEL_ADMIN_EMAIL=ops@example.com
ZITADEL_ADMIN_FIRSTNAME=Admin
ZITADEL_ADMIN_LASTNAME=User
The masterkey encrypts everything at rest and has no clean rotation path. Treat a leaked masterkey as requiring a fresh deployment, and see operations.md for why it belongs in your backup procedure.
The FIRSTINSTANCE_* block only applies on first boot. The bootstrap admin should be break-glass: generate the password, change it at first login, store it in a password manager, and do everything routine through a machine user instead.
Reverse proxy
The proxy must either leave the Host header unchanged or set the original value in Forwarded / X-Forwarded-Host. ZITADEL resolves virtual instances from it. It must also forward X-Forwarded-Proto, which most proxies do by default.
Traefik labels, declared manually so nothing overwrites the backend scheme:
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
- traefik.http.routers.zitadel.rule=Host(`auth.client.com`)
- traefik.http.routers.zitadel.entrypoints=websecure
- traefik.http.routers.zitadel.tls.certresolver=letsencrypt
- traefik.http.services.zitadel.loadbalancer.server.port=8080
- traefik.http.services.zitadel.loadbalancer.server.scheme=h2c
On scheme=h2c: it is not required by the console, because gRPC-Web works over HTTP/1.1. It is required by the Terraform provider and the Go and Node SDKs, which use native gRPC over HTTP/2. Add it before any IaC provisioning rather than debugging it later. ZITADEL's own docs warn that it uses HTTP/2 for all connections and that h2c compatibility must be verified when a proxy terminates TLS.
On Dokploy specifically: configuring the domain in its UI auto-injects router and service labels, which work for the console but default the backend scheme to plain HTTP. To set h2c you must remove the domain from the UI and declare the labels in the compose file, as above, so the generated ones do not conflict.
Configuration changes need setup to rerun
Changing ExternalDomain, ExternalPort or ExternalSecure requires the setup phase to run again. Editing the environment and restarting the container is not enough, and a surviving Postgres volume keeps every old value.
docker compose -p <project> down -v # the -v is the point
Two signals that tell you what actually happened:
clientidinenvironment.jsonunchanged after a "fresh" deploy means the
database survived.
apistillhttpafter settingExternalSecure=truemeans the flag is
still disabled, not that the change failed to apply.
First-boot checklist
- DNS A record resolving to the host before anything starts
-
--tlsMode external,EXTERNALPORT=443,EXTERNALSECURE=true -
ZITADEL_TLS_ENABLEDabsent from the compose file - Booleans quoted, Postgres healthcheck a single shell string
- Traefik labels include
scheme=h2c - Generated masterkey stored in a secrets manager, labelled by deployment
- Healthcheck green, then
environment.jsonshowshttpsfor both fields - Hard-reload the console; browsers cache
environment.json - Bootstrap admin password changed
- SMTP configured
- Telemetry decision made (the
ServicePingsection, a daily cron that phones
home; expect procurement to ask)