AWS EC2 自部署
从 Railway 迁到自管 AWS EC2 —— 单台实例用 docker-compose 跑全栈,Caddy 自动 HTTPS,复用现有 GitHub Actions CI/CD,R2 存储与 Cloudflare Pages 前端保持不变,月成本约等于一台 t3 实例
这份文档回答什么
把 Zapvol 从 Railway 迁到你自己的 AWS 账号,用一台 EC2 + docker-compose 跑全部后端。读完你能动手把它上线,且知道哪些东西 Railway 替你做掉了、迁到裸机后要自己补。
这不是 Cloudflare + Railway 方案 的替代品,而是当你需要数据留在自己的 VPC (Virtual Private Cloud,虚拟私有云) 里、或想摆脱托管平台按用量计费时的路径。前端 (Cloudflare Pages) 与文件存储 (R2) 两个选择保持不变 —— 迁的只是后端计算与数据层。
关键参数
| 项 | 值 |
|---|---|
| 计算 (Compute) | 单台 EC2(起步 t3.large:2 vCPU / 8 GB) |
| 编排 (Orchestration) | docker-compose(server + worker + postgres + redis) |
| TLS 终结 (TLS Termination) | Caddy 容器,自动 Let’s Encrypt 证书 |
| 数据库 (Database) | postgres:16 容器(数据在 EBS 卷) |
| 队列 / 缓存 (Queue / Cache) | redis:8 容器 |
| 文件存储 (File Storage) | Cloudflare R2(不变,零改代码) |
| 前端 (Frontend) | Cloudflare Pages(不变) |
| CI/CD | 现有 .github/workflows/docker.yml(改 secrets 即可) |
| 镜像仓库 (Registry) | ghcr.io(不变) |
| 月度起步成本 | $60–75(一台 t3.large 按需 + EBS) |
为什么是单机 EC2 + compose
你的诉求是省钱 + 易 CI/CD,这个组合是唯一同时命中两条的:
- 省钱:一台 EC2 + 一块 EBS 跑下全部。Postgres 与 Redis 用容器而非 RDS / ElastiCache —— 省掉每月各自几十美元的托管费,也省掉 RDS 强制 TLS、ElastiCache 不支持 Cluster-mode 这些迁移坑。存储继续用 R2(出口流量免费),前端继续白嫖 Cloudflare Pages。
- 易 CI/CD:仓库现有的
docker.yml本来就是「构建镜像 → 推 ghcr.io → SSH 进主机docker compose up」,这套流程对 Railway 和对 EC2 完全一样。迁移在 CI 侧只是把VPS_HOST换成 EC2 地址,工作流文件一行不改。
被淘汰的更重方案:
- ECS Fargate + RDS + ElastiCache:免运维、能弹性,但每月多花 RDS + ElastiCache + ALB 的钱,且要重写部署 (task definition)。规模没起来之前不划算 —— 留作未来的升级路径。
- EKS:为单个应用上 Kubernetes 是纯粹的复杂度赤字。
整体拓扑
一台 EC2 内,四个业务容器 + 一个 Caddy 边车 (Sidecar):
Internet
│ :443 (HTTPS + wss) :80 (ACME + 跳转)
┌──────────▼───────────────────────────────┐ EC2 (安全组只开 22/80/443)
│ caddy ── 自动 Let's Encrypt 证书 │
│ │ 内部网络 reverse_proxy │
│ ▼ │
│ server :8001 ──┐ worker │
│ (HTTP + WS) │ (BullMQ 消费者) │
│ ┌────────┴────────┐ │ │
│ ▼ ▼ ▼ │
│ postgres:16 redis:8 │
│ (EBS: pg_data) (redis_data) │
└────────────────────────────────────────────┘
外部:Cloudflare R2(文件)· Cloudflare Pages(web/marketing)
Anthropic / OpenAI(agent 推理)
server 与 worker 是同一个镜像、不同启动命令;migrate 是一次性容器,启动前跑完 Drizzle 迁移就退出。全部由
docker/docker-compose.yaml 编排 —— 这份文件本来就是为自托管写的。
Railway 替你做了什么,裸机上要自己补
迁到裸 EC2,代码和镜像一行都不用改。真正要补的只有三件平台白送的事:
- TLS 终结 + 域名 —— Railway 自动给你 HTTPS 域名。裸机上
server只吐 8001 明文 HTTP,而 OAuth 回调、better-auth 的 安全 cookie、wss://长连接都要求 HTTPS。补法:docker/docker-compose.prod.yaml叠加一个 Caddy 反向代理 (Reverse Proxy),自动申请并续期 Let’s Encrypt 证书,把 80/443 的流量代理到server:8001。WebSocket 透传无需额外配置。 - 主机初始化 —— 装 Docker、登录 ghcr、放好 compose 与
.env。见下方步骤。 - 数据备份 —— Railway plugin 帮你管 Postgres 备份,裸机要自己做(
pg_dump定时任务打到 R2)。
完整步骤
1. 开 EC2 实例
- AMI:Ubuntu 24.04 LTS 或 Amazon Linux 2023 均可。
- 实例规格:起步
t3.large(2 vCPU / 8 GB)。agent队列并发到 50,长连接多,内存是主要压力项;先 large,观察后再上下调。 - 存储 (EBS):根卷 30 GB 起。Postgres 数据落在 Docker 命名卷
pg_data(默认在根卷上)—— 若数据量会涨,单独挂一块 EBS 给/var/lib/docker/volumes更稳。 - 安全组 (Security Group):只开 22 (SSH,限你的 IP)、80、443。8001 不要对公网开 —— 外部只经 Caddy 的 443 进来。
2. 一键初始化主机
把仓库 clone / scp 到主机后,跑 docker/bootstrap-host.sh。它幂等,一步做完:装 Docker + compose 插件、把当前用户加进
docker 组、建部署目录(默认 ~/zapvol/)、把 docker-compose.yaml / docker-compose.prod.yaml / Caddyfile 平铺过去、从
.env.example 播种一份 .env(已存在则不覆盖),并可选登录 ghcr:
# 基本用法
bash docker/bootstrap-host.sh
# 私有镜像:顺便登录 ghcr(PAT 需勾 read:packages)
GHCR_USER=<你的用户名> GHCR_PAT=<token> bash docker/bootstrap-host.sh
跑完按它打印的下一步走即可。若刚被加进 docker 组,先重登录(或 newgrp docker)再继续。手动等价步骤(get.docker.com
装 Docker → mkdir + cp 平铺文件 → docker login ghcr.io)也可自己跑,脚本只是把它们收敛成一条命令。
3. 填 .env
数据库和 Redis 是同机容器,连接串已由 compose 内部拼好(主机名就是 postgres / redis)—— 你不用手写
DATABASE_URL / REDIS_URL,只要设好密码。关键项:
# 让 compose 自动叠加 Caddy 覆盖层,之后 `docker compose up` 无需带 -f
COMPOSE_FILE=docker-compose.yaml:docker-compose.prod.yaml
# Caddy 用它申请证书;必须是解析到本机公网 IP 的域名
SERVER_DOMAIN=api.example.com
BASE_URL=https://api.example.com # 公网 HTTPS 入口(OAuth 回调 + 安全 cookie)
# 数据库密码(务必改强)—— DATABASE_URL 由 compose 用它自动拼成 postgres://…@postgres:5432/…
POSTGRES_PASSWORD=<openssl rand -hex 24>
# 鉴权与 AI(最少这两项,否则 server 起得来但 agent 调用 401)
BETTER_AUTH_SECRET=<openssl rand -hex 32>
ANTHROPIC_API_KEY=<...> # 或 OPENAI_API_KEY / AI_GATEWAY_API_KEY
# 文件存储:保留 R2(零改代码、出口免费)。四项全设或全不设
R2_ACCOUNT_ID=<...>
R2_ACCESS_KEY_ID=<...>
R2_SECRET_ACCESS_KEY=<...>
R2_BUCKET_NAME=zapvol-prod
R2_PUBLIC_URL=https://files.example.com # 可选,用于拼下载 URL
# 可选加固:把 server 只绑到回环,外部一律走 Caddy
SERVER_PORT=127.0.0.1:8001
完整变量清单见仓库根 .env.example 与 apps/server/.env.example。ghcr 登录已由步骤 2 的脚本处理(或没设 GHCR_*
时自己 docker login ghcr.io 一次,凭证存进 ~/.docker/config.json)。
4. DNS
api.example.com 的 A 记录指向 EC2 公网 IP。
若你的 DNS 托管在 Cloudflare:这条记录必须设成 DNS only(灰云),不能开橙云代理。Cloudflare 代理会终止 WebSocket(非付费档),而 Zapvol 重度依赖 WS;且橙云会干扰 Caddy 的 ACME 验证。
5. 首次启动
cd ~/zapvol
docker compose pull # COMPOSE_FILE 已含两份,自动带上 Caddy
docker compose up -d
# 启动顺序:postgres → migrate(跑完迁移退出)→ server / worker + caddy
docker compose logs -f caddy # 看证书签发;首次约几秒到半分钟
curl https://api.example.com/health # → {"ok":true}
6. 前端指向新 API 域名
apps/web 仍在 Cloudflare Pages,无需迁。只把它的 API / WS 地址指到新域名(变量名以 apps/web/.env.example 为准),重新部署一次即可。
接上 CI/CD
现有 .github/workflows/docker.yml 已经能「打 tag → 构建镜像 → 推 ghcr → SSH 部署」。迁到 EC2 只需在
Settings → Secrets and variables → Actions 里改:
| 类型 | 名称 | 值 |
|---|---|---|
| Secret | VPS_HOST | EC2 公网 IP 或域名 |
| Secret | VPS_USER | ubuntu(Ubuntu AMI)或 ec2-user |
| Secret | VPS_SSH_KEY | EC2 密钥对的私钥(PEM 全文) |
| Variable | DEPLOY_ENABLED | true |
| Variable | DEPLOY_PATH | ~/zapvol |
之后一次发布:
git tag v1.2.0 && git push origin v1.2.0
CI 构建镜像推到 ghcr,SSH 进 EC2 跑 docker compose pull && up -d —— migrate 容器自动应用新迁移,server 健康后接流。
从 Railway 迁数据
选流量低谷执行一次:
# 1. 从 Railway 导出(用其提供的连接串)
pg_dump "$RAILWAY_DATABASE_URL" -Fc -f zapvol.dump
# 2. 传到 EC2,灌进容器里的 postgres
# 先确保 compose 已 up(postgres + 迁移已建表)
docker compose cp zapvol.dump postgres:/tmp/zapvol.dump
docker compose exec postgres pg_restore -U zapvol -d zapvol --clean --if-exists /tmp/zapvol.dump
Redis 不用迁 —— 里面只有在途 job,抽干队列后切换即可,最多丢几个排队任务,重新触发即可。R2 若保留则数据原地不动。
备份(裸机必须自己做)
一条 cron 把每日全库快照打到 R2 / S3:
# crontab -e,每天 03:00
0 3 * * * docker compose -f ~/zapvol/docker-compose.yaml exec -T postgres \
pg_dump -U zapvol zapvol | gzip | \
aws s3 cp - s3://zapvol-backups/pg/$(date +\%F).sql.gz
本项目不做什么
- 不做高可用 (HA):单机就是单点。实例挂了服务中断,直到重启或重建。要 HA 请走 ECS/RDS 那条路。
- 不上 Kubernetes:单应用不值得。
- 不做多区域:单区域部署,前端已由 CF 边缘全球覆盖。
- 不把数据库托管出去:Postgres / Redis 都是本机容器 —— 这正是省钱的来源,代价是备份 / 升级 / 扩容要你自己管(见备份小节)。
避坑清单
每条都对应一个真实会踩的故障:
- 域名没先解析就启动 Caddy —— Caddy 申请证书要走 ACME 验证,
SERVER_DOMAIN必须已解析到本机且 80/443 可达,否则拿不到证书、443 起不来。先配 DNS,再up。 - Cloudflare 橙云断 WebSocket ——
api子域必须 DNS only。看到 WS 握手成功却马上断,先查这里。 - 8001 对公网开着 —— 安全组只放 22/80/443;明文 8001 暴露会绕过 TLS。可选
SERVER_PORT=127.0.0.1:8001再加一层。 nodesandbox 在 server 容器内跑任意代码 ——SANDBOX_TYPE=node时,agent 生成的代码和你的 DB/R2 凭据同处一个执行体。自部署到自己 VPC 后这个边界归你负责:要么换daytona/e2b(配好 API key 即可),要么接受并用安全组 + 最小权限收敛爆炸半径。这是裸机相比托管唯一变重的责任。- EBS 没留够 / 数据没落对卷 ——
pg_data、redis_data、uploads_data、caddy_data都是 Docker 命名卷,默认在根卷。根卷满会同时打挂 DB 和证书续期。监控磁盘,或把 volumes 目录挪到独立 EBS。 - ghcr 私有镜像未登录 ——
docker compose pull报denied,先在主机docker login ghcr.io。 - pnpm 版本不一致 —— 根
package.json写pnpm@11.13.0,docker/Dockerfile用10.32.1。镜像内锁死 10.32.1 能正常构建,但改动前留意,别让 lockfile 漂移。 docker compose exec加-T—— 在 cron / CI 这种无 TTY 环境跑 exec 要带-T,否则报 “the input device is not a TTY”。
成本预估
按起步规模(< 100 DAU、单实例):
| 项 | 月成本 |
|---|---|
EC2 t3.large 按需 (On-Demand) | ~$60 |
| EBS 30 GB gp3 | ~$2.5 |
| 出口流量 (Egress)(少量 API/WS) | $1–5 |
| Cloudflare R2(< 10 GB) | $0 |
| Cloudflare Pages(web / marketing) | $0 |
| 合计 | ~$65–70 |
省钱杠杆:
- 买 1 年 Savings Plan / Reserved Instance —— t3.large 直降约 40%。
- 负载不高可降到
t3.medium(4 GB),但盯紧内存(agent-loop 的 GC 压力)。 - 真起量了再迁 ECS Fargate + RDS + ElastiCache —— 那时按 Cloudflare + Railway 方案 里”往上”一节的思路演进。
延伸阅读
- Cloudflare + Railway —— 托管平台方案,与本文对照
- Node 运行时健康 —— 监控 Node 是否在劣化(内存/GC/事件循环/句柄),而不只是「活着吗」
- 可观测栈总览 —— pino → Loki → Grafana 怎么接(EC2 上把
LOG_LOKI_ENABLED=true) - Agent 概览 —— 各服务的技术选型详解