9 月 29 到 30 号,两天里二十一个提交,把 nav-site 从 Vercel + Neon 搬到一台新加坡的 VPS 上:Cloudflare Tunnel 进来,源站不开任何公网端口,postgres 只绑 127.0.0.1:5432,应用跑在 Docker Compose 里,构建也在服务器上用专用 buildx 构建器做。
迁移最后是成功的。但前面那些提交几乎全在补同一类东西:平台以前替我做的假设。这篇按发现顺序记,不按提交顺序。
一跳就跳到 0.0.0.0:3000
切生产前在 preview 实测,/r/<渠道码> 的 302 落点是 https://0.0.0.0:3000/,Google 登录成功和出错的跳回也一样。投放入口如果在切生产当天才发现,就是全站短链一起失效。
根因在 next-server.js 的 attachRequestMeta:standalone 模式下 next-server 持有监听的 hostname/port,构造请求 URL 时优先用它们,experimental.trustHostHeader 那条分支走不到。Vercel 上平台会自动信任 Host(config.js 在 hasNextSupport 时打开),所以迁移前从未暴露。实测 Host、X-Forwarded-Host 都不影响 req.url,只有协议跟随 X-Forwarded-Proto。
修法是新加 lib/public-origin.ts:取 Host 头(源站只经隧道可达,Host 由 Cloudflare 按访客请求的域名写入),协议只认 https 的 X-Forwarded-Proto。刻意不信任 X-Forwarded-Host——访客可以自带,Cloudflare 原样透传,信它就是开放重定向。
顺带把 openapi.json 和 llms.txt 也换掉了:它们原来优先读 XFH 拼站点根,自托管后可伪造,而接口在边缘缓存一小时,等于缓存投毒。
同一提交,prod 和 preview 抢一个镜像标签
两边都构建 nav-site:<sha>。但同一个提交在两边的产物并不相同:构建期连的库、预渲染数据、NEXT_PUBLIC_* 都可能不一样。后构建的会把标签抢走——实测生产后构建,preview 容器的镜像直接变成无名的 sha256:…;反过来,生产重启或回滚会跑到带 preview 数据的构建上。
改成 nav-site:<env>-<sha>,构建、发布、回滚、清理全部带环境前缀。镜像身份不能只靠 commit,产物不同的东西就不该共用一个名字。
secret 不进缓存键
VPS 上第一次经 CI 发布时,两次提交之间只改了 deploy/ 脚本(不进构建上下文),整条链路命中缓存,5 秒完成。缓存本身没问题,但暴露了一个更安静的隐患:构建期环境变量经 BuildKit secret 注入,secret 的内容不参与缓存键。只改 env、代码没变时,pnpm build 会命中旧缓存,新的 NEXT_PUBLIC_* 静默不生效。
修法是 deploy.sh 把构建 env 文件的 sha256 前 16 位当 build arg 传进去,Dockerfile 里 echo 一下。env 一变缓存即失效。
EXIT trap 引用局部变量,成功的发布退出 1
cmd_build_deploy / cmd_migrate 的 EXIT trap 用了函数局部变量。函数正常返回后脚本退出时 trap 才触发,set -u 下报「未绑定」,把成功的发布变成退出码 1——CI 会把每次成功都标红。清理完成后 trap - EXIT 撤掉。
pg-verify 把「空对空」判成一致
第一次对 Neon 跑导入时,源库因为证书参数连接失败。pg-verify 却输出了「✓ 序列值一致」——两边都是空结果,表循环为空也没有任何 ✗。脚本最后虽然因为 set -e 非零退出,但没有任何明确结论,切换当晚很容易被读成通过。
现在先确认两端都连得上、源库 public 下确有表,否则立即 exit 2。另外记一条:libpq 用 sslmode=verify-full 必须加 sslrootcert=system;Node 的 pg 驱动不认这个参数,给应用用的串只写 sslmode=verify-full。
公开桶里的备份
原本建议新建私有备份桶,后来决定直接用主桶 nav-bucket——它挂着公开的 CDN 域名,公开可读,对象键又带时间戳可猜。库里有用户邮箱、密码哈希、合作方密钥,明文放上去等于公开。
于是上传前一律 age 加密,BACKUP_AGE_RECIPIENT 为空时直接拒绝上传(fail-closed),密文放随机后缀前缀。解密私钥在 /opt/nav-site/env/backup-age.key,机器之外必须另存一份:机器整个没了的时候,R2 上的备份只能靠它解开。
把一把密钥拆成三把
CHANNEL_REPORT_SECRET、USER_SESSION_SECRET、ADMIN_SESSION_SECRET、CRON_SECRET 以前都回退到同一个值,外放链接的有效性因此和一把名字上看不出关联的密钥绑在一起。迁移时拆开,deploy.sh 在任一缺失时拒绝发布。
其中 CHANNEL_REPORT_SECRET 一换,所有已经发给合作方的外放链接和二维码全部失效,必须在服务器之外另存;另外几把可以随时换,代价只是正在登录的人失败一次。能随时换和不能随时换的密钥,不能长成同一个样子。
还有几条不起眼但会咬人的
- 这台机器的 fail2ban 是 3 次认证失败封 24 小时,连不上时先查原因,别试用户名。
- sshd 禁了端口转发,所以迁移、跑脚本、查库全在服务器上做,没有 SSH 隧道这条退路。
- 迁移器没有记录表,每次按文件名全量重跑,迁移文件必须幂等。
- 发布期间正常约 7 秒、请求零失败,起不来的坏版本会自动撤掉;但 Docker 的服务名解析不看健康状态,能监听端口却返回错误的版本,在最长 30 秒的健康检查窗口里会分到约一半流量。这条边界写进了手册,没有假装它不存在。
- 备份每季度至少演练一次恢复——没恢复过的备份不算备份。
搬完之后的默认值
Vercel 替你信任 Host,替你给每次构建一个独立身份,替你保证备份桶是私有的,替你在空结果时说一句「一致」。自托管不是把这些东西弄没了,是把它们从平台的默认值改成自己的默认值:一个 publicOrigin、一个 <env>-<sha> 标签、一个 sha256 build arg、两条前置检查、一个 age 公钥。
迁移清单里真正重要的从来不是命令,是这些假设有没有被一条条接住。
