适合人群:已有 Hugo 博客 + 独立 VPS,想摆脱手动
scp部署,实现 push 自动发布的开发者。
最终效果:push 到main分支 → GitHub Actions 自动构建 → rsync 传到 VPS → 20 秒内上线。
为什么要写这篇#
我在搭这套流程时,前后踩了 4 个坑,每个单独看都不难,但文档里找不到,只能靠报错日志一点点排查。这篇把全流程梳理一遍,重点把容易翻车的地方标出来,省得你重蹈覆辙。
架构设计#
push main
└─ GitHub Actions
├─ checkout + submodules
├─ Hugo 0.161.1 extended 构建
├─ rsync → VPS /var/www/releases/<timestamp>/
├─ ln -snf 切换 symlink(原子操作)
└─ curl healthcheck关键设计决策:symlink 原子切换
不要用 rsync --delete 直接覆盖线上目录。Hugo 生成的静态文件之间有引用关系(JS 引 CSS hash),rsync 传输到一半如果中断,VPS 上会出现新旧文件混合的状态,导致 404。
正确做法:rsync 传到一个新的 timestamp 目录,成功后用 ln -snf 切换 symlink。ln -snf 是原子操作,nginx 读到的永远是完整的某一版,不存在中间态。
准备工作#
- VPS:Ubuntu 22.04,已有 nginx 服务博客
- 博客仓库:
github.com/你的org/blog,主分支main - 本地:macOS 或 Linux
第一步:VPS 准备(root 登录执行)#
1.1 创建 deploy 专用用户#
useradd -m -s /bin/bash deploy⚠️ 常见错误:shell 设成 nologin
很多教程建议用
/usr/sbin/nologin作为 deploy 用户的 shell,看起来更安全。但这行不通。OpenSSH 在执行
authorized_keys里的command=强制命令时,是通过shell -c command来调用的。如果 shell 是nologin,它会打印 “This account is currently not available.” 然后退出,rsync 客户端收到这段文字就报 “protocol version mismatch”。正确做法:shell 设为
/bin/bash,安全性通过forced command+no-pty限制来保证,效果是一样的。
1.2 创建 SSH 目录#
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chown deploy:deploy /home/deploy/.ssh1.3 安装 rrsync#
Ubuntu 22.04 不预装 rrsync,需要手动安装:
apt-get update && apt-get install -y rsync
# 验证
which rrsync
# 输出:/usr/bin/rrsync⚠️ 不要跳过这步。
rsync命令和rrsync是两个东西:
rsync:数据传输工具(两端都需要)rrsync:restricted rsync,是服务端的"守门员",限制 rsync 只能写入指定目录
1.4 编写部署脚本#
cat > /usr/local/bin/blog-deploy.sh << 'SCRIPT'
#!/bin/bash
# 以 forced command 方式运行:rrsync 接收文件 + symlink 原子切换
set -euo pipefail
umask 022
RELEASES_DIR="/var/www/releases"
SYMLINK="/var/www/laodao-ai" # 改成你的实际路径
KEEP=5
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST="${RELEASES_DIR}/${TIMESTAMP}"
mkdir -p "${DEST}"
# ⚠️ 关键:set +e 包裹 rrsync,否则清理逻辑不可达(见下方说明)
set +e
/usr/bin/rrsync -wo "${DEST}"
RSYNC_EXIT=$?
set -e
if [ "${RSYNC_EXIT}" -eq 0 ]; then
ln -snf "${DEST}" "${SYMLINK}"
ls -dt "${RELEASES_DIR}"/[0-9]*/ 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -rf
else
rm -rf "${DEST}"
fi
exit "${RSYNC_EXIT}"
SCRIPT⚠️
set -e陷阱脚本顶部的
set -euo pipefail很好,但会带来一个副作用:任何命令以非零退出码结束,脚本立即退出,后面的代码不执行。如果你这样写:
/usr/bin/rrsync -wo "${DEST}" RSYNC_EXIT=$? # ← 永远不会到达这里(rrsync 失败时)rrsync 失败 → set -e 触发脚本退出 →
$?捕获失败 →else分支的rm -rf "${DEST}"永远不执行 → 目录垃圾堆积。正确做法:用
set +e/set -e临时关闭,手动捕获退出码。
1.5 设置脚本权限#
chown root:deploy /usr/local/bin/blog-deploy.sh
chmod 750 /usr/local/bin/blog-deploy.sh⚠️ 属组必须是 deploy,不能是 root:root
chmod 750的权限分布:
7(rwx):属主 root 可读写执行5(r-x):属组可读执行0(—):其他人无权限deploy 用户不在 root 组,如果属主是
root:root,deploy 落入"其他人",没有执行权限,CI 会报Permission denied。属组设为
deploy→ deploy 用户进入"属组"一栏 → 有读+执行权限 → 脚本可以运行。
1.6 创建 releases 目录并设置权限#
mkdir -p /var/www/releases
chown -R deploy:deploy /var/www/releases/
# 关键:/var/www/ 本身需要 deploy 组可写(用于创建/替换 symlink)
chown root:deploy /var/www
chmod 775 /var/www
# 验证
ls -la /var | grep www
# 应该显示:drwxrwxr-x root deploy⚠️ symlink 需要父目录写权限
ln -snf /var/www/releases/xxx /var/www/laodao-ai这个操作,实际上是在/var/www/目录里创建/替换一个条目。要在目录里创建文件(包括 symlink),需要对该目录本身有写权限,不是对 symlink 本身。
deploy 用户默认对
/var/www/没有写权限 →ln报Permission denied→ symlink 不更新 → 旧版还在上面,但 CI 显示成功(rsync 其实成功了)。解决:给
/var/www/开放 deploy 组的写权限。
第二步:本地生成 deploy key(macOS 执行)#
# 生成专用 deploy key,无密码(CI 自动使用)
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""
# 查看公钥(后面要用)
cat ~/.ssh/deploy_key.pub
# 获取 VPS 主机指纹(后面要填入 GitHub Secrets)
ssh-keyscan -H 你的VPS_IP第三步:配置 GitHub Secrets#
在 github.com/你的org/blog → Settings → Secrets and variables → Actions → 停留在 Secrets tab(不是 Variables)。
⚠️ Secrets vs Variables 的区别
- Secrets:加密存储,在 Actions 日志中自动脱敏显示为
***- Variables:明文存储,日志里可见
SSH 私钥和 IP 都是敏感信息,必须用 Secrets。
点击 New repository secret,依次添加三个:
DEPLOY_SSH_KEY
cat ~/.ssh/deploy_key把完整输出粘贴进去,包括首尾的 -----BEGIN OPENSSH PRIVATE KEY----- 和 -----END OPENSSH PRIVATE KEY----- 两行。
VPS_HOST
直接填 VPS IP,例如 1.2.3.4。
KNOWN_HOSTS
ssh-keyscan -H 你的VPS_IP把完整输出(通常是 3 行,分别是 ecdsa、rsa、ed25519 三种算法)全部粘贴进去。
第四步:VPS 配置 authorized_keys#
先把公钥传到 VPS:
scp ~/.ssh/deploy_key.pub root@你的VPS_IP:/tmp/deploy_key.pubSSH 进 VPS,写入 authorized_keys:
# ⚠️ 分两步写,避免引号地狱
echo -n 'command="/usr/local/bin/blog-deploy.sh",no-pty,no-agent-forwarding,no-X11-forwarding,no-port-forwarding ' \
> /home/deploy/.ssh/authorized_keys
cat /tmp/deploy_key.pub >> /home/deploy/.ssh/authorized_keys
# 验证:应该是一整行
cat /home/deploy/.ssh/authorized_keys⚠️ 公钥内容有空格,不能直接拼在单引号字符串里
deploy_key.pub的内容是ssh-ed25519 AAAA... user@host,中间有空格。如果你试图把它直接拼在echo '...前缀... <公钥>'里,shell 的引号规则会让你头大。最简单的方法:用两步拼接。先用
echo -n(-n去掉换行)写入前缀,再用cat >>追加公钥内容,这样两部分在同一行,不需要处理任何引号。
设置权限:
chmod 600 /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys验证 certbot 不受影响(如果你用 Let’s Encrypt):
certbot renew --dry-run第五步:创建 GitHub Actions Workflow#
在博客仓库本地创建文件:
mkdir -p .github/workflows写入 .github/workflows/deploy.yml:
name: Deploy Blog
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" # 提前兼容 Node.js 24
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.161.1" # 固定版本,与本地一致
extended: true
- name: Build
run: hugo --minify --gc
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy via rsync
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
./public/ deploy@$VPS_HOST:
- name: Health check
run: |
curl -f --retry 3 --retry-delay 2 https://你的域名.com几个关键点:
submodules: recursive:Hugo 主题通常是 git submodule,必须加hugo-version:固定版本,不要用latest,本地和 CI 版本不一致会导致构建结果差异StrictHostKeyChecking=yes:不要图省事用StrictHostKeyChecking=no,那等于在 CI runner 上关掉 MITM 检测FORCE_JAVASCRIPT_ACTIONS_TO_NODE24:GitHub Actions 在 2026 年 6 月强制切换 Node.js 24,提前加这个 env var 现在就能测试兼容性
commit 并 push:
git add .github/workflows/deploy.yml
git commit -m "ci: add GitHub Actions deploy workflow"
git push origin main第六步:验证#
6.1 查看 CI 运行结果#
push 后约 30 秒,在 GitHub → Actions 页面看到运行记录。全绿代表成功:
✓ Checkout
✓ Setup Hugo
✓ Build
✓ Configure SSH
✓ Deploy via rsync
✓ Health check6.2 验证 VPS 目录结构#
SSH 进 VPS:
ls -la /var/www/releases/
# 应该有 20260501XXXXXX 格式的 timestamp 目录
ls -la /var/www/laodao-ai
# 应该显示:lrwxrwxrwx ... /var/www/laodao-ai -> /var/www/releases/20260501XXXXXX6.3 回滚安全验证(必做)#
在任意文章里加一个不存在的 shortcode,触发 Hugo 构建失败:
{{< this_shortcode_does_not_exist >}}push 后观察:
- CI 在 Build 步骤失败,rsync 步骤不执行
curl https://你的域名.com仍返回 200,线上内容完好- VPS 的 symlink 指向没有变化
验证通过后删掉这行,再 push,CI 恢复全绿。
踩坑汇总#
| 错误现象 | 根因 | 修复 |
|---|---|---|
protocol version mismatch | deploy shell 是 nologin,forced command 无法执行 | usermod -s /bin/bash deploy |
Permission denied 执行 blog-deploy.sh | 脚本属组是 root,deploy 用户无执行权限 | chown root:deploy + chmod 750 |
ln: failed to create symbolic link: Permission denied | deploy 对 /var/www/ 目录无写权限 | chown root:deploy /var/www && chmod 775 /var/www |
No such file or directory: /usr/bin/rrsync | Ubuntu 22.04 未预装 rrsync | apt install rsync |
| rsync 失败后 releases/ 留有空目录 | set -e 使清理逻辑不可达 | set +e / set -e 包裹 rrsync 调用 |
安全性说明#
搭完之后,deploy 用户的权限边界是这样的:
- forced command:authorized_keys 里写死
command=blog-deploy.sh,SSH 连接建立后只能执行这一个脚本,不能执行任何其他命令 - no-pty:不能获得交互式终端
- no-agent-forwarding / no-X11-forwarding / no-port-forwarding:关掉所有转发能力
- rrsync -wo:限制 rsync 只能写入指定目录,不能读取其他文件
即使 deploy key 泄露,攻击者能做的只有:往 /var/www/releases/ rsync 静态文件。无法获得 shell,无法读取系统文件,无法横向移动。
下一步#
- 如果你有多个环境(staging/production),可以再加一个 deploy user 和对应的 authorized_keys 条目
- 阶段 2 可以把
check-summary.sh等质量检查接入 CI,在构建前就拦截不合规的文章 - 想手动回滚到某个历史版本,在 VPS 上执行:
ln -snf /var/www/releases/20260501XXXXXX /var/www/laodao-ai秒级生效,无需重新部署。