生产部署: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平台角色公网入口
marketingCloudflare Pages营销站 (Astro)zapvol.com
webCloudflare PagesWeb 应用 (Vite SPA)app.zapvol.com
serverRailwayAPI + WebSocket (Hono)api.zapvol.com
workerRailwayBullMQ 队列消费者
postgresRailway plugin主库仅私网
redisRailway pluginBullMQ + 缓存仅私网

外部依赖: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 同时通知到两边),但下图为了方便阅读把它们竖排。

sequenceDiagram actor Dev as Developer participant GH as GitHub participant CF as Cloudflare Pages participant RW as Railway Build participant Mig as preDeploy (migrate) participant Srv as server service participant Wrk as worker service Dev->>GH: git push origin main GH-->>CF: webhook (marketing / web) GH-->>RW: webhook (server / worker) rect rgba(96, 165, 250, 0.18) Note over CF,CF: ① CF Pages 构建 CF->>CF: pnpm build (Vite / Astro) CF-->>Dev: edge 部署完成 end rect rgba(251, 191, 36, 0.18) Note over RW,Wrk: ② Railway 流水线 RW->>RW: docker build (单镜像) RW->>Mig: preDeployCommand (server only) Mig->>Mig: drizzle migrate Mig-->>RW: exit 0 RW->>Srv: start (rolling) Srv-->>RW: /health 200 RW->>Srv: 切流到新版本 RW->>Wrk: start (replace) end Note over Dev,Wrk: 任一步失败 (T) Railway 自动回滚到上一版镜像

鲁棒性来自三件事:(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/**Dockerfilepnpm-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 Pathdocker/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 projectRepo pathBuild commandOutput dir自定义域名
zapvol-marketingapps/marketingpnpm install && pnpm --filter=marketing buildapps/marketing/distzapvol.com
zapvol-webapps/webpnpm install && pnpm --filter=web buildapps/web/distapp.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 文件夹没拷进镜像 —— Dockerfile Stage 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,那时月成本几百起步

延伸阅读

这页有帮助吗?