SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

如何在 VPS 上用 Docker 自托管 Zitadel

Zitadel 生产环境建议 4 核 CPU 和 8 GB 内存。本文在单台 VPS 上配置 PostgreSQL、masterkey、TLS、SMTP 与备份,并说明升级对数据库的影响。

在 VPS 上自行托管 Zitadel 的要求

要在 VPS 上自行托管 Zitadel,您需要一台 Docker 主机、一个解析到该主机的公共 DNS 名称、PostgreSQL,以及约 4 个 CPU 核心和 8 GB 内存。Zitadel 是身份提供商。它通过 OIDC(OpenID Connect)和 SAML(安全断言标记语言)签发令牌,使其他服务不再维护各自的用户列表。安装过程包括一个 curl 和一个 docker compose up。决定服务能否稳定运行的部分包括 masterkey、数据库用户、SMTP(简单邮件传输协议)、备份以及首次升级。

以下内容均假设您使用 Ubuntu 24.04、带 Compose 插件的 Docker Engine 24 或更高版本,并且类似 auth.example.com 的名称已解析到该服务器。

Zitadel 需要多少 VPS 资源?

Zitadel 文档中的 Compose 快速入门要求 2 GB 内存。这个数值针对的是笔记本电脑。Zitadel 的生产环境指南给出了不同的数值。

ChartZitadel's own published sizing guidance, August 2026
The data behind this chart
[
  {
    "config": "Process floor, no load",
    "cpu_cores": 0.5,
    "ram_gb": 0.5
  },
  {
    "config": "Single node, reduced setup",
    "cpu_cores": 4,
    "ram_gb": 8
  },
  {
    "config": "HA node, logs and metrics on",
    "cpu_cores": 4,
    "ram_gb": 16
  }
]

这些是公开发布的建议值,不是从运行中服务器测得的数据。应将其理解为资源需求的构成。Zitadel 进程本身很小,空闲时约占 0.5 GB 内存。所需 CPU 核心用于密码哈希。该操作本来就设计得很慢,因此登录请求集中到来时会产生 CPU 峰值。PostgreSQL 是另一项主要资源开销:同一份指南按每 100 requests per second 约需 1 个核心、每个核心需 4 GB 内存进行预算。将两部分合计后,单节点需要指南所列的 4 个核心和 8 GB 内存;启用日志和指标后,则需要每个节点 16 GB 内存。

因此,2 GB VPS 可以启动这套服务,但低于项目对实际使用场景的建议值。登录服务是其他所有服务所依赖的服务。它停止后,任何信任该服务的系统都不会允许用户登录。认为 8 GB 内存用于身份认证超出预算是合理的决定,而且现在做出这个决定,成本远低于迁移后再调整。Keycloak、Authentik 和 Zitadel 的对比介绍了它们各自的内存开销和运维工作量;在较小的服务器上,通常的选择是自行托管 Authentik 服务器

获取堆栈并固定版本

mkdir zitadel-compose && cd zitadel-compose
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
cp .env.example .env
chmod 600 .env

该文件定义了实际运行的 4 个服务。Traefik 是反向代理:它按路径路由,并通过下方的 overlay 终止 TLS(传输层安全)。zitadel-api 是监听 8080 端口的 Go 二进制文件。zitadel-login 是在 /ui/v2/login 提供的登录界面。postgres 保存所有内容。Redis 缓存和 OpenTelemetry collector 也位于同一个文件中,但由 Compose profiles 控制;只有在明确启用后才会运行。

暂时不要运行 docker compose up。首次启动会创建实例,下面的若干设置之后无法更改,除非执行额外操作。

你复制的 .env 会固定自身的镜像标签:

ZITADEL_VERSION=v4.16.0
TRAEFIK_IMAGE=traefik:v3.7.7
POSTGRES_IMAGE=postgres:17.10-alpine

当前 v4 版本为 v4.17.1,发布于 14 August 2026。将 ZITADEL_VERSION 设置为你计划运行的版本,并保持在 v4 系列内,不要跟踪最新版本。上面的 curl 会从 main 分支获取 docker-compose.yml,该分支未固定到任何版本,因此请将这两个文件的副本提交到 git repository。否则,下个月在新服务器上运行相同命令时会得到不同的文件,并且你无法知道发生了哪些变化。

为 Postgres 创建专用用户并设置真实密码

