rsync --exclude 救不了服务器上的 .env.local
用 GitHub Actions 把 Next.js 站点部署到 VPS,每次发布后站点启动失败——日志里清一色是 Missing environment variable。明明 rsync 已经加了 --exclude='.env.local',为什么服务器上的配置文件还是消失了?
这是 rsync 一个极易踩中的语义陷阱,叠加 Next.js output: 'standalone' 的特殊目录布局,以及宝塔面板自带的不可变文件,三坑同时落地。
现象
- 每次 CI 部署后,服务器
.env.local消失,应用启动时环境变量为空,功能异常 - rsync 偶发
Permission denied,整个传输中断 - 页面 HTML 更新了,但 JS/CSS 资源 404
- PM2 重载报
Cannot find module './server.js'
根因
坑一:--exclude 是源端过滤,不是目标端保护
这是最隐蔽的一坑。rsync --exclude='.env.local' 的语义是:不把源端的 .env.local 发送过去。但配合 --delete 时,rsync 会把目标端有、源端没有的文件全部删掉。.env.local 在源端本来就不存在(CI 环境不提交敏感文件),所以目标端的 .env.local 被 --delete 清掉了,--exclude 完全没有保护它。
--exclude 告诉 rsync"忽略源端这个路径",但对目标端没有任何保护承诺。只要目标端有、源端没有,--delete 就会删。要保护目标端已有的文件,必须用 --filter='protect <path>'(过滤规则 P,纯目标端操作)。
坑二:Next.js standalone 布局不完整
output: 'standalone' 构建后,可运行产物在 .next/standalone/,但 .next/static/(JS/CSS chunks)和 public/(静态资源)不会被自动复制进去。直接 rsync 这个目录,静态资源 404。
坑三:宝塔 .user.ini 是不可变文件
宝塔面板会对站点目录下的 .user.ini 执行 chattr +i(操作系统级不可变),任何写入都会被内核拒绝,导致 rsync 整体失败。
正解
构建阶段:手动把静态资源拼进 standalone 目录
pnpm build
mkdir -p deploy
cp -r .next/standalone/. deploy/
cp -r .next/static deploy/.next/static
cp -r public deploy/public传输阶段:用 --filter='protect' 替代 --exclude,同时排除宝塔不可变文件
- name: Deploy via rsync
run: |
rsync -azP --delete \
--filter='protect .env.local' \
--filter='protect data/' \
--filter='protect .user.ini' \
--filter='protect .htaccess' \
--filter='protect proxy_cache_dir/' \
-e "ssh -i ~/.ssh/deploy_key" \
deploy/ root@${{ secrets.DEPLOY_HOST }}:/www/wwwroot/app/启动阶段:PM2 幂等重载 + 健康检查兜底
- name: PM2 reload + health check
uses: appleboy/ssh-action@v1.2.2
with:
script: |
cd /www/wwwroot/app
pm2 reload ecosystem.config.js || pm2 start ecosystem.config.js
sleep 5
curl -sf http://127.0.0.1:3456/api/health || exit 1--filter='protect <path>' 是目标端操作:rsync 在生成删除列表时会跳过匹配项,无论源端是否存在该文件,目标端都不会被动。用它替代 --exclude 来保护服务器上的运行时配置、持久化目录和宿主机锁定文件。
- 保护目标端文件 →
--filter='protect <path>' - Next.js standalone → 构建后手动
cp .next/static和public - 宝塔
.user.ini/.htaccess→ 加进 protect 列表,不要尝试覆写
一句话外卖
rsync
--exclude只管"别从我这里发",--delete不管你加没加 exclude 照样清理目标端——要留住服务器的文件,用--filter='protect'。