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,回显均来自真实部署。

ZITADELZITADELIAMOIDC2026/10/18

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)
Masterkey32 字符测试值 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_PORT80808113宿主 8080 被占;8108 被 docmost 占用,顺延 8113
ZITADEL_EXTERNALPORT80808113必须与真实对外端口一致,否则签发的 issuer/redirect 端口错误
ZITADEL_VERSIONv4.16.0v4.19.5示例滞后于最新稳定版,显式钉版
ZITADEL_MASTERKEY示例值32 字符测试值官方注释要求首次部署必须更换
ZITADEL_EXTERNALSECUREfalsefalse保持 HTTP 试用;生产 HTTPS 置 true
ZITADEL_TLS_ENABLED(编排内固定 false)falseTLS 证书由上游反代终结,进程内不开 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 + 健康检查串成链条,理解它有助于判断初始化卡点:

  1. postgres 先行,pg_isready 通过即 Healthy;
  2. 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;
  3. zitadel-login 挂载 bootstrap 卷读取 PAT,起 Next.js(3000),/ui/v2/login/healthy 探活通过;
  4. 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。

参考链接