随附的 .env 使用超级用户连接 Zitadel 和 PostgreSQL,密码为 postgres

POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disable

这里的加固步骤有一个陷阱。Zitadel 文档要求将 POSTGRES_ZITADEL_PASSWORD 追加到 .env,但基础 docker-compose.yml 从不读取该变量,因此设置它不会产生任何效果。单独修改 POSTGRES_ADMIN_PASSWORD 反而会导致连接失败,因为密码也被直接写在 DSN(数据源名称)字符串中。DSN 决定 Zitadel 的连接方式。

.env.example 中的注释已经说明了其余细节:配置 DSN 后,Zitadel 会直接使用其中的用户,不会自动为您创建非特权用户。因此,该角色必须在首次启动前存在。生成密码,单独启动 Postgres,然后创建该角色。

tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echo

docker compose --env-file .env -f docker-compose.yml up -d postgres

docker compose --env-file .env -f docker-compose.yml exec -T postgres \
  psql -U postgres -d postgres <<'SQL'
CREATE ROLE zitadel LOGIN PASSWORD 'the-password-you-generated';
ALTER DATABASE zitadel OWNER TO zitadel;
SQL

docker compose --env-file .env -f docker-compose.yml exec -T postgres \
  psql -U postgres -d zitadel -c 'ALTER SCHEMA public OWNER TO zitadel;'

这些 psql 调用会通过容器内的本地套接字执行。官方 Postgres 镜像信任该连接,因此不会提示输入密码。关键在于所有权。在 PostgreSQL 15 及更高版本中,普通的 GRANT ALL PRIVILEGES ON DATABASE 不再允许角色在 public schema 中创建表,因此 Zitadel 在创建 schema 的设置阶段会因权限错误而失败。让该角色拥有数据库和 schema 即可避免此问题。

现在将 DSN 指向新角色,并在文件中同时设置一个真实的管理员密码:

POSTGRES_ADMIN_PASSWORD=a-32-character-random-string
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:the-password-you-generated@postgres:5432/zitadel?sslmode=disable

这里使用 sslmode=disable 没有问题,因为 Postgres 只能通过私有 Compose 网络访问,其端口从未发布到主机。首次完整启动后,检查该角色是否确实拥有其数据:

docker compose exec -T postgres psql -U zitadel -d zitadel -c '\dn'

输出中应包含一个 eventstore schema 和一个 projections schema。列表为空表示设置阶段尚未执行到这里;API 容器日志会说明原因。

主密钥,以及丢失它的代价

Zitadel 会在存储机密前对其加密,包括客户端机密、身份提供商凭据、SMTP 密码、一次性密码种子和机器密钥。主密钥可解锁所有这些数据。主密钥必须正好包含 32 个字符。文档明确说明了后果:更换主密钥会导致无法访问已加密的数据。

生成一个主密钥,并替换 .env 中的占位行:

tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echo

请编辑 ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters 这一行,而不是再追加一行。Compose 会采用重复键的最后一个定义,因此追加确实有效,但文件中存在两行主密钥配置,会给下一位阅读者留下隐患。

现在考虑该密钥的存放位置。Compose 文件会以如下方式启动 API 容器:

command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"

因此,主密钥会出现在容器的命令行参数中,任何能够访问 Docker socket 的人都可以通过 docker inspect 查看它。在只有一名管理员的 VPS 上,这是可以接受的取舍;.env 的权限模式可保护磁盘上的文件。如果无法接受这种方式,请将密钥挂载为文件,并改用 --masterkeyFile /run/secrets/zitadel-masterkey,这样密钥值不会出现在进程参数中。

首次启动前,将主密钥复制到密码管理器中。主密钥不会出现在数据库转储中,因此使用不同的主密钥恢复转储后,实例将无法读取自己的机密数据。请将主密钥存放在保存转储的归档之外,避免一份被盗的备份同时包含加密数据及其解密密钥。

首次启动前设置外部域名

ZITADEL_DOMAIN in .env feeds ZITADEL_EXTERNALDOMAIN in the container, and it is the name your users type. Zitadel derives the OIDC issuer, the login interface base URI, the SAML endpoints and the first admin's login name from it, so it is not cosmetic.

ZITADEL_DOMAIN=auth.example.com
ZITADEL_EXTERNALPORT=443
ZITADEL_EXTERNALSECURE=true

Zitadel resolves which instance you are talking to from the Host header. If that header does not match a domain it knows, every request gets the same answer:

