从写完到上线:我的 Hugo 博客发布流程
记录我的 Hugo 博客发布流程:本地写作与预览、Gitee 推送、服务器 webhook 自动构建,以及用 Claude Code skill 把整套流程自动化。
博客写了几年,发布方式换过好几次。最早是本地 hugo 生成后手动上传,后来改成 Git 推送、服务器拉取,再到现在的 Gitee webhook 自动部署。最近又把整套流程整理成了一个 Claude Code skill:文章可以以任何形式丢过来——聊天里的一段话、Obsidian 笔记、Markdown 文件、几张截图——它会帮我整理、配图、预览、提交、推送,剩下的交给服务器。
这篇文章记录这套流程的具体步骤,以及中间踩过的坑。
一、整体链路
先看全貌:
写作 / 笔记 / 截图
↓
整理、脱敏、配图
↓
写入 content/posts/<slug>/
↓
hugo server 本地预览
↓
hugo -d /tmp/... 生产构建验证
↓
git commit + git push origin master
↓
Gitee webhook → 服务器 git pull + hugo
↓
Nginx 站点 https://eatta.cn
技术栈很简单:
- Hugo(extended)
- Macchiato 主题(vendored 在
themes/macchiato,不再用 submodule) - Gitee 仓库
- Linux 服务器 + Nginx
- Gitee webhook 触发部署脚本
核心原则只有一句:Git 仓库是唯一的内容源,服务器只负责构建,不在服务器上改内容。
二、文章目录:page bundle 优先
文章放在 content/posts/ 下。带图片的文章用 page bundle:
content/posts/
└── hugo-blog-publishing-workflow/
├── index.md
└── cover.jpg
不带图片的可以用单文件 content/posts/<slug>.md,但默认还是 bundle,后面加图方便,图片和正文也不会走散。
Front matter 用 TOML:
+++
date = '2026-11-13T18:11:21+08:00'
draft = false
title = '从写完到上线:我的 Hugo 博客发布流程'
slug = 'hugo-blog-publishing-workflow'
tags = ['Hugo', 'Gitee', '博客', '自动化']
categories = ['技术分享']
keywords = ['Hugo', 'Gitee', 'Webhook', '自动部署', '博客发布']
description = '记录我的 Hugo 博客发布流程:本地写作与预览、Gitee 推送、服务器 webhook 自动构建。'
summary = '从 Markdown 草稿到 eatta.cn 上线,我的博客发布已经跑成一条固定流水线。'
+++
几个约定:
slug用英文小写连字符,文章 URL 是/posts/<slug>/。- 已有文章的 slug 绝对不改。改 slug 等于换 URL,搜索引擎索引和外部链接都会断。
description给搜索引擎和社交卡片用;summary给列表页和 RSS 用。两个都手写,不依赖 Hugo 自动截断。categories尽量复用已有分类,tags可以中英文混用。- 更新旧文章时保留原
date,只加lastmod。
三、图片:HEIC 先转 JPEG,顺手去掉 EXIF
我用 iPhone 拍照,原图是 HEIC。Hugo 的图片处理不直接支持 HEIC,所以先转成 JPEG:
magick input.HEIC -auto-orient -resize '1600x1600>' -strip -quality 90 \
content/posts/<slug>/cover.jpg
三个参数很关键:
-auto-orient:按 EXIF 方向摆正,避免照片横着。-resize '1600x1600>':长边限制在 1600px,网页够用,文件也小。-strip:去掉 EXIF、GPS、相机信息。这一步是隐私保护,别省。
正文里直接引用文件名:

