ZITADEL Docker Compose 部署指南
ZITADEL v4.19.5 Docker Compose 部署实测:官方编排(Traefik+API+Login+PostgreSQL)逐条落地,端口预分配与 ss/docker ps 双重复核(8113)、自定义网段 10.89.38.0/24、镜像拉取、start-from-init 首次初始化、healthz/OIDC 发现/Console/Session API 登录态四层验证、等价 docker run 直跑、pg_dump 备份与 8 条排障 FAQ,回显均来自真实部署。
ZITADEL Docker Compose 部署指南
目标:在一台 Linux 服务器上以官方 Docker Compose 方式完整部署 ZITADEL v4.19.5(Traefik 反向代理 + ZITADEL API + ZITADEL Login + PostgreSQL),覆盖端口预分配与复核、自定义网段、镜像获取、首次初始化、部署验证、等价单容器方式与常见排障。本文全部命令与回显均来自 2026-10-18 的真实部署实测;组件背景与选型对比见《ZITADEL v4.19.5 介绍与选型指南》,逐项参数调优见《ZITADEL v4.19.5 配置参数详解》,接口调用见《ZITADEL v4.19.5 API 使用详解》。
一、环境信息
| 项目 | 值 |
|---|---|
| 服务器(容器模拟真实服务器) | IP 192.168.146.128,主机名 ops,网卡 ens33(出口路由验证:ip route get 192.168.1.62 → via 192.168.146.2 dev ens33 src 192.168.146.128) |
| 操作系统 | Ubuntu 22.04.4 LTS(内核 6.8.0-138-generic,x86_64) |
| Docker 版本 | 29.8.1 |
| Docker Compose 版本 | v5.5.1 |
| 内存 / 磁盘 | 31 GiB 内存(实测部署期可用约 8.6 GiB)/ 根分区 786G(已用 34%) |
| ZITADEL 版本 | v4.19.5(2026-10-18 查询 GitHub Releases 确认的最新稳定版) |
| 官方镜像 | ghcr.io/zitadel/zitadel:v4.19.5(235MB)、ghcr.io/zitadel/zitadel-login:v4.19.5(331MB) |
| 配套镜像 | traefik:v3.7.7(250MB)、postgres:17.10-alpine(424MB);可选 redis:7.4.9-alpine、otel/opentelemetry-collector-contrib:0.156.0 |
| 对外端口 | 8113(宿主)→ Traefik 80;容器内部 API 8080、Login 3000、PostgreSQL 5432 |
| 自定义网络 | zitadel,显式子网 10.89.38.0/24(规划槽位 10.89.37.0/24 已被 docmost 栈占用,按预案顺延 38,见第二章) |
| 代理 | http://192.168.1.62:7897(境外镜像拉取与资料查询) |
| 初始管理员 | 用户名 admin / 密码 q1w2e3r4(登录名 admin@zitadel.localhost) |
| Masterkey | 32 字符测试值 q1w2e3r4q1w2e3r4q1w2e3r4q1w2e3r4(生产必须更换并妥善保管) |
端口预分配说明:本组件规划宿主端口 8108。启动前用 ss -ltn 复核发现 8080 被宿主进程监听、8108 虽未见于 ss 但被既有容器 docmost 的端口映射占用(compose 绑定时报 address already in use),按预案顺延使用 8113,本文全部实测均基于 8113。
二、部署规划与端口复核
2.1 端口复核实录
分配端口前先复核宿主监听状态:
ss -ltn | grep -E ':(8108|8113|8080)\b' || echo "PORTS_FREE"
实测回显:
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*
结论:8080 已被宿主进程占用(排除);8108、8113 在 ss 视角空闲。注意 ss 只能看到宿主侧监听,不能反映其他容器栈对端口映射的占用——后续第一次 compose up 时 8108 绑定失败(第七章 FAQ-1 有完整报错),最终顺延为 8113。这也是端口规划必须「先 ss 复核 + 再以实际绑定结果为准」双确认的原因。
2.2 网段槽位复核
本环境为每个组件栈分配显式子网(禁用 192.168.0.0/16 与 100.64.0.0/16)。规划槽位 10.89.37.0/24,创建前检查既有网络占用:
docker network inspect $(docker network ls -q) --format '{{.Name}}: {{range .IPAM.Config}}{{.Subnet}} {{end}}' 2>/dev/null | sort | grep '10\.89\.3'
实测回显:
docmost_default: 10.89.37.0/24
槽位 37 已被 docmost 栈占用,按预案顺延 10.89.38.0/24。创建网络后验证宿主路由不受影响:
ip route get 192.168.1.62
实测回显(关键:dev ens33,确认容器网络未劫持宿主出口路由):
192.168.1.62 via 192.168.146.2 dev ens33 src 192.168.146.128 uid 0
cache
2.3 目录与文件规划
mkdir -p /opt/data/quzuo/zitadel-compose && cd /opt/data/quzuo/zitadel-compose
最终目录包含三个文件:docker-compose.yml(官方原样)、docker-compose.local.yml(本地 overlay,固定子网/容器名并注入初始管理员)、.env(环境变量)。compose 最终形态为「官方基线 + 本地 overlay」叠加生效,具体见第四章。
三、获取官方编排文件
官方部署文档(https://zitadel.com/docs/self-hosting/deploy/compose )推荐的基础 compose 文件与示例环境变量托管在 GitHub 仓库 deploy/compose/ 目录。下载(境外资源走代理):
curl -x http://192.168.1.62:7897 -fsSLO \
https://raw.githubusercontent.com/zitadel/zitadel/v4.19.5/deploy/compose/docker-compose.yml
curl -x http://192.168.1.62:7897 -fsSLO \
https://raw.githubusercontent.com/zitadel/zitadel/v4.19.5/deploy/compose/.env.example
两条命令均正常返回(-f 失败即报错退出),无额外输出。官方 docker-compose.yml(v4.19.5)全文如下,是本文部署的核心编排,五个服务为 proxy(Traefik)、zitadel-api、zitadel-login、postgres,以及两个按 profile 启用的可选服务 redis(cache)、otel-collector(observability):
name: zitadel
services:
proxy:
image: ${TRAEFIK_IMAGE}
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports:
- ${PROXY_HTTP_PUBLISHED_PORT}:80
networks:
- zitadel
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
zitadel-api:
condition: service_healthy
zitadel-login:
condition: service_healthy
zitadel-api:
image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION}
restart: unless-stopped
user: "0"
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"
environment:
ZITADEL_PORT: 8080
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNALPORT}
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE}
ZITADEL_TLS_ENABLED: false
ZITADEL_DATABASE_POSTGRES_DSN: ${ZITADEL_DATABASE_POSTGRES_DSN}
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}
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: ${ZITADEL_PUBLIC_SCHEME}://${ZITADEL_DOMAIN}:${ZITADEL_EXTERNALPORT}/ui/v2/login/login?samlRequest=
ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED: ${ZITADEL_ACCESS_LOG_STDOUT_ENABLED}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT}
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE: ${ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE}
ZITADEL_INSTRUMENTATION_SERVICENAME: ${ZITADEL_INSTRUMENTATION_SERVICENAME}
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED: ${ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED}
ZITADEL_CACHES_CONNECTORS_REDIS_URL: ${ZITADEL_CACHES_CONNECTORS_REDIS_URL}
ZITADEL_CACHES_INSTANCE_CONNECTOR: ${ZITADEL_CACHES_INSTANCE_CONNECTOR}
ZITADEL_CACHES_MILESTONES_CONNECTOR: ${ZITADEL_CACHES_MILESTONES_CONNECTOR}
ZITADEL_CACHES_ORGANIZATION_CONNECTOR: ${ZITADEL_CACHES_ORGANIZATION_CONNECTOR}
healthcheck:
test:
- CMD
- /app/zitadel
- ready
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
volumes:
- zitadel-bootstrap:/zitadel/bootstrap:rw
networks:
- zitadel
depends_on:
postgres:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.docker.network=zitadel
- 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.middlewares.zitadel-strip-api.stripprefix.forceSlash=false
# Note: no dedicated gRPC router needed. All gRPC and Connect-RPC traffic
# is handled by the catch-all router below since the backend already uses h2c.
- traefik.http.routers.zitadel-api-alias-web.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.zitadel-api-alias-web.entrypoints=web
- traefik.http.routers.zitadel-api-alias-web.middlewares=zitadel-strip-api
- traefik.http.routers.zitadel-api-alias-web.service=zitadel-api
- traefik.http.routers.zitadel-api-alias-web.priority=200
- traefik.http.routers.zitadel-api-alias-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.zitadel-api-alias-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-api-alias-websecure.tls=true
- traefik.http.routers.zitadel-api-alias-websecure.middlewares=zitadel-strip-api
- traefik.http.routers.zitadel-api-alias-websecure.service=zitadel-api
- traefik.http.routers.zitadel-api-alias-websecure.priority=200
- traefik.http.routers.zitadel-canonical-web.rule=Host(`${ZITADEL_DOMAIN}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)
- traefik.http.routers.zitadel-canonical-web.entrypoints=web
- traefik.http.routers.zitadel-canonical-web.service=zitadel-api
- traefik.http.routers.zitadel-canonical-web.priority=100
- traefik.http.routers.zitadel-canonical-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && !PathPrefix(`/ui/v2/login`) && !PathPrefix(`/api`) && !Path(`/`)
- traefik.http.routers.zitadel-canonical-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-canonical-websecure.tls=true
- traefik.http.routers.zitadel-canonical-websecure.service=zitadel-api
- traefik.http.routers.zitadel-canonical-websecure.priority=100
zitadel-login:
image: ghcr.io/zitadel/zitadel-login:${ZITADEL_VERSION}
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
# Signs the Login UI session cookie. At least 32 characters, e.g. `openssl rand -base64 32`.
ZITADEL_SESSION_COOKIE_SECRET: ${LOGIN_SESSION_COOKIE_SECRET}
CUSTOM_REQUEST_HEADERS: Host:${ZITADEL_DOMAIN},X-Forwarded-Proto:${ZITADEL_PUBLIC_SCHEME}
# Future-ready placeholders for upcoming Login OTEL support.
OTEL_SERVICE_NAME: ${LOGIN_OTEL_SERVICE_NAME}
OTEL_EXPORTER_OTLP_ENDPOINT: ${LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_PROTOCOL: ${LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL}
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
networks:
- zitadel
depends_on:
zitadel-api:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.docker.network=zitadel
- 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-web.rule=Host(`${ZITADEL_DOMAIN}`) && Path(`/`)
- traefik.http.routers.zitadel-root-web.entrypoints=web
- traefik.http.routers.zitadel-root-web.middlewares=zitadel-root-rewrite
- traefik.http.routers.zitadel-root-web.service=zitadel-login
- traefik.http.routers.zitadel-root-web.priority=400
- traefik.http.routers.zitadel-root-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && Path(`/`)
- traefik.http.routers.zitadel-root-websecure.entrypoints=websecure
- 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=400
- traefik.http.routers.zitadel-login-web.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/ui/v2/login`)
- traefik.http.routers.zitadel-login-web.entrypoints=web
- traefik.http.routers.zitadel-login-web.service=zitadel-login
- traefik.http.routers.zitadel-login-web.priority=250
- traefik.http.routers.zitadel-login-websecure.rule=Host(`${ZITADEL_DOMAIN}`) && PathPrefix(`/ui/v2/login`)
- traefik.http.routers.zitadel-login-websecure.entrypoints=websecure
- traefik.http.routers.zitadel-login-websecure.tls=true
- traefik.http.routers.zitadel-login-websecure.service=zitadel-login
- traefik.http.routers.zitadel-login-websecure.priority=250
postgres:
image: ${POSTGRES_IMAGE}
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
POSTGRES_USER: ${POSTGRES_ADMIN_USER}
POSTGRES_DB: ${POSTGRES_DB}
healthcheck:
test:
- CMD-SHELL
- pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_ADMIN_USER}
interval: 10s
timeout: 30s
retries: 10
start_period: 20s
volumes:
- postgres-data:/var/lib/postgresql/data:rw
networks:
- zitadel
redis:
image: ${REDIS_IMAGE}
restart: unless-stopped
profiles:
- cache
command:
- --save
- ""
- --appendonly
- "no"
networks:
- zitadel
otel-collector:
image: ${OTEL_COLLECTOR_IMAGE}
restart: unless-stopped
profiles:
- observability
command:
- --config=/etc/otelcol/config.yaml
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
networks:
- zitadel
networks:
zitadel:
name: zitadel
volumes:
postgres-data:
zitadel-bootstrap:
💡 技术拓展:这份编排里有三类容易混淆的「密钥/凭证」,职责完全不同——
凭证 定义/特点 存放位置 典型用途 Masterkey 恰好 32 字符的对称加密主密钥,用于加密库内敏感字段(如 OIDC 客户端密钥) --masterkey参数 /ZITADEL_MASTERKEY静态数据加密;丢失后无法解密既有数据 数据库口令 PostgreSQL 账号口令,走网络认证 DSN ZITADEL_DATABASE_POSTGRES_DSN数据库连接认证 login-client PAT 首次初始化自动生成的机器账号令牌,写入 bootstrap 卷 /zitadel/bootstrap/login-client.patLogin UI(Next.js)调用管理 API 的服务凭证 生产部署三者的生成与保管策略不同:Masterkey 用
openssl rand -base64 32生成后入密管;数据库口令按最小权限原则建专用账号(官方.env.example注释明确说明 DSN 指定的用户必须已存在且不会被替换);PAT 由系统生成、定期轮换。
四、编写 .env 与本地 overlay
4.1 .env 全文(实测部署所用)
以官方 .env.example 为底稿,做四处调整:版本钉到 v4.19.5、对外端口 8080→8113、Masterkey/会话 Cookie 密钥改为测试值、删除示例中的初始账号段(改由 overlay 注入,见 4.2)。最终 .env 生效内容:
ZITADEL_DOMAIN=localhost
PROXY_HTTP_PUBLISHED_PORT=8113
ZITADEL_EXTERNALPORT=8113
ZITADEL_EXTERNALSECURE=false
ZITADEL_PUBLIC_SCHEME=http
ZITADEL_MASTERKEY=q1w2e3r4q1w2e3r4q1w2e3r4q1w2e3r4
LOGIN_CLIENT_PAT_EXPIRATION=2099-01-01T00:00:00Z
LOGIN_SESSION_COOKIE_SECRET=SessionCookieSecretNeedsAtLeast32Chars
ZITADEL_VERSION=v4.19.5
TRAEFIK_IMAGE=traefik:v3.7.7
POSTGRES_IMAGE=postgres:17.10-alpine
REDIS_IMAGE=redis:7.4.9-alpine
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.156.0
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=INFO
TRAEFIK_ACCESSLOG_ENABLED=true
TRAEFIK_TRUSTED_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
LETSENCRYPT_EMAIL=ops@example.com
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disable
ZITADEL_ACCESS_LOG_STDOUT_ENABLED=true
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_URL=redis://redis:6379/0
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
ZITADEL_INSTRUMENTATION_SERVICENAME=zitadel-api
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_TYPE=none
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_ENDPOINT=otel-collector:4317
ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE=true
LOGIN_OTEL_SERVICE_NAME=zitadel-login
LOGIN_OTEL_EXPORTER_OTLP_ENDPOINT=
LOGIN_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
与默认值差异对照(TLS 差异为本篇重点,第五章展开):
| 变量 | 官方示例值 | 本文值 | 原因 |
|---|---|---|---|
PROXY_HTTP_PUBLISHED_PORT | 8080 | 8113 | 宿主 8080 被占;8108 被 docmost 占用,顺延 8113 |
ZITADEL_EXTERNALPORT | 8080 | 8113 | 必须与真实对外端口一致,否则签发的 issuer/redirect 端口错误 |
ZITADEL_VERSION | v4.16.0 | v4.19.5 | 示例滞后于最新稳定版,显式钉版 |
ZITADEL_MASTERKEY | 示例值 | 32 字符测试值 | 官方注释要求首次部署必须更换 |
ZITADEL_EXTERNALSECURE | false | false | 保持 HTTP 试用;生产 HTTPS 置 true |
ZITADEL_TLS_ENABLED | (编排内固定 false) | false | TLS 证书由上游反代终结,进程内不开 TLS |
4.2 本地 overlay(docker-compose.local.yml)
为什么需要 overlay:compose 的 .env 文件只负责变量替换(compose 文件里 ${VAR} 的取值),并不会自动把 .env 中所有变量注入容器环境。官方 compose 只把白名单里的变量传给 zitadel-api,因此「初始管理员用户名/密码」这类编排未列出的 ZITADEL_* 变量,写在 .env 里不会生效(实测教训,FAQ-3 有完整排障过程)。正确做法是用 overlay 文件显式注入:
# 本地适配 overlay:固定子网与容器名,便于多栈共存与日志排查;
# 并为首个实例指定初始管理员账号与密码复杂度策略
services:
proxy:
container_name: zitadel-v4-19-5-traefik
zitadel-api:
container_name: zitadel-v4-19-5-api
environment:
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: q1w2e3r4
ZITADEL_DEFAULTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: admin@zitadel.localhost
ZITADEL_DEFAULTINSTANCE_ORG_HUMAN_EMAIL_VERIFIED: "true"
ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASUPPERCASE: "false"
ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASSYMBOL: "false"
zitadel-login:
container_name: zitadel-v4-19-5-login
postgres:
container_name: zitadel-v4-19-5-postgres
networks:
zitadel:
ipam:
config:
- subnet: 10.89.38.0/24
overlay 同时完成两件事:显式子网 10.89.38.0/24(官方文件只命名网络不定子网),以及四组密码复杂度关闭项(默认策略要求大写+符号,q1w2e3r4 无法通过,故显式降低首实例复杂度)。
启动命令固定为双文件叠加:
docker compose --env-file .env -f docker-compose.yml -f docker-compose.local.yml up -d --wait
五、拉取镜像
四个镜像均来自境外仓库(ghcr.io / docker hub),统一走代理拉取:
docker pull ghcr.io/zitadel/zitadel:v4.19.5
docker pull ghcr.io/zitadel/zitadel-login:v4.19.5
docker pull traefik:v3.7.7
docker pull postgres:17.10-alpine
逐条实测回显(关键行,四条 pull 日志的 Digest/Status 摘录):
ghcr.io/zitadel/zitadel:v4.19.5
Digest: sha256:a586456a208e0db4c633db0ba43c427c24b37846138b8b13ff16ad8e003a18d3
Status: Downloaded newer image for ghcr.io/zitadel/zitadel:v4.19.5
ghcr.io/zitadel/zitadel-login:v4.19.5
Digest: sha256:2f8e57672fcd4f9a285d99355fae9f85fd7495768c090c07c3bbc2d84fc06617
Status: Downloaded newer image for ghcr.io/zitadel/zitadel-login:v4.19.5
traefik:v3.7.7
Digest: sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8
Status: Downloaded newer image for traefik:v3.7.7
postgres:17.10-alpine
Digest: sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
Status: Downloaded newer image for postgres:17.10-alpine
拉取完成后核对镜像:
docker images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}' | grep -E 'zitadel|traefik:v3.7.7|postgres:17.10'
实测回显:
ghcr.io/zitadel/zitadel-login:v4.19.5 2f8e57672fcd 331MB
ghcr.io/zitadel/zitadel:v4.19.5 a586456a208e 235MB
traefik:v3.7.7 1cb3845d7a05 250MB
postgres:17.10-alpine 742f40ea20b9 424MB
六、启动与首次初始化
6.1 首次启动实录
第一次按预案以 8108 启动,端口绑定失败(详见 FAQ-1)。改为 8113 并重置数据卷后,正式启动:
docker compose --env-file .env -f docker-compose.yml -f docker-compose.local.yml up -d --wait
实测回显(首次初始化耗时约 100 秒,--wait 阻塞等待全部健康;以下为对同一栈再次执行 up -d --wait 的幂等输出,首次执行时各 Container 行为 Created→Starting→Healthy 递进,结尾健康状态一致):
Container zitadel-v4-19-5-postgres Waiting
Container zitadel-v4-19-5-traefik Waiting
Container zitadel-v4-19-5-api Waiting
Container zitadel-v4-19-5-login Waiting
Container zitadel-v4-19-5-traefik Healthy
Container zitadel-v4-19-5-postgres Healthy
Container zitadel-v4-19-5-api Healthy
Container zitadel-v4-19-5-login Healthy
注:首次启动时 proxy 一行最终为
Started(它自身无健康检查,仅依赖后端两个 Healthy),与上面幂等输出中的Waiting/Healthy行为一致——幂等重跑时 compose 只是复验依赖状态。
启动时序由 depends_on + 健康检查串成链条,理解它有助于判断初始化卡点:
- postgres 先行,
pg_isready通过即 Healthy; - zitadel-api 执行
start-from-init --masterkey ...:连库建表(eventstore/projections/system 三个 schema)→ 写入首实例事件(组织、项目、Console 应用、admin人类用户、login-client机器账号)→ 生成 login-client PAT 写入/zitadel/bootstrap/login-client.pat→ 起监听 8080,/app/zitadel ready探活通过转 Healthy; - zitadel-login 挂载 bootstrap 卷读取 PAT,起 Next.js(3000),
/ui/v2/login/healthy探活通过; - proxy(Traefik)最后启动,经 Docker socket 读取两个后端的 label 生成路由。
6.2 栈状态确认
docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' | grep zitadel
实测回显(部署完成约半小时后):
zitadel-v4-19-5-api Up 20 minutes (healthy) 8080/tcp
zitadel-v4-19-5-traefik Up 32 minutes 0.0.0.0:8113->80/tcp, [::]:8113->80/tcp
zitadel-v4-19-5-login Up 32 minutes (healthy)
zitadel-v4-19-5-postgres Up 32 minutes (healthy) 5432/tcp
对外入口只有 Traefik 的 0.0.0.0:8113->80/tcp,API 与 Login 均不直接发布端口,符合最小暴露面原则。网络归属确认:
docker network inspect zitadel --format 'network={{.Name}} subnet={{range .IPAM.Config}}{{.Subnet}}{{end}}'
实测回显:
network=zitadel subnet=10.89.38.0/24
七、部署验证
7.1 健康与 OIDC 发现端点
curl -s http://localhost:8113/debug/healthz -w "\nHTTP %{http_code}\n"
实测回显:
ok
HTTP 200
curl -s http://localhost:8113/.well-known/openid-configuration | python3 -m json.tool
实测回显(节选,完整 JSON 较长):
{
"issuer": "http://localhost:8113",
"authorization_endpoint": "http://localhost:8113/oauth/v2/authorize",
"token_endpoint": "http://localhost:8113/oauth/v2/token",
"introspection_endpoint": "http://localhost:8113/oauth/v2/introspect",
"userinfo_endpoint": "http://localhost:8113/oidc/v1/userinfo",
"revocation_endpoint": "http://localhost:8113/oauth/v2/revoke",
"end_session_endpoint": "http://localhost:8113/oidc/v1/end_session",
"device_authorization_endpoint": "http://localhost:8113/oauth/v2/device_authorization",
"jwks_uri": "http://localhost:8113/oauth/v2/keys",
"scopes_supported": [
"openid",
"profile",
"email",
"phone",
"address",
"offline_access"
],
"response_types_supported": [
"code",
"id_token",
"id_token token"
],
...
}
关键确认点:issuer 为 http://localhost:8113——scheme(http)、域名(localhost)、端口(8113)三者与 .env 完全一致,说明 ZITADEL_DOMAIN / ZITADEL_EXTERNALPORT / ZITADEL_EXTERNALSECURE 三件套生效(这是本地 HTTP 部署最容易翻车的组合配置,见 FAQ-4/FAQ-5)。
7.2 Web 界面三探针
curl -s -o /dev/null -w "console: HTTP %{http_code}\n" http://localhost:8113/ui/console
curl -s -w "login healthy: HTTP %{http_code}\n" http://localhost:8113/ui/v2/login/healthy
curl -s -o /dev/null -w "root: HTTP %{http_code}\n" http://localhost:8113/
实测回显:
console: HTTP 200
login healthy: {} HTTP 200
root: HTTP 308
解读:/ui/console(管理控制台)与 /ui/v2/login/healthy(Login V2 健康端点,返回 {})均 200,说明 API 与 Login 两个后端都经 Traefik 正常服务;根路径 / 返回 308,由 Traefik 按 label 重写到 /ui/v2/login/(浏览器访问根路径即落到登录页)。
7.3 登录态验证(admin / q1w2e3r4)
用 Session API 实测初始账号能否通过认证(PAT 的来源与用法见 7.4):
docker cp zitadel-v4-19-5-api:/zitadel/bootstrap/login-client.pat - | tar -xO # 读出 login-client PAT
curl -s -X POST http://localhost:8113/v2/sessions \
-H "Authorization: Bearer <login-client PAT>" \
-H 'Content-Type: application/json' \
-d '{"checks":{"user":{"loginName":"admin@zitadel.localhost"},"password":{"password":"q1w2e3r4"}}}'
实测回显(敏感值截断):
{
"sessionId": "395671551736817155",
"sessionToken": "eyJhbGciOiJBMjU2R0NNS1ci...(截断)",
"details": {...}
}
HTTP 201。凭 sessionId + sessionToken 反查会话因子,确认用户与密码两级校验通过:
curl -s http://localhost:8113/v2/sessions/395671551736817155 \
-H "Authorization: Bearer <login-client PAT>" \
-H "x-session-token: <sessionToken>"
实测回显:
{
"session": {
"id": "395671551736817155",
"creationDate": "2026-10-18T14:43:43.549768Z",
"changeDate": "2026-10-18T14:43:43.549768Z",
"sequence": "4",
"factors": {
"user": {
"verifiedAt": "2026-10-18T14:43:42.904902Z",
"id": "395667685880964611",
"loginName": "admin@zitadel.localhost",
"displayName": "ZITADEL Admin",
"organizationId": "395667685880440323"
},
"password": {
"verifiedAt": "2026-10-18T14:43:43.548891Z"
}
}
}
}
factors.user 与 factors.password 均带 verifiedAt,即 overlay 注入的初始账号 admin / q1w2e3r4 初始化成功。浏览器侧等效操作:访问 http://localhost:8113/ui/console,用同一账号登录。
7.4 bootstrap 凭证与启动日志
Login UI 与 API 之间的服务凭证在初始化时自动生成:
docker cp zitadel-v4-19-5-api:/zitadel/bootstrap/login-client.pat - | tar -xO | wc -c
实测回显:277(即一个合法 JWT 形态的 PAT,eyJ 开头)。API 容器启动日志的三段关键证据:
docker logs zitadel-v4-19-5-api 2>&1 | grep -E 'initialization started|verify migration|server is listening' | head -3
实测回显(节选):
time=2026-10-18T14:17:20.160Z level=INFO msg="initialization started" stream=runtime version=v4.19.5
time=2026-10-18T14:17:20.538Z level=INFO msg="verify migration" stream=runtime version=v4.19.5 name=projections.notifications
time=2026-10-18T14:17:20.654Z level=INFO msg="server is listening" stream=runtime version=v4.19.5 address=[::]:8080
initialization started(开始初始化)→ 大量 verify migration(逐个校验投影迁移)→ server is listening(8080 就绪)。若卡在初始化,docker logs 中最后一个 verify migration/错误行就是排查起点。
八、等价 docker run 方式(可选,最小化直跑)
Compose 是官方推荐路径;若只需一个「无反代直连」的最小试验环境(例如调试 API 行为),可用两条 docker run 复刻。先复核端口(本机 8114 空闲):
ss -ltn | grep -E ':8114\b' && echo "8114 OCCUPIED" || echo "8114 FREE"
实测回显:8114 FREE。
第一步,独立 PostgreSQL(复用 zitadel 网络以便与主栈并存,独立数据卷):
docker volume create zitadel-run-pgdata
docker run -d --name zitadel-v4-19-5-run-postgres --network zitadel \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=zitadel \
-v zitadel-run-pgdata:/var/lib/postgresql/data postgres:17.10-alpine
实测就绪确认:
docker exec zitadel-v4-19-5-run-postgres pg_isready -d zitadel -U postgres
/var/run/postgresql:5432 - accepting connections
第二步,ZITADEL(start-from-init 直跑,对外 8114→8080)。注意 DSN 主机名必须与上面的 --name 一致(第一次误写成 postgres-run 导致连不上,FAQ-3 同源教训——容器间靠网络别名寻址,名字必须对得上):
docker run -d --name zitadel-v4-19-5-run-api --network zitadel -p 8114:8080 \
-e ZITADEL_DATABASE_POSTGRES_DSN='postgresql://postgres:postgres@zitadel-v4-19-5-run-postgres:5432/zitadel?sslmode=disable' \
-e ZITADEL_EXTERNALDOMAIN=localhost \
-e ZITADEL_EXTERNALPORT=8114 \
-e ZITADEL_EXTERNALSECURE=false \
-e ZITADEL_TLS_ENABLED=false \
-e ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME=admin \
-e ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=q1w2e3r4 \
-e ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASUPPERCASE=false \
-e ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASSYMBOL=false \
ghcr.io/zitadel/zitadel:v4.19.5 start-from-init --masterkey q1w2e3r4q1w2e3r4q1w2e3r4q1w2e3r4
等待约 15 秒初始化后验证:
curl -s http://localhost:8114/debug/healthz -w " HTTP %{http_code}\n"
docker exec zitadel-v4-19-5-run-postgres psql -U postgres -d zitadel -t -c "SELECT login_name FROM projections.login_names3;" | head -5
curl -s -o /dev/null -w "console: HTTP %{http_code}\n" http://localhost:8114/ui/console
实测回显:
HTTP 200
admin@zitadel.localhost
console: HTTP 200
与 compose 方式的差异:直连容器 8080(无 Traefik,gRPC/HTTP 走同一端口无需 h2c 协商)、EXTERNALDOMAIN 仍是 localhost 但外部端口必须写 8114、没有 Login UI 容器(8114 的 /ui/console 可用,/ui/v2/login 不存在——此形态仅适合 API 调试,完整体验必须含 login 服务)。
💡 技术拓展:这条直跑路径也是理解 ZITADEL 端口模型的钥匙——容器内进程永远只监听 8080;对外呈现的 scheme/host/port 完全由
ExternalDomain/ExternalPort/ExternalSecure三个视角参数「声明」,与实际监听端口解耦。反代部署时 8113→80→8080 的两层映射之所以能正确签发 issuer,靠的就是这三个参数而不是端口探测。
九、日常运维与备份
9.1 常用运维命令(均在 compose 目录执行)
# 幂等启动/应用配置变更(推荐始终带 --wait)
docker compose --env-file .env -f docker-compose.yml -f docker-compose.local.yml up -d --wait
# 查看日志(-f 跟随)
docker logs -f --tail 100 zitadel-v4-19-5-api
# 停止/启动(保留数据卷)
docker compose --env-file .env -f docker-compose.yml -f docker-compose.local.yml down
docker compose --env-file .env -f docker-compose.yml -f docker-compose.local.yml up -d --wait
注意 down 不带 -v 时数据卷(zitadel_postgres-data、zitadel_zitadel-bootstrap)保留,仅删容器与网络;-v 会连数据一起删除,生产严禁。
9.2 备份实测(pg_dump 自定义格式)
docker compose --env-file .env -f docker-compose.yml -f docker-compose.local.yml exec postgres \
pg_dump -U postgres -Fc -d zitadel -f /tmp/zitadel.dump
docker cp zitadel-v4-19-5-postgres:/tmp/zitadel.dump /tmp/zitadel.dump
ls -lh /tmp/zitadel.dump
实测回显:
-rw-r--r-- 1 root root 370K Oct 18 14:52 /tmp/zitadel.dump
370K 的自定义格式转储包含 eventstore 事件 + projections 投影 + system schema 全量。恢复路径:pg_restore -U postgres -d zitadel(空库)后原样拉起栈。由于 ZITADEL 的事件溯源内核(见《ZITADEL v4.19.5 介绍与选型指南》3.2)事件表是事实源,即使投影表损坏也可在空投影上重放重建,因此备份必须包含 eventstore schema,本命令已满足。
9.3 升级路径(官方注释转述)
官方 .env.example 的版本段注释给出了标准升级流程:
# 修改 .env 中 ZITADEL_VERSION 后:
docker compose --env-file .env -f docker-compose.yml pull
docker compose --env-file .env -f docker-compose.yml up -d --wait
ZITADEL 支持零停机升级(README:Zero-downtime updates);跨小版本时先看 Release Notes 是否含破坏性变更,start-from-init 幂等可重复执行,升级后用 7.1 的 healthz + discovery 快速回归。
十、FAQ(排障实录)
FAQ-1:8108 端口绑定失败(ss 复核过的端口为何还会冲突)
按预案首启用 8108,compose 绑定 proxy 端口时报 daemon 端口占用错误。等价复现(同一 daemon 错误原文):
docker run --rm --name zt-portprobe -p 8108:80 traefik:v3.7.7
实测回显:
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint zt-portprobe (...): Bind for 0.0.0.0:8108 failed: port is already allocated
排查与结论:ss -ltn 显示 8108 空闲,但 docker ps 全量检查发现既有 docmost 栈的容器已映射 8108。ss 看不到「容器端口映射」这类 docker-proxy 占用(部分内核/配置下不出现在 LISTEN 面向用户态的输出里),所以端口复核必须同时做两件事:ss -ltn 排除宿主进程 + docker ps --format '{{.Names}}\t{{.Ports}}' 排除容器映射。处置:对外端口顺延为 8113(.env 的 PROXY_HTTP_PUBLISHED_PORT 与 ZITADEL_EXTERNALPORT 两处同步改),并在首次初始化前改完——对外端口参与 issuer 签发,中途换端口需重置数据卷重初始化。
FAQ-2:自定义子网冲突(Pool overlaps)
规划子网 10.89.37.0/24 创建时报池冲突(等价复现命令与原文):
docker network create --subnet 10.89.37.0/24 zt-subnet-probe
实测回显:
Error response from daemon: invalid pool request: Pool overlaps with other one on this address space
结论:宿主已有网络占用了该地址段(本机为 docmost 栈的 10.89.37.0/24)。用 docker network inspect $(docker network ls -q) --format '{{.Name}}: {{range .IPAM.Config}}{{.Subnet}} {{end}}' 列出全部占用后顺延 10.89.38.0/24。创建后务必 ip route get <宿主网关> 验证出口路由仍走 ens33(第二章实测)。
FAQ-3:.env 里加了初始账号变量却不生效
第一轮部署把 ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME=admin 等写进 .env,初始化后登录却失败。证据链:
docker compose ... exec postgres psql -U postgres -d zitadel -t -c "SELECT login_name FROM projections.login_names3;"
回显实际用户是 zitadel-admin@zitadel.localhost(overlay 注入生效后变为 admin@zitadel.localhost);用 q1w2e3r4 对默认账号登录报:
{"code": 3, "message": "Password is invalid (COMMAND-3M0fs)"}
根因:compose 的 .env 只做变量替换,不注入容器环境。官方 compose 的 environment: 白名单没有初始账号变量,写了等于没写。处置:在 overlay 文件里对 zitadel-api 服务显式加 environment:(见 4.2)。同类教训也出现在 docker run 直跑方式——容器间寻址用网络别名,DSN 里的主机名必须与 --name/服务名完全一致。
FAQ-4:访问报 404 「Instance not found」(ExternalDomain 与 Host 不一致)
ZITADEL 按 HTTP Host 头路由到对应实例(多租户域名机制)。用未注册域名实测:
docker run --rm --network zitadel curlimages/curl -s \
-H "Host: unregistered.example.com" \
http://zitadel-api:8080/.well-known/openid-configuration -w "\nHTTP %{http_code}\n"
实测回显:
unable to set instance using origin &{unregistered.example.com http} (ExternalDomain is localhost): ID=QUERY-1kIjX Message=Instance not found. Make sure you got the domain right. Check out https://zitadel.com/docs/apis/introduction#domains
HTTP 404
官方 compose 文档明确点名这是最常见的部署问题。要点:ZITADEL_DOMAIN 是「实例注册的唯一对外域名」,所有请求的 Host 必须与之一致(经 Traefik 时由 label 里的 Host(...) 规则保证);用 IP 或别的域名访问必然 404。本地试用就用 localhost 并保持三件套(DOMAIN/PORT/SECURE)与真实入口一致。
FAQ-5:进程内 TLS 开启但无证书启动失败
本地 HTTP 实测时若误开 TLS(--tlsMode enabled 或默认配置未关),启动直接失败:
Error: TLS is enabled: please specify a key (path) and a cert (path) or disable TLS if needed (e.g. by setting flag `--tlsMode external` or `--tlsMode disabled
(v4.19.5 start 命令实测原文,err.id=LOG-ii6Pi。)背景:ZITADEL 默认面向「反代终结 TLS」设计,进程内 TLS 仅在能提供证书时才可开。三种 TLS 模式:disabled(进程纯 HTTP,本地用)/ external(上游反代已终结 TLS,进程收明文,读 X-Forwarded-Proto)/ enabled(进程自带证书,需配 cert/key 路径)。官方 compose 已固定 ZITADEL_TLS_ENABLED: false + EXTERNALSECURE=false 组合,即「反代之后、进程禁 TLS」的本地形态;生产 HTTPS 则由 Traefik 的 websecure 入口终结。
FAQ-6:未提供 masterkey 启动失败
docker run --rm ghcr.io/zitadel/zitadel:v4.19.5 start --tlsMode disabled ...
实测回显:
Error: no master key provided: masterkey must either be provided by file path, value or environment variable
masterkey 有三种提供方式:--masterkey <值>、--masterkeyFile <路径>、环境变量 ZITADEL_MASTERKEY。必须是恰好 32 字符(官方示例 MasterkeyNeedsToHave32Characters)。它加密库内敏感字段,首次初始化后不可更换(换了旧数据解不开),务必入密管。
FAQ-7:数据库口令错误启动失败
DSN 口令错误时的启动报错(实测原文,节选):
Error: cannot start DB client for queries: failed to connect to `user=postgres database=zitadel`: 10.89.38.2:5432 (postgres): failed SASL auth: FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01)
这类「库连通但认证失败」的错误会出现在 start-from-init 的最初几行日志;注意报错里把 DSN 解析出的主机/IP(10.89.38.2)一并打印,可据此判断连的是不是预期实例。口令/主机修正后重跑即可,无需清理卷(尚未初始化成功时)。
FAQ-8:API 容器是 distroless,无法 exec shell
想进 API 容器看文件时:
docker exec zitadel-v4-19-5-api sh -c 'ls /zitadel/bootstrap'
实测回显:
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
镜像无 shell、无 busybox(安全加固的正常结果)。读取容器内文件(如 bootstrap PAT)改用 docker cp:
docker cp zitadel-v4-19-5-api:/zitadel/bootstrap/login-client.pat - | tar -xO
查数据库则进 postgres 容器(alpine 有 sh)执行 psql。
参考链接
- 官方 Compose 部署文档(含三种 overlay:homelab/tls/letsencrypt):https://zitadel.com/docs/self-hosting/deploy/compose
- v4.19.5 官方编排文件:https://github.com/zitadel/zitadel/blob/v4.19.5/deploy/compose/docker-compose.yml
- v4.19.5 示例环境变量:https://github.com/zitadel/zitadel/blob/v4.19.5/deploy/compose/.env.example
- 官方安装总览(二进制/Helm/Compose):https://zitadel.com/docs/self-hosting/deploy/overview
- Session API v2 文档:https://zitadel.com/docs/apis/resources/session_service_v2/session-service-v2-create-session
- 默认配置文件 defaults.yaml:https://github.com/zitadel/zitadel/blob/v4.19.5/cmd/defaults.yaml
- 本系列配套篇:《ZITADEL v4.19.5 介绍与选型指南》《ZITADEL v4.19.5 配置参数详解》《ZITADEL v4.19.5 API 使用详解》