本文为通用示例。域名、用户、目录、仓库、密钥与服务器指纹均应替换为自己的值;真实私钥、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_HOST | server.example.com | 服务器域名或地址 |
BLOG_SERVER_PORT | 22 | SSH 端口 |
BLOG_SERVER_USER | deploy | 专用部署用户 |
BLOG_DEPLOY_PATH | /srv/blog/site | 静态文件目标目录 |
BLOG_COMPOSE_DIR | /srv/blog | Compose 文件所在目录 |
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 版本范围内兼容;自动升级可能让一次普通文章发布变成构建故障。
建议:
- 将 Hugo 版本写入工作流的
HUGO_VERSION; - 先在本地或独立分支验证新版本;
- 验证成功后再更新工作流版本;
- 为更新检查脚本设置日志、超时与失败告警,但不要在失败时误报成功。
六、发布与排错
本地先构建一次:
1
| hugo --minify --destination public-test
|
提交并推送后,在 Actions 页面确认以下步骤全部成功:
- Checkout 与主题子模块;
- Hugo 构建;
- Artifact 上传;
- SSH 部署;
- 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 提交;静态构建、传输、容器重建和基础安全检查都由可审计的工作流完成。