生产部署:Cloudflare + Railway
用 Cloudflare Pages 托管前端、Railway 跑 server + worker + Postgres + Redis 的全套生产部署方案 —— 选型理由、环境变量映射、迁移钩子、避坑清单
这份文档回答什么
读完你能直接动手把 Zapvol 推上生产环境,且知道每个选择背后为什么是这样。不是选型对比文章 —— 选型对比见 架构总览 (Architecture Overview)。
关键参数
| 项 | 值 |
|---|---|
| 前端托管 (Frontend Hosting) | Cloudflare Pages |
| 后端托管 (Backend Hosting) | Railway |
| 数据库 (Database) | Railway Postgres plugin |
| 队列 / 缓存 (Queue / Cache) | Railway Redis plugin |
| 文件存储 (File Storage) | Cloudflare R2 |
| 日志后端 (Log Backend) | Grafana Cloud Loki |
| Server 镜像 | 单 Dockerfile,server / worker 共用 |
| 月度起步成本 | $5–15 |
为什么是 Cloudflare + Railway
经过淘汰:
- Vercel 不适合 server:
@hono/node-ws需要长连接 WebSocket、BullMQ worker 需要常驻进程,Vercel Functions 是无服务器函数 (Serverless Functions) 模型,两者都跑不起来。 - Cloudflare Workers 不适合 server:
@hono/node-server/ioredis/postgres这些 Node 原生包在 Workers 运行时不兼容,要全部重写。 - Fly.io 推 Upstash Redis —— Upstash 在 BullMQ 这种大量阻塞读 (Blocking Read) + 持久 TCP 连接的场景上有连接数与按命令计费的限制,不是好选。
- Railway 给真 Postgres + 真 Redis 容器,server 与 worker 跟数据库走同一个项目内的私网 (Private Network)
*.railway.internal,零出口流量费、低延迟,且支持 monorepo 多 service。
前端为什么不也放 Railway —— Cloudflare Pages 的 CDN 节点比 Railway 多得多,全球边缘 (Global Edge) 命中率更高,且静态资源完全免费。
整体拓扑
四个 Railway service + 两个 CF Pages project:
| Service | 平台 | 角色 | 公网入口 |
|---|---|---|---|
marketing | Cloudflare Pages | 营销站 (Astro) | zapvol.com |
web | Cloudflare Pages | Web 应用 (Vite SPA) | app.zapvol.com |
server | Railway | API + WebSocket (Hono) | api.zapvol.com |
worker | Railway | BullMQ 队列消费者 | 无 |
postgres | Railway plugin | 主库 | 仅私网 |
redis | Railway plugin | BullMQ + 缓存 | 仅私网 |
外部依赖:Anthropic / OpenAI(agent 推理)、Cloudflare R2(文件存储)、Grafana Cloud Loki(日志)。
Server / Worker 共享镜像
部署相关产物都收敛在 docker/ 目录:
docker/
├─ Dockerfile 多阶段构建文件
├─ docker-compose.yaml 自托管部署 / 本地 prod-mirror(默认拉 ghcr 镜像)
├─ docker-compose.build.yaml override —— 让 compose 本地构建而不是拉 ghcr
├─ railway.server.json Railway server service 配置
└─ railway.worker.json Railway worker service 配置
docker/Dockerfile 多阶段构建后产出一个镜像,两个 service 用不同 startCommand 复用:
docker/Dockerfile(构建上下文 = 仓库根)
├─ Stage 1-3: install + build (pnpm workspaces, tsup)
├─ Stage 4: prod-only deps
└─ Stage 5: production image
└─ CMD ["node", "apps/server/dist/index.mjs"] ← 默认跑 server
Railway service "server" → CMD: node apps/server/dist/index.mjs
Railway service "worker" → CMD: node apps/server/dist/worker.mjs
为什么不在 CMD 里同时拉起两个进程:worker 出错导致进程退出,会把 server 也带挂;扩容粒度也变粗。Railway 给两个 service 各自的重启策略 (Restart Policy) 与扩容档位是更好的形态。
两份 Railway 配置:
docker/railway.server.json ← server service 用
docker/railway.worker.json ← worker service 用
server 配置里关键字段:
{
"build": { "builder": "DOCKERFILE", "dockerfilePath": "docker/Dockerfile" },
"deploy": {
"startCommand": "node apps/server/dist/index.mjs",
"preDeployCommand": "node apps/server/dist/db-migrate.mjs",
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 5
}
}
preDeployCommand 在新版本启动前只跑一次,专门执行 Drizzle 数据迁移 (Database
Migration)。worker 配置里没有这一项 —— 双进程同时执行 DDL 会有锁竞争,迁移只在 server 这一侧跑。
本地 prod-mirror(推 Railway 之前先跑一遍)
docker/docker-compose.yaml 复刻 Railway 的服务拓扑(postgres + redis + migrate + server +
worker),是同一份自托管部署用的 compose
—— 不再单独维护一份”测试用”配置。开发分支合并前在本地跑一遍,能提前发现”dev 跑得起来 / 生产构建报错”这类问题。
它默认拉 ghcr.io/zapvol/zapvol-server:${VERSION:-latest} 这个预构建镜像。本地测试当前未推到 ghcr 的代码时,叠加
docker-compose.build.yaml 让 compose 用 docker/Dockerfile 现场构建:
# 在仓库根执行 —— 本地构建版(开发流程)
docker compose -f docker/docker-compose.yaml -f docker/docker-compose.build.yaml up -d --build
# 或者拉 ghcr 镜像(验证已发布版本)
docker compose -f docker/docker-compose.yaml up -d
# 健康检查 (Healthcheck)
curl http://localhost:8001/health # → {"ok":true}
启动顺序由 depends_on 编排:postgres → migrate(一次性,跑 drizzle 迁移后退出) → server / worker。和 Railway 上
preDeployCommand → server start 是同一个语义。
凭证(AI / R2 / OAuth 等)放在仓库根的 .env(已被 gitignore),compose 启动时自动读取。最少要设 BETTER_AUTH_SECRET
与至少一个 AI 提供商 (AI Provider) key —— 否则 server 正常启动但 agent 调用会 401。完整变量清单见 .env.example。
部署时序
下图是一次 git push 之后系统的全过程。CF Pages 与 Railway 是并行触发的(同一个 push
webhook 同时通知到两边),但下图为了方便阅读把它们竖排。
鲁棒性来自三件事:(1) 迁移只在 server 一处跑、且失败即整次部署回滚;(2) server 通过 /health
健康检查 (Healthcheck) 才切流;(3) worker 只在 server 部署成功之后才 replace。
完整部署步骤
第一次接入
1. 创建 Railway 项目
# 在 Railway dashboard 操作:
# 1. New Project → Deploy from GitHub repo → 选 zapvol
# 2. 进入 project → Add Service → Database → PostgreSQL(plugin)
# 3. 再 Add Service → Database → Redis(plugin)
2. 配置 server service
GitHub repo 已经被关联进项目,Railway 会自动识别 Dockerfile。要手动调整:
- Settings → Source → Config-as-code Path: 填
docker/railway.server.json - Settings → Source → Watch Paths:
apps/server/**、packages/backend/**、packages/common/**、Dockerfile、pnpm-lock.yaml - Settings → Networking → Public Networking: 启用,绑定
api.zapvol.com
3. 复制 server 为 worker service
Railway 在同一 project 内可以从已有 service “Duplicate”,避免 GitHub 接入步骤再走一遍:
- Add Service → From existing service → server
- 复制后改 Config-as-code Path 为
docker/railway.worker.json - Networking 不开 —— worker 不要公网入口
4. 环境变量
每个 service 在 Variables 标签里设。Railway 用 ${{ServiceName.VAR}} 模板做跨 service 引用:
server 必需:
NODE_ENV=production
PORT=8001
# 私网连接串 —— 用 PRIVATE 版本,免出口流量
DATABASE_URL=${{Postgres.DATABASE_PRIVATE_URL}}
REDIS_URL=${{Redis.REDIS_PRIVATE_URL}}
# better-auth
BETTER_AUTH_URL=https://api.zapvol.com
BETTER_AUTH_SECRET=<openssl rand -hex 32>
BETTER_AUTH_COOKIE_DOMAIN=.zapvol.com # 让 web 子域共享会话
# CORS —— 列出 web 与 marketing 的所有公网入口
CORS_ORIGINS=https://app.zapvol.com,https://zapvol.com
# 模型供应商 (Model Providers)
ANTHROPIC_API_KEY=<...>
OPENAI_API_KEY=<...>
# 文件存储 (File Storage) —— R2
R2_ACCESS_KEY_ID=<...>
R2_SECRET_ACCESS_KEY=<...>
R2_BUCKET=zapvol-prod
R2_ENDPOINT=https://<account>.r2.cloudflarestorage.com
# 日志 (Logs) —— Grafana Cloud
LOKI_URL=https://logs-prod-xxx.grafana.net
LOKI_USERNAME=<...>
LOKI_PASSWORD=<...>
worker 基本同上,去掉 PORT / BETTER_AUTH_* /
CORS_ORIGINS(worker 不开 HTTP 端点)。其余 DB、Redis、AI、R2、Loki 全部需要。
5. CF Pages
两个 project,分别从 GitHub 接入:
| CF Pages project | Repo path | Build command | Output dir | 自定义域名 |
|---|---|---|---|---|
zapvol-marketing | apps/marketing | pnpm install && pnpm --filter=marketing build | apps/marketing/dist | zapvol.com |
zapvol-web | apps/web | pnpm install && pnpm --filter=web build | apps/web/dist | app.zapvol.com |
Web SPA 必须在 apps/web/public/_redirects 里加 SPA fallback:
/* /index.html 200
否则刷新非根路径 (Non-root Path) 直接 404。
环境变量(只 web 需要):
VITE_API_BASE_URL=https://api.zapvol.com
VITE_WS_URL=wss://api.zapvol.com/ws
6. DNS
在 Cloudflare 域名管理里:
zapvol.com → CNAME zapvol-marketing.pages.dev (proxied)
app.zapvol.com → CNAME zapvol-web.pages.dev (proxied)
api.zapvol.com → CNAME <railway-server>.up.railway.app (DNS only ⚠️)
api.zapvol.com 必须设 “DNS only” 不能 proxy ——
Cloudflare 代理会终止 WebSocket(除非用付费档),而 server 重度依赖 WS。绕过 CF 代理直接打 Railway 边缘 (Edge),连接稳定性更好。
后续推送
git push origin main 触发整个流水线。CF Pages 与 Railway 都是自动部署 (Auto-deploy),不需要任何手工操作。
回滚:Railway → service → Deployments → 选上一版 → Redeploy。一键。CF Pages 同理。
本项目不做什么
- 不做手工 CI/CD:GitHub Actions、ArgoCD、Jenkins 都不需要。CF + Railway 自带。
- 不做容器编排:不上 Kubernetes,不用 docker-compose 跑生产。流量到几十万 QPS 之前,Railway 的 service 模型够用。
- 不做多区域 (Multi-region):server 是单区域 (Single-region) 部署。前端走 CF 边缘已经覆盖全球,后端跨区域复制带来的复杂度(数据一致性、写路由)远大于此阶段的收益。
- 不做无服务器 (Serverless) 化:见前文”为什么不是 Vercel / CF Workers”。
- 不在 production 容器里放 Postgres / Redis:用 Railway plugin。自管数据库的备份、版本升级、磁盘扩容、HA 不在我们当前精力范围内。
避坑清单
下面每条都对应一个真实可发生的故障:
- CF 代理 WebSocket —— 上面已说,
api.zapvol.com必须 DNS only。如果某天你看到 WS 莫名 30 秒后断、或wss://握手 200 但马上断,先查 CF proxy 状态。 - Postgres 公网 vs 私网 ——
DATABASE_URL默认是公网 URL,会走外网且记入出口流量。改用DATABASE_PRIVATE_URL才走*.railway.internal私网。 - preDeployCommand 写到 worker 上 —— worker 不能跑迁移。两份
railway.*.json不要写串了。 /health经过完整 middleware 栈 —— 当前实现走pino-logger+cors+requestContext,每次健康检查都会写一行日志。Loki cardinality 没问题,但日志会有点吵。如果在意,把/health注册到这些 middleware 之前。- Drizzle migrations 文件夹没拷进镜像 ——
DockerfileStage 5 必须显式COPY --from=build /app/apps/server/drizzle/ apps/server/drizzle/。否则db-migrate启动后会找不到迁移文件,预钩子失败、整次部署回滚。 - better-auth cookie domain —— 如果
BETTER_AUTH_COOKIE_DOMAIN没设成.zapvol.com,web (app.zapvol.com) 拿不到 server (api.zapvol.com) 写的会话 cookie,登录态丢失。 - CORS_ORIGINS 漏掉
marketing—— 如果 marketing 站上有”立即体验”按钮直接打api.zapvol.com,就需要https://zapvol.com也在白名单里。漏了浏览器报 CORS 错。 - Watch Paths 太宽 —— 默认 Railway 监听整个 repo,改一行 marketing 也会触发 server 重建,浪费构建额度。把 Watch
Paths 收窄到
apps/server/**等四项。 - Healthcheck timeout —— 默认 30 秒。冷启动 (Cold Start) 时如果
initToolRegistry()加载了大 skill 包可能超时,把healthcheckTimeout调到 60 秒。 db-migrate只跑首次 push 的迁移 —— 这是个无状态进程,每次部署都从头扫 drizzle 文件夹、对比__drizzle_migrations表,跳过已应用的,安全可重入 (Idempotent)。
成本预估
按当前规模(< 100 DAU、单实例 server / worker、单 Postgres / Redis):
| 项 | 月成本 |
|---|---|
| Railway Hobby 计划 (含 $5 用量额度) | $5 |
| Railway 实际用量(server + worker + DB + Redis 各 256MB-1GB) | $0–10 |
| Cloudflare Pages(marketing + web) | $0 |
| Cloudflare R2(< 10GB 存储 + < 1M ops) | $0 |
| Grafana Cloud(free tier) | $0 |
| 合计 | $5–15 |
往上:
- 每加一个 server 副本 (Replica) ≈ +$3–5/月
- Postgres 升到 4GB RAM ≈ +$15/月
- 流量真的起来了再考虑迁去 ECS / Kubernetes,那时月成本几百起步
延伸阅读
- 可观测栈总览 —— pino → Alloy → Loki → Grafana 怎么接
- 本地 dev 接入 Grafana Cloud —— 让本地日志直接进生产仪表盘
- 可观测面板 —— 上线后看哪些指标
- 架构总览 (Architecture Overview) —— 各 service 的技术选型详解