Hugo 博客自动部署实战:GitHub Actions + SSH

写在前面

每次写完博客,都要 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.4jiwei.space
SERVER_USERSSH 用户名建议使用专用 deploy 用户
SERVER_SSH_KEY第四步复制的私钥完整内容
SERVER_HOST_FINGERPRINTSSH 主机公钥的 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 页面:

  1. 左侧选 Deploy workflow
  2. 右侧点 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 的理想工作流。

Licensed under CC BY-NC-SA 4.0