Herald

部署

用 docker compose 在一台服务器上跑起 Herald,三个容器搞定后端、数据库和缓存。

用 docker compose 在一台服务器上跑起 Herald,三个容器搞定后端、数据库和缓存。

架构

Internet ──→ Caddy (80/443) ──→ herald (3000)
                                      │
                           PostgreSQL + Redis

外部流量走 Caddy 进来。Caddy 负责 TLS 终止,把请求转发到 herald 容器的 3000 端口。herald 容器里的后端二进制同时处理 API 请求(/api/*)和前端静态文件(/app/frontend/dist),所以前端不用单独部署。

三个容器挂在同一个 compose 网络上,用服务名互相访问(herald 连 PostgreSQL 用 db:5432)。Caddy 是可选的反向代理,只在你要 HTTPS 或自定义域名时加。

前置条件

  • Linux 服务器(Ubuntu 22.04+ 或 Debian 12+),至少 2GB 内存
  • Docker Engine 24+ 和 Docker Compose v2
  • 一个域名,DNS A 记录指向服务器 IP
  • 防火墙开放两个端口:80(HTTP)、443(HTTPS)

如果只想先跑通不带 HTTPS,可以跳过 Caddy,直接开放 3000 端口。

准备配置文件

部署文件在仓库的 docker/deploy/ 目录。把它们拷到服务器,比如 /opt/herald/:

mkdir -p /opt/herald
cd /opt/herald
# 把 docker/deploy/ 下的 docker-compose.yml、config.toml、.env.example 拷到这里
cp .env.example .env

需要改三个文件:config.toml(应用配置)、.env(运行时密钥)、Caddyfile(反向代理,可选)。

生成密钥

应用配置(config.toml)和运行时环境(.env)里各需要几个密钥。先一次性生成:

openssl rand -base64 48   # config.toml 的 [jwt] secret
openssl rand -hex 32      # config.toml 的 [custom_domain] ask_key
openssl rand -base64 24   # .env 的 ADMIN_PASSWORD
openssl rand -hex 24      # .env 的 INTERNAL_API_KEY
openssl rand -base64 48   # .env 的 TOTP_SECRET_KEY(至少 32 字节)

Herald 的应用配置只从 TOML 文件读取(ApiConfig::load 在 backend/api/src/config.rs),所以 JWT 密钥放在 config.toml。但管理员账号初始化和几个服务密钥在运行时从环境变量读,这些放 .env。两套都不能少。

应用配置 config.toml

config.toml 控制数据库连接、Redis 地址、JWT 密钥等。镜像内置了一份带占位值的 production.toml,你这份 config.toml 会覆盖它。改这几项:

[frontend]
# 改成你的真实域名,控制 CORS 允许来源
url = "https://your-domain.com"

[jwt]
# 必填。用上面 openssl rand -base64 48 生成的值
secret = "替换成你的 JWT 密钥"

[custom_domain]
# 必填,服务启动时会校验非占位值(backend/api/src/lib.rs:164)
# 用上面 openssl rand -hex 32 生成的值
ask_key = "替换成你的自定义域名密钥"
cname_target = "custom.your-domain.com"

数据库和 Redis 的地址保持默认(db:5432、redis:6379),它们对应 compose 里的服务名。[custom_domain].ask_key 留成 change-me-in-production 服务会拒绝启动,见配置。

运行时环境 .env

.env 给 herald 容器注入环境变量。生产模式下这几项必填,少一个服务都起不来:

# 镜像地址。pin 版本可保证部署可复现,比如 ghcr.io/timzaak/herald:0.4.1
HERALD_IMAGE=ghcr.io/timzaak/herald:latest

# 生产模式下 ADMIN_PASSWORD 必填,否则启动直接 bail(backend/core/src/admin/user.rs:62)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=替换成你的管理员密码
ADMIN_REALM_ID=admin

# 内部 API 调用用的密钥(/api/internal/* 的 X-Internal-Api-Key)
INTERNAL_API_KEY=替换成你的内部 API 密钥

# TOTP 的 HMAC 密钥,至少 32 字节(backend/domain/src/user_totp/service.rs:182)
TOTP_SECRET_KEY=替换成你的 TOTP 密钥

首次启动时,Herald 会在数据库为空时用 ADMIN_EMAIL / ADMIN_PASSWORD 创建管理员账号。之后再启动不会重复创建。

Caddyfile(可选,HTTPS 用)

需要 HTTPS 或自定义域名时加 Caddy。创建 /opt/herald/Caddyfile:

your-domain.com {
    reverse_proxy herald:3000
}

herald 是 compose 里的服务名,Caddy 通过 compose 网络访问它。把 your-domain.com 改成你的域名,Caddy 会自动向 Let's Encrypt 申请证书并续期,不用手动配。

启动

配置改完后,一条命令拉起三个容器:

docker compose up -d

启动顺序由 depends_on 控制:PostgreSQL 和 Redis 先就绪(healthcheck 通过),herald 才开始启动。herald 启动时会自动跑数据库迁移(sqlx::migrate! 嵌在二进制里),你不用手动建表。

看启动状态:

docker compose ps

等 herald 那行显示 healthy。第一次拉镜像和跑迁移会慢一点,大概一两分钟。

验证

curl http://localhost:3000/health

返回类似下面的 JSON 就说明后端、数据库、Redis 都通了:

{"status":"healthy","database":true,"redis":true,"version":"0.4.1","uptime":45,"timestamp":"..."}

浏览器打开 https://your-domain.com(加了 Caddy 的话)或 http://你的服务器IP:3000(没加的话),用 ADMIN_EMAIL / ADMIN_PASSWORD 登录管理后台。

加上 Caddy(HTTPS)

前面只起了三个容器,没带 Caddy。要 HTTPS,把 Caddy 作为第四个服务加进来。在 docker-compose.yml 末尾补:

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    depends_on:
      - herald
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config

volumes:
  pgdata:
  caddy-data:
  caddy-config:

再 docker compose up -d caddy。Caddy 第一次启动会向 Let's Encrypt 申请证书。如果域名 DNS 还没生效或 80 端口被防火墙挡了,证书申请会失败,Caddy 会一直重试,日志里能看到 acme: error。

改了 Caddyfile 后不用重启容器:

docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

升级

升级只换镜像,PostgreSQL 和 Redis 不动。中间有几秒服务中断。

# 先备份,迁移不可逆
docker compose exec db pg_dump -U herald herald > backup_$(date +%Y%m%d).sql

# 改 .env 里的 HERALD_IMAGE 到新版本,然后重建 herald 容器
docker compose up -d herald

docker compose up -d herald 会拉新镜像、停掉旧 herald 容器、用新镜像起一个。其他容器不受影响。

出问题就回滚。把 .env 里的 HERALD_IMAGE 改回旧版本 tag,再 docker compose up -d herald。

备份与恢复

App 本身无状态。持久化数据只有 PostgreSQL:

# 备份
docker compose exec db pg_dump -U herald herald > backup.sql

# 恢复
cat backup.sql | docker compose exec -T db psql -U herald herald

Redis 是缓存和会话,丢了不严重,App 会自动重建。Volume pgdata 删容器不会丢数据,但 docker compose down -v 会把 volume 一起删,注意别加 -v。

CI/CD

项目用 GitHub Actions 自动构建镜像,流程在 .github/workflows/cd.yml。

推一个 v 开头的 tag 触发(比如 git tag v0.4.1 && git push origin v0.4.1)。流程做四件事:在 amd64 和 arm64 上分别构建、合并多架构 manifest、推送到 ghcr.io/timzaak/herald:<tag> 和 ghcr.io/timzaak/herald:latest、创建 GitHub Release。

Dockerfile 用五阶段构建(cargo-chef 缓存 Rust 依赖 → 后端 release → 前端 build 含 OpenAPI 生成 → alpine 运行时)。最终镜像非 root 用户运行,内置每 30 秒一次的 /health 健康检查。本地构建:

docker build -f docker/Dockerfile -t herald-app:local .

然后在 .env 里设 HERALD_IMAGE=herald-app:local,用自己构建的镜像。

常见问题

herald 启动后马上退出

看日志:

docker compose logs herald

大概率是密钥没填。生产模式下这几项缺一不可:config.toml 里的 [jwt] secret 和 [custom_domain] ask_key(不能是占位值),.env 里的 ADMIN_PASSWORD、INTERNAL_API_KEY、TOTP_SECRET_KEY。

herald 连不上数据库

日志里看到 connection refused。检查 compose 里 herald 的 depends_on 是否等 db 的 healthcheck 通过了,再确认 config.toml 里的数据库地址是 db:5432 而不是 localhost。容器之间用服务名通信,不是 localhost。

Caddy 证书申请失败

日志里看到 acme: error。检查域名 DNS 是否指向服务器 IP(dig your-domain.com),防火墙是否开放 80 和 443,服务器 80 端口是否被别的进程占了(ss -tlnp | grep :80)。

realm-admin role not found

正常不会出现,迁移在初始化前就跑完了。如果真遇到了,重启 herald 容器,迁移是幂等的:

docker compose restart herald

On this page