0. 背景与架构演进:为什么我们需要流水线?
Hugo 的运行机制
Hugo 是一个基于 Go 语言的静态网站生成器(Static Site Generator)。它的核心工作流是将我们编写的 Markdown 源文件,结合站点配置和主题模板,在编译阶段直接渲染成纯静态的 HTML、CSS 和 JS 文件(输出到 public/ 目录)。
由于是纯静态站点,线上的服务器不需要运行任何数据库或动态后端(如 PHP/Node.js),只需要一个 Web 服务器来托管这些文件即可对外提供访问。这种架构带来了极高的访问速度和安全性。
自动化构建的必然性
正因为 Hugo 所有的页面都是预先生成的,如果采用手动发布,我们每次更新文章就会陷入一个繁琐的循环:本地写 Markdown -> 本地运行 hugo 构建出 public 目录 -> 手动打包或通过 scp/rsync 把文件传到服务器。
这三步里有两步是枯燥的机械劳动,不仅容易出错(比如忘了构建就上传),而且如果更换电脑写作,还得重新配置 Hugo 环境。因此,搭建一条自动化的 CI/CD 流水线,把编译和部署工作交给云端,是大幅提升博客写作体验的必选项。我们最终期望的体验是:只管写 Markdown,推代码即自动部署。
为什么这样设计部署架构?
基于上述诉求,我们设计了当前的流水线架构:
- 构建层(GitHub Actions):文章源码天然需要使用 Git 进行版本控制并托管在 GitHub 上。GitHub Actions 提供了与代码仓库深度绑定的免费 CI/CD 环境。它可以监听
git push事件,自动在云端运行容器,拉取子模块(主题)并执行hugo build。这样无论我们在哪里写代码,只要能 push,云端就能帮我们完成构建。 - 传输层(rsync):在云端构建完成后,我们使用
rsync经由 SSH 将生成的静态文件增量同步到阿里云 ECS。rsync非常轻量高效,配合--delete参数能够确保服务器上的文件状态与最新编译结果完全一致(自动删除已移除的文件),不需要在服务器端部署任何复杂的 Agent。 - 网关与服务层(从 Nginx 到 Pingap):静态文件传到服务器的指定目录后,需要对外暴露。本站点早期用 Nginx 直接托管静态文件,后来迁移到 Pingap 统一入口。为了让 Hugo 生成的目录页、文章页和标签页都保持稳定的
index.html行为,当前采用darkhttpd在本机监听静态目录,Pingap 负责 HTTPS、SNI、HTTP 跳转和反代。无论入口网关怎么演进,“云端编译静态文件并推送到服务器目录” 始终是整套发布流程的核心。
1. 最终部署架构图
本地写 Markdown ──git push──→ GitHub ──Actions──→ Hugo build ──rsync──→ ECS /var/www/hugo-site/
↑
SSH deploy key
- GitHub:源码仓库 + CI 触发器 + 版本历史
- GitHub Actions:Ubuntu runner 上跑 hugo build,然后 rsync
- 阿里云 ECS:实际跑服务的地方,
darkhttpd承载静态文件,Pingap 对外反代
2. 准备:仓库初始化
假设你已经有一个空仓库克隆到本地:
cd my_articles
rm -rf main.py .idea # 清理旧文件
mkdir -p archetypes assets content/posts data layouts static themes
添加主题(这里用 PaperMod,和你现有站点保持一致):
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
写站点配置 hugo.toml:
baseURL = 'https://cloudside.icyyan.com'
languageCode = 'en'
title = 'Cloudside'
theme = 'PaperMod'
# 分页:每页 6 篇文章
paginate = 6
[params]
author = 'Icyyan'
description = '这是一个用 Hugo + PaperMod 搭建的个人博客'
ShowReadingTime = true
ShowPostNavLinks = true
ShowBreadCrumbs = true
ShowCodeCopyButtons = true
ShowToc = true
Search = true
[params.profileMode]
enabled = true
title = 'Cloudside'
subtitle = '记录、思考、分享'
[params.homeInfoParams]
Title = 'Cloudside'
Content = '记录、思考、分享'
[[params.socialIcons]]
name = 'github'
url = 'https://github.com/Icyyan22'
[outputs]
home = ["HTML", "RSS", "JSON"]
[menu]
[[menu.main]]
identifier = 'posts'
name = '文章'
url = '/posts/'
weight = 10
[[menu.main]]
identifier = 'tags'
name = '标签'
url = '/tags/'
weight = 20
[[menu.main]]
identifier = 'search'
name = '搜索'
url = '/search/'
weight = 30
创建 archetype 模板 archetypes/default.md:
+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++
创建第一篇测试文章 content/posts/hello.md:
+++
date = '2025-05-12T00:00:00+08:00'
title = 'Hello'
+++
这是我在 Hugo 上发布的第一篇文章!
3. GitHub Actions Workflow
创建 .github/workflows/deploy.yml:
name: Deploy Hugo Site to ECS
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
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
- name: Verify build output
run: |
echo "=== public/ contents ==="
ls -la public/ || echo "public/ NOT FOUND"
echo "=== file count ==="
find public/ -type f | wc -l
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
log-public-key: false
- name: Test SSH + target dir
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
echo "=== SSH test ==="
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$DEPLOY_USER@$DEPLOY_HOST" "echo 'SSH OK'; ls -ld /var/www/hugo-site/"
- name: Deploy via rsync
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
rsync -avz --delete \
-e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
public/ "$DEPLOY_USER@$DEPLOY_HOST:/var/www/hugo-site/"
关键点:
actions/checkout@v4必须加submodules: recursive,否则 PaperMod 主题拉不下来peaceiris/actions-hugo@v3装 hugo,比手动下载二进制稳webfactory/ssh-agent管理私钥,比手动echo到文件更不容易出格式问题rsync -avz --delete确保远端和本地完全一致,删掉的文件也会被清理
这个 workflow 是当前仓库正在使用的版本,已经能稳定部署。后续可以继续加固:用 ssh-keyscan 或固定 host key secret 替代 StrictHostKeyChecking=no,并给 rsync 加 --no-owner --no-group 避免远端文件所有者变成 GitHub runner 的 UID/GID。
4. 服务器端:部署密钥 + rsync
4.1 准备部署目录
当前服务器实际是用 root 作为 DEPLOY_USER 部署,远端目录是 /var/www/hugo-site:
mkdir -p /var/www/hugo-site
我后来 SSH 复核时看到这个目录的所有者是 1001:1001,这是 GitHub Actions runner 通过 rsync 同步后留下的 UID/GID。因为文件权限是 644、目录权限是 755,darkhttpd 仍然可以读取,所以当前站点访问正常。更稳的做法是创建专用 deploy 用户,并在 rsync 时加 --no-owner --no-group,但这属于后续加固,不是当前服务器现状。
4.2 生成密钥对
在服务器上生成一对专门给 GitHub Actions 使用的 SSH key:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy -N ""
4.3 公钥授权
cat ~/.ssh/github_actions_deploy.pub >> ~/.ssh/authorized_keys
4.4 私钥塞进 GitHub Secrets
打开仓库 Settings → Secrets and variables → Actions → New repository secret:
| Secret | 值 |
|---|---|
DEPLOY_KEY | ~/.ssh/github_actions_deploy 的完整私钥内容(含 -----BEGIN/END-----) |
DEPLOY_HOST | 你的服务器公网 IP |
DEPLOY_USER | 当前服务器实际使用 root;如果后续创建专用用户,就改成对应用户名 |
4.5 装 rsync
阿里云 Linux 3 默认不带 rsync:
yum install -y rsync
没装 rsync 的话,workflow 跑到 deploy 步骤会报 rsync: connection unexpectedly closed (code 12),这是本次踩的最大坑。
5. 第一次触发
git add -A
git commit -m "init hugo site + deploy pipeline"
git push origin main
push 后去 GitHub Actions 页面看执行日志。如果一切正常,30 秒内你会看到:
- Checkout 成功
- Hugo build 成功,
public/下生成了几十到几百个文件 - SSH 测试通过,
SSH OK+ 目录信息 - rsync 成功,
sent xxx bytes received xxx bytes
6. 踩坑实录
6.1 当时踩到 directory 子目录 index.html 兼容问题
这是当时迁移环境里遇到的 Pingap 静态目录兼容问题,不是 CI 的问题,但会直接影响部署后的访问效果。见上一篇迁移文档的 10.5 节。当前建议是:新版本先重新验证 directory 插件;如果要稳妥上线,继续使用 darkhttpd(或类似轻量级 HTTP 服务器)在本机承载 Hugo 静态目录,Pingap 只负责对外反代。
6.2 服务器上没有 rsync
报错:rsync: connection unexpectedly closed (code 12),bash: rsync: command not found。
解决:yum install -y rsync。
6.3 DEPLOY_KEY 格式问题
GitHub Secrets 里粘贴私钥时,如果丢了换行符,webfactory/ssh-agent 会报 invalid format。
解决:确保复制的是完整内容,包括 -----BEGIN OPENSSH PRIVATE KEY----- 和 -----END OPENSSH PRIVATE KEY-----,中间不要手动加空格或换行。
6.4 Submodules 没拉下来
如果 workflow 里忘了 submodules: recursive,build 步骤会报 theme not found。
解决:checkout 步骤必须加 submodules: recursive。
6.5 文件 UID 变成 runner 用户
rsync 的 archive 模式会尝试保留源文件的 UID/GID。GitHub Actions runner 里的文件属于 1001:1001 这类临时用户,如果远端用 root 接收且保留 owner/group,部署后的 /var/www/hugo-site/ 可能出现奇怪的所有者。以后切回 nginx 或其他静态文件服务时,就可能遇到 403。
当前服务器就处于这个状态:/var/www/hugo-site 的 owner 是 1001:1001。因为权限仍然允许读取,所以 darkhttpd 能正常提供页面;但这不是一个干净的长期状态。
解决方法有两个:
- workflow 里的 rsync 加
--no-owner --no-group,让远端文件保持接收用户或目录默认权限。 - 已经出现问题时,按实际运行用户修正一次所有者。
如果继续用 root 部署,可以临时修正为:
chown -R root:root /var/www/hugo-site/
如果后续改成专用 deploy 用户,再改成:
chown -R deploy:deploy /var/www/hugo-site/
7. 后续更新文章的工作流
现在已经完全不需要 SSH 上服务器了:
# 1. 本地写新文章
vim content/posts/my-new-article.md
# 2. 提交并推送
git add content/posts/my-new-article.md
git commit -m "add: my new article"
git push origin main
# 3. 等 30 秒,刷新浏览器
如果某个文章还在草稿阶段,把 frontmatter 里的 draft = true 留着,hugo build 时不会生成它。想发布了就把 draft 改成 false 再 push。
8. 还没做但值得做
- 图片/资源管理:
content/posts/my-article/下建images/目录,把配图和 Markdown 放一起, Hugo 会自动处理 - 缓存优化:在 Pingap 或前置 CDN 层配置静态资源缓存,重点处理
assets/、图片、字体这类长期不变文件 - 评论系统:PaperMod 支持 Giscus(GitHub Discussions 驱动),几行配置就能接
- 全文搜索:PaperMod 支持 Fuse.js 本地搜索,build 时自动生成索引
- 自定义域名 + CDN:境内直连要处理 ICP 备案;如果走 Cloudflare 橙云,需要单独验证境内外访问路径和缓存策略