ID=QUERY-1kIjX Message=Instance not found

That is the most common self-hosted Zitadel error, and it almost always means one of two things. Either ZITADEL_DOMAIN is not the name you are browsing to, or a proxy in front is rewriting Host to the upstream address. Browsing to the server's IP address instead of the name produces it as well.

You can change these values later. Zitadel has to rerun its setup phase to pick the change up, and every application you already registered keeps its old redirect URIs. Choosing the final name now is much cheaper than moving it.

使用 Let's Encrypt overlay 终止 TLS

对于公网域名,添加 Zitadel 的 Let's Encrypt overlay。它会将 Traefik 切换为 ACME(自动证书管理环境)HTTP challenge,并将发布端口替换为 80 和 443,因此服务器上的其他程序不能占用这两个端口中的任何一个。

curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
echo 'LETSENCRYPT_EMAIL=ops@example.com' >> .env

该 overlay 还会在 API 容器上设置 ZITADEL_EXTERNALPORT: 443ZITADEL_EXTERNALSECURE: true,因此公网 URL 与 Zitadel 为自身生成的 URL 保持一致。启动前必须确保 A 记录已解析,因为没有该记录时 HTTP challenge 会失败。

如果已在 nginx 或负载均衡器上终止 TLS,请改用 docker-compose.mode-external-tls.yml,并将 TRAEFIK_TRUSTED_IPS 设置为代理发送请求时使用的地址范围。Traefik 只接受来自该列表中地址的 X-Forwarded-* header,因此值不正确时,转发的协议会被丢弃,Zitadel 会开始为 HTTPS 站点生成 http:// URL。

上游代理有两项 Zitadel 要求严格的职责。它必须使用 HTTP/2 与后端通信,因为 API 使用 gRPC。它还必须原样传递 HostX-Forwarded-Proto: https。Zitadel 自带的 nginx 示例如下:

