Compose
A hand-adapted version of Zitadel's own official compose, with its bundled Traefik proxy stripped out and its routing labels re-attached to Coolify's Traefik.
docker-compose.yaml
services:
zitadel-api:
image: 'ghcr.io/zitadel/zitadel:v4.17.3'
restart: unless-stopped
user: "0"
command: start-from-init --masterkeyFromEnv
environment:
- SERVICE_URL_ZITADELAPI_8080
- 'ZITADEL_MASTERKEY=${SERVICE_BASE64_32_ZITADELAPI}'
- 'ZITADEL_PORT=8080'
- 'ZITADEL_EXTERNALDOMAIN=${SERVICE_FQDN_ZITADELAPI}'
- 'ZITADEL_EXTERNALPORT=${ZITADEL_EXTERNALPORT:-443}'
- 'ZITADEL_EXTERNALSECURE=${ZITADEL_EXTERNALSECURE:-true}'
- 'ZITADEL_TLS_ENABLED=false'
- 'ZITADEL_DATABASE_POSTGRES_DSN=postgresql://${SERVICE_USER_ZITADELPOSTGRESQL}:${SERVICE_PASSWORD_ZITADELPOSTGRESQL}@zitadel-postgresql:5432/${POSTGRES_DB:-zitadel}?sslmode=disable'
- 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME=zitadel-admin'
- 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS=admin@${SERVICE_FQDN_ZITADELAPI}'
- 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_VERIFIED=true'
- 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=${SERVICE_PASSWORD_64_ZITADELADMIN}!1Aa'
- 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED=false'
- 'ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH=/zitadel/bootstrap/login-client.pat'
- 'ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME=login-client'
- 'ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME=Automatically Initialized IAM_LOGIN_CLIENT'
- 'ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE=${LOGIN_CLIENT_PAT_EXPIRATION:-2030-01-01T00:00:00Z}'
- 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=true'
- 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI=https://${SERVICE_FQDN_ZITADELAPI}/ui/v2/login/'
- 'ZITADEL_OIDC_DEFAULTLOGINURLV2=https://${SERVICE_FQDN_ZITADELAPI}/ui/v2/login/login?authRequest='
- 'ZITADEL_OIDC_DEFAULTLOGOUTURLV2=https://${SERVICE_FQDN_ZITADELAPI}/ui/v2/login/logout?post_logout_redirect='
healthcheck:
test: ['CMD', '/app/zitadel', 'ready']
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
volumes:
- 'zitadel-bootstrap:/zitadel/bootstrap:rw'
depends_on:
zitadel-postgresql:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.services.zitadel-api.loadbalancer.server.port=8080
- traefik.http.services.zitadel-api.loadbalancer.server.scheme=h2c
- traefik.http.middlewares.zitadel-strip-api.stripprefix.prefixes=/api
- 'traefik.http.routers.zitadel-api-websecure.rule=Host(`id.rjkorteschiel.nl`) && PathPrefix(`/api`)'
- traefik.http.routers.zitadel-api-websecure.entrypoints=https
- traefik.http.routers.zitadel-api-websecure.tls=true
- traefik.http.routers.zitadel-api-websecure.middlewares=zitadel-strip-api
- traefik.http.routers.zitadel-api-websecure.service=zitadel-api
- traefik.http.routers.zitadel-api-websecure.priority=10200
- 'traefik.http.routers.zitadel-canonical-websecure.rule=Host(`id.rjkorteschiel.nl`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)'
- traefik.http.routers.zitadel-canonical-websecure.entrypoints=https
- traefik.http.routers.zitadel-canonical-websecure.tls=true
- traefik.http.routers.zitadel-canonical-websecure.service=zitadel-api
- traefik.http.routers.zitadel-canonical-websecure.priority=10100
zitadel-login:
image: 'ghcr.io/zitadel/zitadel-login:v4.17.3'
restart: unless-stopped
user: "0"
environment:
- 'ZITADEL_API_URL=http://zitadel-api:8080'
- 'NEXT_PUBLIC_BASE_PATH=/ui/v2/login'
- 'ZITADEL_SERVICE_USER_TOKEN_FILE=/zitadel/bootstrap/login-client.pat'
- 'CUSTOM_REQUEST_HEADERS=Host:${SERVICE_FQDN_ZITADELAPI},X-Forwarded-Proto:https'
healthcheck:
test: ['CMD', '/bin/sh', '-c', 'node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy']
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
volumes:
- 'zitadel-bootstrap:/zitadel/bootstrap:ro'
depends_on:
zitadel-api:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.services.zitadel-login.loadbalancer.server.port=3000
- traefik.http.middlewares.zitadel-root-rewrite.replacepath.path=/ui/v2/login/
- 'traefik.http.routers.zitadel-root-websecure.rule=Host(`id.rjkorteschiel.nl`) && Path(`/`)'
- traefik.http.routers.zitadel-root-websecure.entrypoints=https
- traefik.http.routers.zitadel-root-websecure.tls=true
- traefik.http.routers.zitadel-root-websecure.middlewares=zitadel-root-rewrite
- traefik.http.routers.zitadel-root-websecure.service=zitadel-login
- traefik.http.routers.zitadel-root-websecure.priority=10400
- 'traefik.http.routers.zitadel-login-websecure.rule=Host(`id.rjkorteschiel.nl`) && PathPrefix(`/ui/v2/login`)'
- traefik.http.routers.zitadel-login-websecure.entrypoints=https
- traefik.http.routers.zitadel-login-websecure.tls=true
- traefik.http.routers.zitadel-login-websecure.service=zitadel-login
- traefik.http.routers.zitadel-login-websecure.priority=10250
zitadel-postgresql:
image: 'postgres:16-alpine'
restart: unless-stopped
environment:
- 'POSTGRES_USER=${SERVICE_USER_ZITADELPOSTGRESQL}'
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_ZITADELPOSTGRESQL}'
- 'POSTGRES_DB=${POSTGRES_DB:-zitadel}'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d ${POSTGRES_DB:-zitadel} -U $${POSTGRES_USER}']
interval: 10s
timeout: 30s
retries: 10
start_period: 20s
volumes:
- 'zitadel-postgresql-data:/var/lib/postgresql/data'
See Setup to deploy it. Notes below on a few Coolify/Zitadel quirks this compose works around, each confirmed against source or live evidence rather than guessed.
Environment variables
- The masterkey uses
SERVICE_BASE64_32_ZITADELAPI— Coolify'sBASE64family supports arbitrary lengths, including the exact 32 characters Zitadel expects (Coolify docs). - It's passed via
command: start-from-init --masterkeyFromEnv, the explicit flag Zitadel needs before it'll readZITADEL_MASTERKEYfrom the environment at all (cmd/key/masterkey.go). - The Domain-tab entry is left path-less, which is what keeps
SERVICE_FQDN_ZITADELAPIa clean domain — confirmed via Coolify's Environment Variables UI that a Path set on the entry folds straight into the variable's value.
Routing
With the Domain-tab entry path-less, /api vs /ui/v2/login vs / are split with hand-written Traefik labels instead — the same approach Zitadel's own bundled compose uses with its own Traefik. Two things to know about how those labels are written:
- The domain in each label rule is hardcoded (
id.rjkorteschiel.nl) — Coolify resolves its magic variables insideenvironment:entries, confirmed by reading the container's live Docker labels, and tracked as a known Coolify issue forlabels:(coollabsio/coolify#5686). - The entrypoints are
entrypoints=https, matching how this Coolify Traefik instance actually names them (http/https).
Admin bootstrap
The admin account is created explicitly via ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME/_EMAIL_ADDRESS/_PASSWORD — FirstInstance has its own separate defaults from DefaultInstance (cmd/setup/03.go), so zitadel-admin only exists here because we set it.
The password is ${SERVICE_PASSWORD_64_ZITADELADMIN}!1Aa — a Coolify-generated alphanumeric value with a fixed suffix appended, to satisfy Zitadel's password complexity policy (which requires a symbol). Not SERVICE_PASSWORDWITHSYMBOLS_64_<ID>: a symbol-including generated value can contain a literal $ followed by letters, which Coolify's own substitution pipeline misreads as a variable reference and blanks out — a known, long-standing Coolify bug class (coollabsio/coolify#3946, #1918), confirmed here directly: the generated value for this identifier was s}mfhu<|?*gsOeD7Ti.I{AE77y.7D$AEAd<^FH19XG97s$LHbOqS&D:[ER8GcVUJ, and Coolify's deploy log warned The "AEAd" variable is not set / The "LHbOqS" variable is not set — the exact substrings after each $.
The full loginname is zitadel-admin@zitadel.id.rjkorteschiel.nl — Zitadel always suffixes <org-name>.<external-domain> onto the configured username (internal/domain/org_domain.go).