使用 GitHub Actions 自动构建与部署 Hugo 博客

使用 GitHub Actions 构建 Hugo 静态站点,并通过专用 SSH 部署用户将产物发布到 Caddy 静态目录的通用方案。

本文为通用示例。域名、用户、目录、仓库、密钥与服务器指纹均应替换为自己的值;真实私钥、Token 与服务器地址不应写入文章、仓库或构建日志。

一、部署流程

推荐将“构建”和“运行”分开:GitHub Actions 负责生成 Hugo 静态文件,服务器只运行 Caddy 并提供静态内容。

1
2
3
4
5
6
7
推送 main 分支
  → 检出仓库与主题子模块
  → 固定版本 Hugo 构建 public/
  → 保存构建产物 Artifact
  → 使用专用 SSH 密钥连接服务器
  → 原子替换静态目录内容
  → docker compose 重建 Caddy

这种方式不需要在生产服务器安装 Hugo,也避免将站点构建依赖与 Web 服务混在同一个容器中。

二、服务器准备

假设 Caddy 的 Compose 文件位于 /srv/blog/compose.yaml,静态文件目录为 /srv/blog/site/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./site:/srv:ro
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    restart: unless-stopped

Caddyfile 使用自己的站点域名:

1
2
3
4
5
blog.example.com {
    root * /srv
    encode gzip zstd
    file_server
}

为 CI 创建一个专用部署用户,例如 deploy。它只需要:

  • 对 /srv/blog/site/ 的写入权限;
  • 运行该 Compose 项目的最小 Docker 权限;
  • ~/.ssh/authorized_keys 中的部署公钥;
  • 禁止密码登录,仅允许公钥认证。

不要使用日常管理员账户,也不要将管理员私钥放入 Actions Secrets。

三、配置 GitHub Actions Secrets

在仓库 Settings → Secrets and variables → Actions 中创建下列 Secrets:

Secret示例值说明
BLOG_SERVER_HOSTserver.example.com服务器域名或地址
BLOG_SERVER_PORT22SSH 端口
BLOG_SERVER_USERdeploy专用部署用户
BLOG_DEPLOY_PATH/srv/blog/site静态文件目标目录
BLOG_COMPOSE_DIR/srv/blogCompose 文件所在目录
BLOG_SERVER_SSH_KEY<部署私钥>与服务器公钥匹配的私钥
BLOG_SERVER_HOST_KEY<SSH 主机公钥行>服务器 SSH 主机公钥,用于校验服务器身份

部署密钥的公钥放在服务器的 authorized_keys;私钥只放在 GitHub Actions Secret 与本地安全存储中。

可在管理员设备上查看服务器主机公钥,并人工核对指纹后保存为 Secret:

1
ssh-keyscan -p 22 server.example.com

不要在 CI 中直接把未核验的 ssh-keyscan 结果追加到 known_hosts。首次连接遭遇中间人攻击时,这会信任攻击者提供的密钥。

四、工作流示例

在仓库中创建 .github/workflows/deploy.yml。下面示例固定 Hugo 版本、保存 Artifact,并在部署前校验所有必要配置:

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
name: Build and deploy Hugo site

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    env:
      HUGO_VERSION: "0.167.0"
      SERVER_HOST: ${{ secrets.BLOG_SERVER_HOST }}
      SERVER_PORT: ${{ secrets.BLOG_SERVER_PORT }}
      SERVER_USER: ${{ secrets.BLOG_SERVER_USER }}
      REMOTE_PATH: ${{ secrets.BLOG_DEPLOY_PATH }}
      COMPOSE_DIR: ${{ secrets.BLOG_COMPOSE_DIR }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: true

      - name: Build static files
        run: hugo --minify --destination public

      - name: Upload generated files
        uses: actions/upload-artifact@v4
        with:
          name: site-public
          path: public/
          if-no-files-found: error

      - name: Deploy generated files
        env:
          SSH_KEY: ${{ secrets.BLOG_SERVER_SSH_KEY }}
          HOST_KEY: ${{ secrets.BLOG_SERVER_HOST_KEY }}
        run: |
          set -euo pipefail
          for name in SERVER_HOST SERVER_PORT SERVER_USER REMOTE_PATH COMPOSE_DIR SSH_KEY HOST_KEY; do
            test -n "${!name}" || { echo "Missing $name"; exit 1; }
          done

          install -d -m 700 ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$HOST_KEY" > ~/.ssh/known_hosts

          tar -C public -czf - . | ssh \
            -i ~/.ssh/deploy_key \
            -o StrictHostKeyChecking=yes \
            -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" \
            "mkdir -p \"$REMOTE_PATH\" && find \"$REMOTE_PATH\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar -xzf - -C \"$REMOTE_PATH\""

          ssh -i ~/.ssh/deploy_key \
            -o StrictHostKeyChecking=yes \
            -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" \
            "cd \"$COMPOSE_DIR\" && docker compose up -d --force-recreate caddy"

若主题使用 Git 子模块且网络环境不稳定,可为子模块准备可靠的镜像或代理策略;无论使用何种方式,都应检出主仓库锁定的子模块提交,而不是无条件拉取主题的最新分支。

五、为什么固定 Hugo 版本

不要在 CI 中使用 hugo-version: latest。主题与站点配置可能只在某一 Hugo 版本范围内兼容;自动升级可能让一次普通文章发布变成构建故障。

建议:

  1. 将 Hugo 版本写入工作流的 HUGO_VERSION;
  2. 先在本地或独立分支验证新版本;
  3. 验证成功后再更新工作流版本;
  4. 为更新检查脚本设置日志、超时与失败告警,但不要在失败时误报成功。

六、发布与排错

本地先构建一次:

1
hugo --minify --destination public-test

提交并推送后,在 Actions 页面确认以下步骤全部成功:

  1. Checkout 与主题子模块;
  2. Hugo 构建;
  3. Artifact 上传;
  4. SSH 部署;
  5. Caddy 容器重建。

服务器侧常用检查命令:

1
2
3
4
cd /srv/blog
sudo docker compose ps
sudo docker compose logs -f caddy
curl -I https://blog.example.com/

若部署失败,应优先检查:

  • Actions Secrets 的名称与值是否完整;
  • 私钥是否与服务器 authorized_keys 中的公钥匹配;
  • 部署用户是否拥有静态目录与 Docker Compose 所需权限;
  • known_hosts 中的主机公钥是否与服务器当前主机密钥一致;
  • 服务器磁盘空间、Docker 容器状态及防火墙规则。

七、安全清单

  • 使用专用部署用户与独立密钥;
  • 部署用户禁用密码认证;
  • Secrets 只保存于 GitHub,不写入仓库;
  • 固定 Actions 与 Hugo 的版本,定期评估升级;
  • 使用已验证的 SSH 主机公钥,启用 StrictHostKeyChecking=yes;
  • 构建产物仅授予 Caddy 只读挂载;
  • 在部署脚本中开启 set -euo pipefail,任何异常立即失败;
  • 服务器日志中不得输出 Token、私钥或 Secret 内容。

通过以上方案,博客发布只需要推送 Git 提交;静态构建、传输、容器重建和基础安全检查都由可审计的工作流完成。

updatedupdated2026-10-042026-10-04