不要加 ./。站点里有一个渲染钩子 layouts/_default/_markup/render-image.html,它会自动把 JPEG/PNG 转成 WebP(q85),加上 loading="lazy"、width、height。WebP 和 GIF 原样输出。
图片放 page bundle,不要放 static/:static/ 里的图片不经过 Hugo 的图片处理,不会自动转 WebP。
四、本地预览:一定要加 --renderToMemory
写完先本地看:
cd /path/to/eatta-blog
hugo server --bind 127.0.0.1 --port 1313 --renderToMemory
--renderToMemory 是我踩过坑之后加的。hugo server 默认会把构建结果写进 public/,而构建产物本来就不该进仓库,一跑预览就冒出一堆改动,提交时很容易混进去。加了 --renderToMemory,预览只在内存里渲染,不碰工作区。
--bind 127.0.0.1 是只监听本机,不把预览暴露到局域网。
预览时我会检查:
- 页面返回 200,标题和 description 正确
- 目录、表格、代码块、上下篇正常
- 图片能打开(直接
curl图片 URL,确认 200) - 手机宽度下没有横向滚动
小坑:headless Chrome 截图时,
loading="lazy"的图片可能还没加载,截图里是空白。这不代表页面有问题,直接请求图片 URL 验证就行。
五、构建验证:先构建到 /tmp
预览没问题后,再做一次生产构建,但输出到临时目录:
hugo -d /tmp/eatta-build --cleanDestinationDir
检查三件事:
ls /tmp/eatta-build/posts/<slug>/index.html
grep 'https://eatta.cn/posts/<slug>/' /tmp/eatta-build/sitemap.xml
ls /tmp/eatta-build/posts/<slug>/*.webp
- 文章页生成了
- sitemap 包含新 URL
- 图片 WebP 生成了
不要把生产构建直接跑进 public/。服务器会做最终构建,本地 public/ 里的东西不参与部署,提交它们只会制造噪音。
六、提交与推送:只提交内容源文件
确认无误后提交:
git status --short -- content/
git add content/posts/<slug>/
git commit -m "new post: <slug>"
git push origin master
更新旧文章时,提交信息用 update post: <slug>。
这里有一条纪律:只 git add 文章目录/文件,不要 git add -A。 本地可能残留 public/、resources/_gen/ 的构建缓存,git add -A 会把它们一起带进去。部署由服务器构建,这些文件不需要进仓库。
七、服务器:webhook 自动构建
推送之后就不需要再登录服务器了。Gitee 的 webhook 打到服务器上的一个部署脚本,脚本只做三件事:拉最新代码、跑一次构建、把产物放进 Nginx 的站点目录。所以整个发布动作可以概括为:
push 即发布。
具体命令、站点目录和 webhook 配置就不展开了——那些都是服务器上的信息,公开出来等于给扫描器递地图。比“抄脚本”更值得记住的是两条:
- webhook 必须校验 secret(或至少限制来源 IP),否则任何人都能伪造一次 push 事件触发构建。
- 部署脚本用最小权限账号跑,只需要读仓库、写站点目录,别给它多余权限。
以前用 submodule 引主题时,脚本里还有一步 git submodule update --init --recursive;后来把主题 vendored 进仓库,这一步就去掉了,少了一个可能失败的外部依赖。
八、线上验证:推送成功不等于发布成功
推送成功只说明代码到了 Gitee,不代表服务器已经构建完成。所以每次发布后我都会验一遍:
curl -sS -o /tmp/live.html -w '%{http_code}\n' \
https://eatta.cn/posts/<slug>/
grep -c '文章里的独特短语' /tmp/live.html
curl -s https://eatta.cn/sitemap.xml | grep <slug>
检查:
- HTTP 200
- 标题、description 正确
- 文章里的关键内容已经出现
- 图片 URL 200,类型是
image/webp - sitemap 里有新 URL
webhook 构建通常几秒到几十秒。如果页面还是旧的,等一会儿再试;如果一直不更新,就去服务器看部署日志和 Hugo 的构建输出。
九、把这套流程交给 Claude Code
流程固定下来之后,我把它写成了一个 Claude Code skill,叫 publish-blog-post。现在发布文章可以变成一句话:
把这篇发到博客。
然后把文章、笔记或者截图丢过去。skill 会按上面的步骤执行:
- 判断是新文章还是更新旧文章;更新时保留原 slug 和 URL
- 整理正文,检查并脱敏密钥、用户名、主机名、内网 IP、手机号
- 处理图片:HEIC 转 JPEG、限制 1600px、去掉 EXIF/GPS
- 补全 front matter:description、summary、keywords、标签、分类
- 本地预览,验证 200、图片、目录、表格
- 构建到临时目录,检查 sitemap 和 WebP
- 只提交
content/源文件,推送 Gitee - 线上验证,确认关键内容已经出现
默认是直接发布,不会每一步都停下来问。如果我想先看,就说“先给我看”或者“先别发”,它会停在预览这一步。
当然,自动化不代表无脑。遇到这几类情况,它应该停下来问我:
- 文章里有无法安全脱敏的密钥、密码、token
- 图片或截图里有账号、聊天记录、内部系统信息
- 内容是整段搬运别人的文章,版权不确定
- 价格、时间、技术细节没法确认
十、几个踩过的坑
最后列一下这套流程里踩过的坑,算是给以后的自己留个备忘:
- 别提交开发服务器构建的
public/。 里面可能有localhost:1313的 canonical、sitemap、搜索索引和 livereload 脚本。 hugo server默认会写public/。 用--renderToMemory。- 不要改旧文章的 slug。 URL 会变,索引会丢。
- HEIC 一定要
-strip。 照片里的 GPS 坐标比想象中多。 - 图片放 page bundle。
static/不参与 Hugo 图片处理。 - 只提交
content/。 构建产物交给服务器。 - 推送后要验线上。 webhook 也会失败,别假设一定成功。
- 笔记里的密钥先撤销再发布。 脱敏只能去掉文本,已经泄露的密钥要轮换。
结尾
这套流程最大的变化不是“快了多少”,而是发布不再需要决策。写完之后,整理、预览、提交、部署、验证都按固定路径走,我只需要判断内容本身值不值得发。
发布不该是负担,写作才是。