一句话摘要
这篇文章复盘了我把一个 Vue + Vite + PWA 项目从手动上传改造成 GitHub Actions 自动发布的全过程。真正有价值的部分,不只是部署自动化本身,而是如何把发布链路变成一个可重复、可验证、可排查的流程。
目录
背景与问题
这次折腾 CI/CD,起点其实很简单:我不想每次改完网站后,还要一遍遍手动上传。
原来的流程是:
- 本地改完代码。
- 手动上传到 GitHub。
- 再手动上传到腾讯云。
- 再手动刷新网站。
如果只是偶尔改一次,这样做还能忍。但一旦进入频繁迭代阶段,这套流程会越来越烦,而且非常容易出错。
所以这次的目标很明确:
以后只要 git push,网站就自动检查、自动打包、自动上传、自动切到新版本。
本章小结
这次改造主要是为了减少手动发布带来的重复劳动。
最终方案
最终跑通的链路是这样的:
- 本地改代码,提交到 GitHub。
- GitHub Actions 自动执行安装依赖、类型检查、测试和打包。
- GitHub Actions 通过 SSH 把打包结果传到 Lighthouse。
- 服务器执行发布脚本,把文件解压到新的版本目录。
- 服务器把
current指向这个新版本。 - OpenResty 继续对外提供网站。
对应到仓库里,关键文件有两个:
- yml工作流文件
- OpenResty 配置
简单说,这套方案的核心思想就是:
GitHub 负责“打包和上传”,服务器负责“接收和切换版本”。
我的线上环境是:
- 腾讯云 Lighthouse
- 1Panel
- OpenResty
- Vue + Vite + PWA 前端项目
这意味着我不是纯静态托管平台,也不是 Docker 编排方案。我已经有一台稳定运行的网站服务器,所以最合适的方式不是重新迁移平台,而是保留 Lighthouse + 1Panel + OpenResty,只把“手动发布”改成“自动发布”。
本章小结
这次方案的关键不是引入更多基础设施,而是在现有环境上补齐自动化发布能力,让 GitHub Actions 和服务器各自负责清晰的一段。
实践过程
步骤 1:调整服务器目录结构
以前网站文件直接放在一个固定目录里,比如:
/opt/1panel/www/sites/calorie/index为了支持自动发布和多版本切换,我把目录改成了这种结构:
/opt/1panel/www/sites/calorie/ index releases/ first/ 1bc469d390d5d8f2094763ce9273f05aef7e1d59/ current -> releases/1bc469d390d5d8f2094763ce9273f05aef7e1d59这里最关键的是两个概念:
releases/:保存每次发布后的版本目录。current:当前线上正在使用的版本。
这样以后每次发布,都只是新建一个版本目录,把新文件解压进去,再把 current 切过去。
步骤 2:让 OpenResty 读取 current
服务器这边不是系统自带 Nginx,而是 1Panel 管理的 OpenResty。
最后生效的思路是:
- 宿主机路径用
/opt/1panel/...。 - OpenResty 配置里的路径用
/www/sites/...。
OpenResty 配置里核心是这两句:
root /www/sites/calorie/current;index index.html;此外,Vue Router 我使用的history路由模式,所以必须加上:
location / { try_files $uri $uri/ /index.html;}否则像 /history、/profile 这种页面,直接刷新就会 404。
步骤 3:编写服务器发布脚本
服务器上放了一个简单脚本,用来完成“解压新版本 + 切换 current”。
最终版本是:
#!/usr/bin/env bashset -e
APP_DIR="/opt/1panel/www/sites/calorie"ARCHIVE_PATH="$1"VERSION_NAME="$2"
RELEASE_DIR="$APP_DIR/releases/$VERSION_NAME"
mkdir -p "$RELEASE_DIR"tar -xzf "$ARCHIVE_PATH" -C "$RELEASE_DIR"ln -sfn "releases/$VERSION_NAME" "$APP_DIR/current"rm -f "$ARCHIVE_PATH"
cd "$APP_DIR/releases"ls -1dt */ | tail -n +6 | xargs -r rm -rfIMPORTANT这里最关键的一行是
ln -sfn "releases/$VERSION_NAME" "$APP_DIR/current"。它必须使用相对路径软链接,不能写成指向宿主机绝对路径的软链接。
步骤 4:生成 GitHub 用的 SSH 密钥
要让 GitHub 自动上传文件到 Lighthouse,就得让 GitHub 能通过 SSH 登录服务器。
流程是:
- 本地生成一对专门用于部署的密钥。
- 把公钥放到服务器的
authorized_keys。 - 把私钥放到 GitHub Secrets。
我在本地测试过:
ssh -i "$env:USERPROFILE\.ssh\calorie_lighthouse" root@服务器ip地址能正常登录,就说明这一步成功。
步骤 5:配置 GitHub Secrets
在仓库的设置里添加这 4 个 Secrets:
LIGHTHOUSE_HOST:服务器的 IP 地址LIGHTHOUSE_PORT:服务器的 SSH 端口LIGHTHOUSE_USER:SSH 登录用户名LIGHTHOUSE_SSH_KEY:生成的私钥私钥内容
小提醒
- GitHub 里放的是私钥文本。
- 服务器里放的是公钥。
- 不要把
.pub公钥文件填进 GitHub。
步骤 6:添加 GitHub Actions 工作流
工作流文件位置:
.github/workflows/deploy.yml它做的事情是:
- 拉代码。
- 安装依赖。
- 跑
pnpm type-check。 - 跑
pnpm test。 - 跑
pnpm build。 - 把
dist同步到服务器的新版本目录。 - 把
current切到新版本。
一开始我用的是 tar -czf dist.tar.gz -C dist .,也就是先压缩,再上传,再解压。
这个方案很直观,但如果站点里有大量图片、音频这类已经压缩过的静态资源,tar.gz 的收益会很小。比如一个 699MB 的 dist,压缩后可能仍然有 680MB 以上。
后面我把上传方式改成了 rsync 增量同步。核心命令是:
- name: Sync dist to server run: | rsync -az --delete \ --link-dest=/opt/1panel/www/sites/calorie/current \ -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.LIGHTHOUSE_PORT }} -o ServerAliveInterval=30 -o ServerAliveCountMax=20" \ dist/ \ "${{ secrets.LIGHTHOUSE_USER }}@${{ secrets.LIGHTHOUSE_HOST }}:/opt/1panel/www/sites/calorie/releases/${{ github.sha }}/"这里的关键不是 rsync 本身,而是 --link-dest。
它会拿新版本目录和当前线上版本 current 做比较:
- 没变化的文件:直接硬链接上一版文件,不重新上传。
- 有变化的文件:才从 GitHub Actions 传到服务器。
- 服务器多出来的旧文件:通过
--delete清理掉。
同步完成后,再执行:
- name: Activate release run: | ssh -i ~/.ssh/deploy_key \ -p "${{ secrets.LIGHTHOUSE_PORT }}" \ "${{ secrets.LIGHTHOUSE_USER }}@${{ secrets.LIGHTHOUSE_HOST }}" \ "cd /opt/1panel/www/sites/calorie && test -f releases/${{ github.sha }}/index.html && ln -sfn releases/${{ github.sha }} current && cd releases && ls -1dt */ | tail -n +6 | xargs -r rm -rf"这里先检查新版本目录里是否存在 index.html,再切换 current。这样即使同步中途失败,线上仍然停留在旧版本,不会切到半成品目录。
本章小结
整套实践过程可以拆成三层:GitHub Actions 负责构建和增量同步,服务器负责版本目录和软链接切换,OpenResty 负责读取当前版本并提供访问。
踩坑复盘
这部分是整次搭建里最有价值的部分。
坑 1:pnpm/action-setup 版本冲突
第一次跑 GitHub Actions 时,卡在了:
Error: Multiple versions of pnpm specified原因是 package.json 里已经有:
"packageManager": "pnpm@10.28.0"但工作流里又写了:
with: version: 10这相当于在同一个地方声明了两份版本信息。
最后修法很简单:删除工作流里的 version: 10,保留 package.json 里的版本作为唯一来源。
坑 2:Lighthouse 上没有 nginx 命令
一开始我想直接执行:
sudo nginx -tsudo systemctl reload nginx结果报错:
sudo: nginx: command not foundFailed to reload nginx.service: Unit nginx.service not found.这并不是配置错了,而是因为这台服务器不是系统直接装的 Nginx,而是 1Panel 管理的 OpenResty。
所以后面真正生效的操作方式不是 systemctl reload nginx,而是:
- 通过 1Panel 修改站点配置。
- 在 1Panel 里点击“重载”。
坑 3:宿主机路径和 OpenResty 容器路径不是一回事
这是本次最大的坑。
我一开始以为网站真实目录是:
/opt/1panel/www/sites/calorie/所以 OpenResty 配置里也写成了:
root /opt/1panel/www/sites/calorie/current;结果网站一直 500。
后面才发现:
- 在服务器 shell 里看到的是宿主机路径:
/opt/1panel/...。 - OpenResty 实际工作时看到的是容器里的路径:
/www/sites/...。
所以正确做法是:
- shell 命令里用
/opt/1panel/...。 - OpenResty 配置里用
/www/sites/...。
这类容器映射问题,如果不搞清楚,很容易一直以为“目录都在,为什么网站还是坏的”。
坑 4:current 软链接用了绝对路径,导致 500
这次最关键的根因是:一开始我让 current 指向了宿主机绝对路径:
current -> /opt/1panel/www/sites/calorie/releases/xxx看起来没问题,但 OpenResty 读这个链接时会顺着跳到 /opt/1panel/...,而对它来说那个路径并不存在。
于是它不断 fallback 到 /index.html,最后在日志里出现:
rewrite or internal redirection cycle while internally redirecting to "/index.html"最后修法是把 current 改成相对路径软链接:
current -> releases/1bc469d390d5d8f2094763ce9273f05aef7e1d59而不是:
current -> /opt/1panel/www/sites/calorie/releases/...改完后,网站立刻恢复正常。
坑 5:浏览器显示旧页面,不代表服务器正常
这是一个很迷惑人的现象。
GitHub Actions 明明已经成功了,服务器里的 current 也已经切到了新版本,但浏览器里看起来网站“完全没变”。
最后排查发现:
- 服务器真实响应其实已经 500 了。
- 浏览器里显示的是旧的 PWA 缓存页面。
因为这个项目开了 PWA:
这意味着电脑和手机都可能继续显示旧缓存。哪怕服务器已经挂了,用户表面上也可能还能打开旧页面。
WARNING排查线上问题时,应该先看服务器真实返回,再看浏览器表现。浏览器页面能打开,不等于服务器当前状态一定正常。
像这次,如果先执行:
curl -I https://your-domain.com就能很快发现服务器返回的是 500,而不是继续围着“为什么页面没更新”打转。
本章小结
这次踩坑集中在三类问题上:工具链配置重复、服务环境与预期不一致、浏览器缓存掩盖真实状态。真正节省时间的不是反复试,而是尽快确认每一层的真实状态。
增量部署的实现
增量部署这件事,表面上看是“少传点文件”,但真正要解决的是两个问题:
- 怎么判断哪些文件没变。
- 怎么保证同步失败时不影响线上版本。
最终我采用的是 rsync + --link-dest + current 软链接 这套组合。
为什么不是继续用压缩包
压缩包方案的链路是:
dist -> dist.tar.gz -> scp 到服务器 -> tar 解压 -> 切 current它的优点是简单,所有文件都在一个包里,服务器只要解压就行。
但它有一个明显缺点:每次都是全量上传。
如果项目产物主要是 HTML、CSS、JS,tar.gz 的压缩收益通常还不错。但如果项目里有大量 JPG、PNG、MP3 这类文件,它们本身已经压缩过了,再套一层 gzip 基本省不了多少体积。
所以当 dist 里有几百 MB 的相册图片时,压缩包部署就会变成:
每次 push,都重新上传几百 MB。这会让 GitHub Actions 到服务器之间的网络抖动被无限放大。上传一旦中断,服务器上还可能留下一个不完整的 .tar.gz。
rsync --link-dest 怎么做到增量
rsync 本身可以比较源目录和目标目录,只传变化部分。
但如果每次发布都同步到一个全新的 commit 目录,比如:
releases/e25e77d64d7eaac0dc81134f502456acade290de/这个目录一开始是空的,rsync 仍然会认为所有文件都需要传一遍。
真正让它变成增量的是 --link-dest:
rsync -az --delete \ --link-dest=/opt/1panel/www/sites/calorie/current \ dist/ \ root@服务器:/opt/1panel/www/sites/calorie/releases/<commit-sha>/这行命令的意思是:
生成一个新的 release 目录,但如果某个文件和
current里的文件完全一样,就不要重新上传,而是在新 release 里创建一个指向旧文件 inode 的硬链接。
这样看起来每个 release 都是完整目录:
releases/ old-sha/ new-sha/current -> releases/new-sha但磁盘和网络层面并不是每次都复制一整份。没变的大图、音频、字体文件,会直接复用上一版。
为什么还要保留 current
current 不只是给 OpenResty 读取当前版本,它还是增量部署的参照物。
部署时:
rsync参考current,生成新的 release 目录。- 同步完成后,检查新目录里是否有
index.html。 - 检查通过后,才执行
ln -sfn releases/<commit-sha> current。 - OpenResty 继续读取
/www/sites/calorie/current。
这个顺序很重要。
如果同步失败,current 还指向旧版本,线上不会受影响。只有新版本完整同步成功后,才会切换流量。
和压缩包部署相比,它解决了什么
增量部署解决了三个实际问题。
第一,上传量变小。
文章、样式、小脚本改动时,只需要传少量变化文件。几百 MB 的相册图片不会每次重新上传。
第二,失败影响更小。
同步失败时,新 release 最多是半成品,但 current 没切过去,线上仍然是旧版本。
第三,回滚方式不变。
因为最终结构仍然是:
current -> releases/<version>所以回滚还是同一套命令:
ln -sfn releases/<old-version> current增量部署只是改变“新 release 怎么生成”,没有改变“线上版本怎么切换”。
本章小结
增量部署的核心不是简单地把 scp 换成 rsync,而是让 rsync 通过 --link-dest=current 复用上一版文件,再用 current 软链接保证发布切换的原子性。这样既减少传输量,也保留了多版本发布和快速回滚的能力。
跑通后的完整链路
到最后,整套发布链路是这样工作的:
- 本地改代码。
- 提交到
main。 - GitHub Actions 自动运行。
- 通过检查、测试、构建后,把
dist/增量同步到 Lighthouse 的新 release 目录。 - 服务器确认新 release 存在
index.html,再把current切到新版本。 - OpenResty 继续从
/www/sites/calorie/current对外提供网站。 - 网站更新完成。
这次成功的证据是:
- GitHub Actions 成功。
deploy.sh存在且可执行。current指向最新提交目录。- 网站恢复正常访问。
从这次经历里,我最终总结出几个经验。
第一,先跑通最短链路,不要一开始追求完美。
一开始我其实想做得很“高级”:版本目录、自动切换、可回滚、兼容 1Panel 都要一起上。结果越是这样,越容易踩环境差异坑。
更稳的做法是:
- 先做最短可用链路。
- 每一步都单独验证。
- 哪一步坏了就只查那一步。
第二,服务器“看到目录”不等于服务“能读取目录”。
宿主机上执行:
ls -l /opt/1panel/www/sites/calorie看到一切正常,不代表 OpenResty 真能按同样路径读取。
以后碰到 1Panel、Docker、容器环境时,第一反应应该是:
我在 shell 里看到的路径,和服务进程看到的路径,是不是同一个?
第三,浏览器看到的页面,不一定是真实线上状态。
PWA、Service Worker、浏览器缓存都会让你误判。
以后如果怀疑“网站没更新”,应该先执行:
curl -I 域名先看真实响应是 200、404 还是 500。如果浏览器还能打开,但 curl 是 500,那大概率就是缓存干扰了判断。
第四,相对路径软链接在这种场景下比绝对路径更稳。
这次问题最后其实就卡在一个很小的细节上:
- 绝对路径软链接:看上去明确,但跨宿主机和容器容易翻车。
- 相对路径软链接:在同一挂载目录下更稳。
这个坑看起来很小,但杀伤力非常大。
本章小结
跑通之后回看,CI/CD 的价值不只是自动上传文件,而是让发布过程中的每一步都有明确职责,也都有办法验证。
后续优化
虽然现在这套 CI/CD 已经能用了,但后面还有几件事可以继续优化。
1. 限制发布权限
比如:
- 给
main分支加保护。 - 不允许随便直接 push。
- 至少减少误操作风险。
2. 不再直接用 root 做部署
这次为了尽快打通链路,部署用户直接用了 root。
长期来说,更稳妥的方式是:
- 单独建一个部署用户。
- 只给它必要权限。
3. 进一步处理 PWA 缓存更新体验
现在虽然项目已经有更新提示逻辑,但出了线上故障时,缓存也确实容易干扰判断。
后面还可以继续优化:
- 缓存更新策略。
- 强制刷新提示。
- 发布后的缓存验证方式。
本章小结
现在的链路已经可用,后续优化重点应该放在权限收敛、发布安全和缓存体验上,而不是继续堆复杂度。
总结
这次搭 CI/CD,表面上看只是“自动发布网站”,但实际踩到的问题非常真实:
- CI 工具版本冲突。
- 1Panel 和系统 Nginx 的差异。
- 宿主机路径与容器路径不一致。
- 绝对路径软链接带来的隐藏问题。
- PWA 缓存掩盖真实线上错误。
最后真正让我把这套流程搞通的,不是“多改几次试试看”,而是这几个排查习惯:
- 先确认哪一层成功了,哪一层失败了。
- 不拿浏览器现象当唯一依据。
- 用日志和真实 HTTP 返回来判断问题。
- 把环境差异搞清楚,而不是只看“目录在不在”。
现在回头看,这套 CI/CD 的价值不只是省掉手动上传,更重要的是:
它把发布过程变成了一个可重复、可验证、可排查的流程。
这才是自动化真正有意义的地方。
部分信息可能已经过时




