>_devkit
zitadel-production
skills/zitadel-production/

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:

SettingDefaultPurpose
ExternalDomainlocalhost"the domain on which end users access ZITADEL"
ExternalPort8080"the port on which end users access ZITADEL. It can differ from Port e.g. if a reverse proxy forwards the traffic"
ExternalSecuretrue"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 Secure attribute 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.Enabled asks: does ZITADEL itself terminate TLS? If yes you must

supply a certificate and private key.

  • ExternalSecure asks: 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 false is a YAML boolean where Compose expects a

string.

  • expose: 8080 is the internal listen port, unrelated to ExternalPort

which describes what users reach.

  • The Postgres healthcheck array is the classic CMD-SHELL mistake. 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:

  • clientid in environment.json unchanged after a "fresh" deploy means the

database survived.

  • api still http after setting ExternalSecure=true means 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_ENABLED absent 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.json shows https for both fields
  • Hard-reload the console; browsers cache environment.json
  • Bootstrap admin password changed
  • SMTP configured
  • Telemetry decision made (the ServicePing section, a daily cron that phones

home; expect procurement to ask)