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 推理)

serverworker同一个镜像、不同启动命令;migrate 是一次性容器,启动前跑完 Drizzle 迁移就退出。全部由 docker/docker-compose.yaml 编排 —— 这份文件本来就是为自托管写的。

Railway 替你做了什么,裸机上要自己补

迁到裸 EC2,代码和镜像一行都不用改。真正要补的只有三件平台白送的事:

  1. 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 透传无需额外配置。
  2. 主机初始化 —— 装 Docker、登录 ghcr、放好 compose 与 .env。见下方步骤。
  3. 数据备份 —— 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.exampleapps/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 里改:

类型名称
SecretVPS_HOSTEC2 公网 IP 或域名
SecretVPS_USERubuntu(Ubuntu AMI)或 ec2-user
SecretVPS_SSH_KEYEC2 密钥对的私钥(PEM 全文)
VariableDEPLOY_ENABLEDtrue
VariableDEPLOY_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 再加一层。
  • node sandbox 在 server 容器内跑任意代码 —— SANDBOX_TYPE=node 时,agent 生成的代码和你的 DB/R2 凭据同处一个执行体。自部署到自己 VPC 后这个边界归你负责:要么换 daytona / e2b(配好 API key 即可),要么接受并用安全组 + 最小权限收敛爆炸半径。这是裸机相比托管唯一变重的责任。
  • EBS 没留够 / 数据没落对卷 —— pg_dataredis_datauploads_datacaddy_data 都是 Docker 命名卷,默认在根卷。根卷满会同时打挂 DB 和证书续期。监控磁盘,或把 volumes 目录挪到独立 EBS。
  • ghcr 私有镜像未登录 —— docker compose pulldenied,先在主机 docker login ghcr.io
  • pnpm 版本不一致 —— 根 package.jsonpnpm@11.13.0docker/Dockerfile10.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 方案 里”往上”一节的思路演进。

延伸阅读

这页有帮助吗?