server {
    listen 443 ssl;
    http2 on;
    ssl_certificate     /etc/certs/selfsigned.crt;
    ssl_certificate_key /etc/certs/selfsigned.key;
    location /ui/v2/login {
        proxy_pass http://login-external-tls:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }
    location / {
        grpc_pass grpc://zitadel-external-tls:8080;
        grpc_set_header Host $host;
        grpc_set_header X-Forwarded-Proto https;
    }
}

其中的上游名称是 Zitadel 测试环境中的容器名称,因此应替换为实际名称。如果通过 443 以外的端口提供 Zitadel,请使用 grpc_set_header Host $host:$server_port;,以便将端口一并写入 header。其余部分是普通的 virtual host,逐行阅读 nginx 反向代理配置介绍了与 Zitadel 无关的部分。

第一位管理员及强制修改密码

首次启动会创建一个实例、一个组织和一个人类管理员。登录名由 zitadel-admin@zitadel. 和您的外部域名组成,因此使用 ZITADEL_DOMAIN=auth.example.com 时为:

zitadel-admin@zitadel.auth.example.com

如果您未设置自定义密码,默认密码为 Password1!。Zitadel 的上游默认设置会在首次登录时强制修改密码,而随附的 compose 文件覆盖了这一默认设置:

ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false

该行硬编码在 docker-compose.yml 中,而不是从 .env 读取。因此,请在您自己的小型 overlay 文件中设置自定义值。将其命名为 docker-compose.local.yml

services:
  zitadel-api:
    environment:
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: you@example.com
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "a-long-temporary-password"
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "true"

只有在不使用 -f 标志运行 Compose 时,Compose 才会自动加载 docker-compose.override.yml;而 Zitadel 指南中的每条命令都会传递 -f,从而禁用这一行为。不要重复维护不断增长的标志列表,而应在 .env 中固定文件列表:

COMPOSE_FILE=docker-compose.yml:docker-compose.mode-letsencrypt.yml:docker-compose.local.yml

现在启动它:

docker compose pull
docker compose up -d --wait

--wait 会一直等待命令,直到健康检查通过。如果 API 容器始终无法通过检查,Compose 会以 dependency failed to start: container zitadel-compose-zitadel-api-1 is unhealthy 停止,而 docker compose logs zitadel-api 中会记录具体原因。首次启动时,原因通常是 masterkey 长度或数据库 DSN。

登录 https://auth.example.com/ui/console,修改密码,然后在创建其他内容前为该账户启用第二因素。每个 ZITADEL_FIRSTINSTANCE_* 值仅在创建第一个实例期间生效。实例创建后,修改这些值完全不会产生任何作用。

密码重置为何必须等 SMTP 正常后才会生效

无法发送邮件的身份提供商,其故障可能会隐藏数周。Zitadel 会为用户邀请、地址验证、密码重置链接、一次性代码和域名声明通知发送邮件。未配置 SMTP 提供商时,Console 仍会报告操作已完成,但消息会进入没有发送目标的通知工作线程。默认情况下,该工作线程会获得 MaxAttempts: 3MaxTtl: 5m,因此会在几分钟内重试数次,然后停止。等待链接的用户不会收到任何提示。

在 Console 中配置此功能,位置是实例设置下的 https://auth.example.com/ui/console/settings。SMTP 提供商表单要求填写发件人电子邮件地址、发件人名称、主机和端口、用户、SMTP 密码,以及 TLS 开关。保存前使用表单中的测试按钮,因为它会发送真实邮件:邮件要么到达,要么无法到达。

对应的环境变量包括 ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST 及其同类变量。它们只在创建实例时生效。对于已经运行的堆栈,这些变量不会产生任何影响,因此现有实例应使用 Console 配置。

VPS 的邮件投递有两点需要注意,因为问题通常出在这里。大多数提供商会阻止新账户的出站 25 端口,因此直接向收件人的邮件服务器发送邮件会超时,并且不会提供有用的错误信息。应改用经过身份验证的 587 端口中继服务。同时,为发件域发布 SPF(sender policy framework)和 DKIM(domainkeys identified mail)记录,否则重置链接会进入垃圾邮件。对用户而言,这与邮件根本没有发送完全相同。

在邀请任何人之前先验证邮件投递。创建一个临时用户,请求密码重置,并确认邮件是否到达。如果没有到达,docker compose logs -f zitadel-api 会指出 SMTP 故障。SMTP 密码会加密存储在数据库中,masterkey 还负责保护这一项数据。

分别备份 Postgres 和 masterkey

Zitadel 的所有数据都存储在 PostgreSQL 中。负责解密这些数据的是 masterkey。请将它们备份到两个不同的位置。

先创建转储:

sudo install -d -m 700 /srv/zitadel-backups
docker compose exec -T postgres \
  pg_dump -U postgres -Fc zitadel > "/srv/zitadel-backups/zitadel-$(date +%F).dump"

-Fc 是 custom format。它会在导出时压缩数据,并且 pg_restore 可以按需读取该格式。exec -T 会退出终端会话。这一点很重要,因为该命令由 cron 运行,且没有附加终端。

然后使用 restic 将该目录推送到异地。restic 会加密数据并进行重复数据删除:

export RESTIC_REPOSITORY="sftp:backup@backup.example.com:/srv/restic/zitadel"
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic init
restic backup /srv/zitadel-backups
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

restic init 只在第一次运行的当天执行一次。将转储命令和最后两个命令写入 /usr/local/bin/zitadel-backup.sh,然后每晚运行该脚本:

0 3 * * * /usr/local/bin/zitadel-backup.sh

.env 以及使用的所有 compose 文件备份到 git 中。masterkey 不适用上述做法。应将它存储在密码管理器中,并额外存储到一个不属于此 restic 仓库的位置。因为如果同一个归档同时包含数据库和解密密钥,那么它就不再是加密系统的备份。

未经过恢复验证的备份只是一个猜测。请在同一台服务器上的临时数据库中恢复,然后检查数据:

docker compose exec -T postgres createdb -U postgres zitadel_restore_test
docker compose exec -T postgres pg_restore -U postgres -d zitadel_restore_test \
  < /srv/zitadel-backups/zitadel-2026-08-21.dump
docker compose exec -T postgres psql -U postgres -d zitadel_restore_test -c '\dt eventstore.*'
docker compose exec -T postgres dropdb -U postgres zitadel_restore_test

如果 eventstore schema 中列出了数据表,说明转储有效。如果报错称该 schema 不存在,说明转储无效。你可以在不会造成损失时发现这个问题。备份和升级 Compose 堆栈的通用方法 在这里几乎无需修改即可使用。将 masterkey 排除在同一归档之外,是 Zitadel 特有的唯一要求。

升级 Zitadel 且不丢失实例

升级就是在 .env 中提升版本,然后执行两个命令:

docker compose pull
docker compose up -d --wait

在对用户登录使用的实例执行第二个命令前,先了解它的作用。容器的命令是 start-from-init。该命令会在开始提供服务前运行 init 和 setup 阶段,其中 setup 阶段负责执行数据库迁移。因此,提升版本会在容器启动时对生产数据库执行架构迁移,且整个过程无人值守;此时 --wait 会一直等待健康检查通过。这正是上文的恢复测试不可省略的原因。

在升级前立即创建一份全新的数据库转储。昨晚的转储不能替代它。

不要跨越主版本升级。从 v3 升级到 v4 前,必须先使用 v3.4.1 或更高版本,因为 v4 移除了旧版 OIDC 签名密钥。跨越版本后,使用旧密钥签名的令牌会立即无法验证。Zitadel 的技术公告 A-10017 说明了这一点。解决方法是先运行较新的 v3,等待旧令牌过期,然后再升级。

使用 docker compose logs -f zitadel-api 监控 setup 阶段。大型 eventstore 的迁移可能需要数分钟。Traefik 只有在健康检查通过后才会将请求路由到 API,因此站点会在这段时间内中断。应提前规划这段停机时间,而不是在升级时才发现。

回滚不能只改回旧标签。迁移执行后,旧二进制文件无法理解当前架构,因此回滚意味着恢复数据库转储。实例开始承载真实用户后,应改用 docker-compose.prodlike.yml。该 overlay 将 init 和 setup 作为独立于 start 的步骤运行,使迁移成为需要手动触发和监控的操作,而不是容器重启的副作用。

新身份提供商的配置目标

在 Console 中创建一个项目,然后在其中创建一个应用。对于现代应用,选择 OIDC。Zitadel 会提供 client ID、client secret,以及位于 https://auth.example.com/.well-known/openid-configuration 的 discovery document。大多数支持单点登录的自托管软件都需要这些信息。

许多软件不支持单点登录,或者仅在付费版本中提供该功能。对于前一种情况,在应用前部署 oauth2-proxy,可将任意 HTTP 服务接入 Zitadel 的保护范围。对于后一种情况,在围绕尚未购买的功能规划迁移前,建议阅读 自托管应用中的 SSO 费用门槛

FAQ

自托管 Zitadel 需要多少 RAM 和 CPU?

Zitadel 生产指南建议,使用精简配置的单节点大约需要 4 个 CPU 核心和 8 GB RAM;启用日志和指标后,每个节点需要 16 GB RAM。PostgreSQL 需要单独规划资源,标准约为每 100 个请求/秒使用 1 个 CPU 核心,每个核心配备 4 GB RAM。Compose 快速入门配置在 2 GB RAM 内即可启动,适合试用,但低于项目对承载其他依赖服务的系统所建议的配置。

如果丢失 Zitadel masterkey,会发生什么?

所有使用该密钥加密的数据仍会保持加密状态。客户端密钥、身份提供商凭据、SMTP 密码和一次性密码种子都无法解密,而且事后无法更换该密钥。单独的数据库转储无法恢复可用实例,因为转储中只有密文,没有密钥。请将 masterkey 存储在密码管理器中,并与保存数据库转储的备份分开存放。如果两者都丢失,只能从头重建实例。

为什么 Zitadel 的密码重置邮件始终收不到?

因为未配置 SMTP 提供商,或者已配置的提供商无法投递邮件。Zitadel 默认会将每条通知加入队列,并交给工作进程尝试发送 3 次;无论结果如何,Console 都会报告成功,因此发送失败不会明显显示。请在实例设置中配置 SMTP 提供商,并使用该表单中的测试按钮发送真实邮件。从 VPS 发送邮件时,请使用端口 587 上需要身份验证的中继,因为大多数提供商会阻止出站端口 25。还应为发信域发布 SPF 和 DKIM 记录,避免邮件被过滤为垃圾邮件。

安装后可以更改 Zitadel 的外部域名吗?

可以,但不能只编辑 .env。请修改 ZITADEL_EXTERNALDOMAINZITADEL_EXTERNALPORTZITADEL_EXTERNALSECURE,然后让 Zitadel 重新运行设置阶段,以应用这些更改。已注册的应用会保留旧的重定向 URI,必须手动更新;任何 Host 标头与 Zitadel 已知域名不匹配的请求,都会收到 Instance not found。在首次启动前确定最终域名,可以避免这些问题。