写在前面
每次写完博客,都要 SSH 登服务器 git pull + hugo 重新构建——写了三五十篇之后,这事就变得很烦。本文记录我用 GitHub Actions + SSH 搭建自动部署的完整过程:push 到 GitHub,服务器自动拉代码、重新构建,全程零手动。
这套方案适合自己有 VPS、用 Nginx 托管静态站点的同学。读完能直接照着配。
一、为什么要自动部署
1.1 手动部署的痛点
1
2
3
4
5
6
7
| 写完文章 → 本地预览 → git push → SSH 登服务器 → cd 目录 → git pull → hugo 构建 → 完成
每篇文章都要重复后三步,而且:
✗ 容易忘(push 了但没部署,线上还是旧的)
✗ 多设备时混乱(公司 push 了,家里没同步)
✗ 服务器上手动操作易出错(命令敲错、忘记更新主题)
✗ 出门没电脑就改不了博客
|
1.2 自动化之后
1
2
3
4
5
6
| 写完文章 → git push → 自动部署 → 线上更新
✓ push 即部署,所见即所得
✓ 任何设备、任何地方都能更新
✓ 不用记服务器命令
✓ 主题更新、配置改动都自动同步
|
二、部署方案对比
Hugo 静态站点的自动部署有几种主流方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 方案 原理 优点 缺点
──────────────────────────────────────────────────────────────────────────────
1. Actions SSH GitHub 通过 SSH 登服务器 复用现有流程 依赖服务器环境
执行 执行 git pull + hugo 服务器改动小 (git/hugo)
配置简单
2. Actions 构建 GitHub 跑 Hugo,rsync 推 构建不依赖服务器 要配 rsync +
+ rsync 静态文件到服务器 环境,更标准 nginx 目录
3. 服务器 cron 服务器定时 git pull + hugo 零 GitHub 配置 有几分钟延迟
最简单
4. 托管平台 Cloudflare Pages / Vercel 免服务器、全球 要改 DNS、
托管 / Netlify CDN、免费 可能不符现有架构
|
选型建议
1
2
3
4
| 有自己的 VPS + 现在手动 SSH 部署 → 方案1(本文)
有自己的 VPS,想让构建脱离服务器 → 方案2
不想碰 GitHub Actions → 方案3
没有服务器 / 想要免费 CDN → 方案4
|
本文用方案1:最贴合"已经在手动 SSH 部署"的现状,服务器几乎零改动,迁移成本最低。
三、方案1 的原理
很多人对 GitHub Actions 的 runs-on 有误解,先讲清楚。
3.1 整体流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| 你 push 代码到 GitHub
│
▼
┌───────────────────┐
│ GitHub 虚拟机 │ ← runs-on: ubuntu-latest
│ (GitHub 托管 Runner)│ 这只是个"跑腿的"临时环境
│ │
│ 唯一的动作: │
│ 通过 SSH 连你的服务器│
└─────────┬─────────┘
│ SSH
▼
┌───────────────────┐
│ 你的 VPS (Debian等) │ ← 真正部署发生的地方
│ │
│ cd 仓库目录 │
│ fetch + 快进合并 │
│ hugo 构建 │
│ → nginx 目录更新 │
└───────────────────┘
|
3.2 runs-on 不是你的服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
| runs-on: ubuntu-latest
这指定的是 GitHub Actions 的"执行环境"——
GitHub 提供的一台临时 Ubuntu 托管 Runner;免费额度与计费取决于仓库可见性和账户方案。
它和你的服务器系统(Debian/CentOS 都行)无关。
这台虚拟机只负责一件事:发起 SSH 连接到你的服务器。
任务完成即销毁。
打个比方:
runs-on 的 Ubuntu = 快递员(跑腿的)
你的服务器 = 收件人(真正干活的地方)
快递员是谁不重要,能把包裹(SSH 命令)送到就行
|
理解这点很关键:部署逻辑跑在你的服务器上,GitHub 虚拟机只是触发器。
四、前置准备
4.1 服务器要求
1
2
3
4
| ✓ 一台 VPS / 云服务器,有 SSH 权限;部署用户能读仓库并写 Hugo 输出目录
✓ 已安装 git 和 hugo(手动部署过的话肯定有)
✓ Nginx(或其他 Web 服务器)已配置好,能托管静态文件
✓ 仓库已 git clone 到服务器(手动部署过的话肯定有)
|
4.2 生成专用部署密钥
关键:给 GitHub Actions 单独生成一对密钥,不要和你日常 SSH 的 key 混用。这样更安全(最小权限、可随时撤销)。
在可信的管理终端生成密钥,再把公钥安装到服务器。不要让服务器长期保留部署私钥:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # 1. 生成密钥对(ed25519 更安全更快,无密码)
ssh-keygen -t ed25519 -f ./github_actions -N "" -C "github-actions-deploy"
# 生成两个文件:
# ./github_actions 私钥(写入 GitHub Secret 后安全删除本地副本)
# ./github_actions.pub 公钥(安装到服务器)
# 2. 把公钥加入授权列表(让持有私钥的 GitHub 能登录)
cat github_actions.pub | ssh deploy@example.com 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'
ssh deploy@example.com 'chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys'
# 3. 查看并复制私钥内容(下一步要填到 GitHub)
cat ./github_actions
# 4. 从可信网络核对服务器 ED25519 主机密钥指纹,保存为 Secret
ssh deploy@example.com 'ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub'
|
1
2
3
4
5
6
| 复制私钥时要注意:
✓ 完整复制,包含 -----BEGIN/END OPENSSH PRIVATE KEY----- 两行
✓ 包含所有换行
✗ 不要漏掉任何字符
这把私钥只填到 GitHub Secrets,绝不放进代码仓库
|
五、配置 GitHub Secrets
Secrets 是 GitHub 加密存储的变量,workflow 里通过 ${{ secrets.XXX }} 引用。GitHub 会尝试遮蔽日志中的完整 Secret,但经过编码、截断或由第三方程序变形后的值仍可能泄漏;不要主动输出 Secret,也不要把不可信输入和 Secret 交给同一个 shell。
打开仓库 → Settings → Secrets and variables → Actions → New repository secret,添加 4 个:
| Secret 名 | 值 | 说明 |
|---|
SERVER_HOST | 服务器 IP 或域名 | 如 1.2.3.4 或 jiwei.space |
SERVER_USER | SSH 用户名 | 建议使用专用 deploy 用户 |
SERVER_SSH_KEY | 第四步复制的私钥 | 完整内容 |
SERVER_HOST_FINGERPRINT | SSH 主机公钥的 SHA256 指纹 | 防止连接到被冒充的主机 |
1
2
3
| 如果你的 SSH 端口不是 22:
额外加一个 SERVER_PORT(如 2222)
workflow 里把 port 那行的注释去掉
|
六、编写 workflow
在仓库根目录创建 .github/workflows/deploy.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| # Hugo 自动部署:push 到 main 后,通过 SSH 登服务器执行 fetch、快进合并和 hugo
name: Deploy
on:
push:
branches: [main] # push 到 main 时触发
workflow_dispatch: # 也允许在 Actions 页面手动触发
permissions:
contents: read # 此 workflow 不需要写仓库
concurrency:
group: production-deploy
cancel-in-progress: false # 避免两个远程构建同时写同一输出目录
jobs:
deploy:
runs-on: ubuntu-latest # GitHub 提供的执行环境(不是你的服务器)
steps:
- name: SSH 登录服务器并部署
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
DEPLOY_SHA: ${{ github.sha }}
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
fingerprint: ${{ secrets.SERVER_HOST_FINGERPRINT }}
envs: DEPLOY_SHA
# 端口非 22 时,取消下行注释并配置 SERVER_PORT
# port: ${{ secrets.SERVER_PORT }}
script: |
set -e
cd /var/www/blog/site # ← 改成你服务器上仓库的路径
git fetch origin main
test "$(git rev-parse origin/main)" = "$DEPLOY_SHA" || {
echo "main 已变化,本次旧部署停止,等待较新的 workflow"
exit 1
}
git checkout main
git merge --ff-only "$DEPLOY_SHA" # 部署本次 workflow 对应的提交
git submodule update --init --recursive
hugo --minify --buildFuture --cleanDestinationDir -d /var/www/blog/public
echo "✅ 部署完成 $(date '+%F %T')"
|
6.1 逐段讲解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| on:
push:
branches: [main] # 监听 main 分支的 push
workflow_dispatch: # 加这个能在 Actions 页面点按钮手动跑
→ 每次 push 文章/配置,自动触发;也能手动重跑
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
→ 社区维护的 SSH Action,稳定好用,不用自己写 SSH 逻辑
set -e
→ 任何命令失败立即中止(避免 pull 失败还继续 hugo)
cd /var/www/blog/site
→ 进到服务器上的仓库目录(手动部署时你 cd 的那个)
git fetch origin main
git merge --ff-only "$DEPLOY_SHA"
→ 只快进到触发本次 workflow 的提交,避免并发 push 时部署到另一个尚未审核的提交
hugo --minify --buildFuture --cleanDestinationDir -d /var/www/blog/public
→ 各参数的作用见下节
|
6.2 hugo 命令的四个关键参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| -d /var/www/blog/public
指定输出目录(-d = destination)
直接输出到 nginx 的 web 根目录,省去 cp/rsync 步骤
--minify
压缩 HTML/CSS/JS(去注释、去空白)
减小生成文件体积;是否显著改善传输和加载速度取决于压缩、缓存及内容规模
--buildFuture
构建发布日期在未来的文章
本站的发布日期是按内容规划手工排的,经常比实际写作时间超前,
不加这个参数这些文章不会上线;按真实发布时间写日期的站点可以不加
--cleanDestinationDir
构建前清理目标目录里不在生成的文件
⚠️ 重要:如果不加,删了某篇文章后旧的 HTML 还会残留
访问者可能撞到已删除的页面
前提:目标目录只放 Hugo 输出(别混别的文件)
|
6.3 主题是 submodule 的话
1
2
3
4
5
6
7
8
| 如果主题用 git submodule 管理(很多 Hugo 主题推荐这么做),
git pull 不会自动同步 themes/ 目录。
哪天你更新了主题(git submodule update --remote themes/stack),
记得把这行的注释取消:
git submodule update --init --recursive
这条命令是幂等的,建议每次部署都执行,确保新克隆、主题指针变化和嵌套 submodule 都正确初始化。
|
七、触发与验证
7.1 提交触发
1
2
3
| git add .github/workflows/deploy.yml
git commit -m "ci: 添加自动部署"
git push
|
这次 push 本身就会触发首次部署(因为 workflow 监听 push)。
7.2 查看执行结果
打开仓库的 Actions 标签页(github.com/<用户>/<仓库>/actions):
1
2
3
| 🟢 绿色 ✓ → 成功!以后每次 push 自动部署
🔴 红色 ✗ → 失败,点进去看哪一步报错
🟡 黄色 → 正在运行
|
点开失败的 run,能看到每一步的日志,定位问题。
7.3 手动触发
配置了 workflow_dispatch 后,在 Actions 页面:
- 左侧选 Deploy workflow
- 右侧点 Run workflow → 选 main 分支 → Run
适合改了 secrets 或服务器配置后,不 push 代码也能重新部署。
八、常见问题
8.1 hugo: command not found
最常见的坑。 SSH 的 non-login shell 环境变量不全,可能找不到 hugo(尤其 snap 安装的)。
1
2
3
| # 在服务器上查 hugo 的绝对路径
which hugo
# 可能输出:/usr/local/bin/hugo 或 /snap/bin/hugo
|
然后把 workflow 里的 hugo 改成绝对路径:
1
2
3
4
5
| script: |
set -e
cd /var/www/blog/site
# 省略与主 workflow 相同的 fetch、提交校验、checkout、merge 和 submodule 步骤
/usr/local/bin/hugo --minify --buildFuture --cleanDestinationDir -d /var/www/blog/public
|
8.2 Git 更新失败
1
2
3
4
5
6
7
8
9
10
11
12
| 原因1:路径不对
cd 的目录不是 git 仓库(没有 .git)
→ 确认服务器上仓库的真实路径
原因2:服务器仓库有本地改动或历史不能快进
--ff-only 会拒绝合并
→ 服务器上别手动改文件,所有改动走 git push
原因3:仓库是 private
服务器 git fetch 需要认证
→ 给服务器配 deploy key,或用 https + token
(public 仓库无此问题)
|
8.3 SSH 连接失败
1
2
3
4
5
6
7
8
9
10
| Permission denied (publickey)
→ 私钥没配对,检查 SERVER_SSH_KEY 内容是否完整
→ 公钥是否加到 authorized_keys
Connection timeout
→ 防火墙/安全组没放行 SSH 端口
→ 或 SERVER_HOST 填错
端口非 22
→ 加 SERVER_PORT secret,workflow 取消 port 行注释
|
8.4 部署成功但页面没更新
1
2
3
4
5
6
7
8
| 可能1:浏览器缓存
→ 强制刷新(Ctrl+F5)或无痕模式
可能2:CDN 缓存
→ 清 CDN 缓存,或等缓存过期
可能3:hugo 构建输出了,但 nginx root 指向的目录不对
→ 确认 -d 的目录 = nginx server 块的 root
|
九、安全注意事项
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| 1. 用专用密钥,不共用
给 GitHub Actions 单独一对密钥
和你日常 SSH 的 key 分开
万一泄露,只影响部署,且能单独撤销
2. 私钥只进 Secrets,绝不进代码
.gitignore 不用管(Secrets 根本不在仓库里)
别手滑把私钥贴到代码或日志
3. 最小权限(进阶)
建专用 deploy 用户,只给仓库和 Hugo 输出目录的权限
不能 sudo,限制 SSH 只能执行特定命令
4. 密钥可随时撤销
怀疑泄露 → 服务器删掉 authorized_keys 里那行公钥即可
重新生成一对,更新 GitHub Secret
5. 校验服务器身份并审计第三方 Action
配置 fingerprint,避免只加密却不认证远端主机
生产中把 Action 固定到审核过的完整 commit SHA,并由 Dependabot/Renovate 提交升级
|
十、进阶优化(可选)
跑通基础版后,可以按需增强:
10.1 部署失败通知
GitHub 是否发送失败邮件取决于个人通知设置。若需要团队即时获知,可以显式接入告警渠道:
1
2
3
4
5
6
7
8
9
10
11
12
13
| jobs:
deploy:
steps:
# ... 部署步骤 ...
- name: 失败通知
if: failure()
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
run: |
# 调用钉钉/飞书/企业微信 webhook
curl --fail-with-body -X POST "$DINGTALK_WEBHOOK" \
-H "Content-Type: application/json" \
-d '{"msgtype":"text","text":{"content":"博客部署失败!"}}'
|
10.2 多环境部署
1
2
3
4
| main 分支 → 生产服务器
dev 分支 → 测试服务器
用分支触发不同 job,或用 environment 区分 secrets
|
10.3 加上构建产物校验
1
2
3
4
5
6
7
8
| script: |
set -e
cd /var/www/blog/site
# 先执行与主 workflow 相同的 Git 更新和 submodule 初始化步骤
hugo --minify --buildFuture --cleanDestinationDir -d /var/www/blog/public
# 校验关键页面是否生成
test -f /var/www/blog/public/index.html || { echo "构建失败"; exit 1; }
echo "✅ 部署完成 $(date '+%F %T')"
|
十一、小结
本文记录了用 GitHub Actions + SSH 自动部署 Hugo 博客的完整流程:
- 方案选型:对比 4 种方案,方案1(Actions SSH 执行)最贴合手动 SSH 部署的现状
- 原理:runs-on 是 GitHub 的跑腿虚拟机,真正的部署在你的服务器上发生
- 密钥:生成专用 ed25519 密钥对,公钥留服务器,私钥进 GitHub Secrets
- Secrets:SERVER_HOST / SERVER_USER / SERVER_SSH_KEY(端口非 22 加 SERVER_PORT)
- workflow:固定第三方 Action 的提交,校验主机指纹和触发提交,再执行 Hugo 构建
- 触发:push 自动触发 + workflow_dispatch 手动触发
- 排坑:Hugo PATH、Git 更新失败、SSH 连接、页面缓存
- 安全:专用密钥、主机指纹、只读
GITHUB_TOKEN、最小服务器权限、可撤销
配好之后,写博客的流程简化为:
1
2
3
| hugo new content posts/xxx/index.md # 写文章
hugo server # 本地预览
git add && git commit && git push # 发布,自动部署
|
从此告别 SSH 手动操作,专注写内容。这正是静态博客 + CI/CD 的理想工作流。