用 Zola + Linkita 把博客搬到自己的对象存储上
之前博客挂在阿里云 OSS 上,用 Caddy 反代过去。后来发现链接早就失效了——桶都不存在了,典型的「搭完就忘」。
这次干脆重做一遍,顺便把几个一直想做的事一起做掉:换个顺眼的主题、把静态托管收回到自己的存储上、把 SEO 和访问统计一次配齐。
这套组合
选型主要是想避免引入新的运维负担:
| 需求 | 选择 | 理由 |
|---|---|---|
| 静态站点生成 | Zola | 单个二进制,无 Node/Ruby 依赖,构建 < 200ms |
| 主题 | Linkita | 干净、响应式、自带 SEO 标签和 feed |
| 托管 | RustFS | 已有部署,S3 兼容,Apache 2.0 |
| 统计 | Umami | 自建,无 Cookie,数据在自己手里 |
Zola 最舒服的一点是没有 npm 那一坨。zola build 是个静态链接的二进制,扔进容器就能跑。博客这种规模,Jekyll 的 Ruby 环境和 Hexo 的 Node 环境都不太值当。
目录结构
blogs/
├── zola.toml # 站点配置
├── content/
│ ├── _index.md # 首页
│ ├── blog/ # 文章
│ └── pages/ # 独立页面(关于、归档)
├── templates/ # 覆盖主题的模板
│ └── injects/head_end.html
├── static/ # 原样拷贝的资源
├── themes/linkita/ # 主题
├── deploy/ # nginx / caddy / umami 配置
└── scripts/deploy.shtemplates/ 下的同名文件会覆盖主题里的文件,这是后面注入 SEO 结构化数据和统计脚本的关键——不用改主题源码,升级主题时也不会冲突。
对象存储怎么当 Web 服务器
RustFS 是 S3 兼容的,但它不是 Web 服务器:它不认 index.html 约定,也没有把 /path/ 映射到 /path/index.html 的逻辑。中间得有个反向代理补上这块。我复用了现有的 Nginx 统一入口:
用户 -> https://blog.laofu.online (Caddy :443)
-> http://10.100.100.2:8888 (Nginx 统一入口)
-> http://rustfs:9000/blog/<key> (path-style, 匿名只读)对象的映射关系是:
/ -> blog/index.html
/blog/foo/ -> blog/blog/foo/index.html
/app.css -> blog/app.css
/tags/zola/ -> blog/tags/zola/index.html桶名是 blog,所以 /blog/foo/ 会变成 blog/blog/foo/index.html——前缀那个 blog 是桶名。第一次看到会觉得有点怪,但这就是 path-style 的写法。
Nginx 要补齐的两件事
第一,目录索引和 URL 规范化。 不能指望 try_files,因为文件在远端不在本地磁盘,没法「试探存在性」。所以用正则 location 直接改写要转发的 URI:
# 无扩展名且不以 / 结尾 -> 301 补尾斜杠
# 只做 301, 不做内部改写: 避免同一篇文章有两个可访问 URL (重复内容)
location ~ ^(/[^.]*[^/])$ {
return 301 $1/;
}
# 首页
location = / {
proxy_pass http://rustfs:9000/blog/index.html;
}
# 目录: /foo/ -> key "foo/index.html"
# $dir 捕获到末尾斜杠之前, 拼出来正好是 blog/<path>/index.html
location ~ ^(?<dir>/.+)/$ {
proxy_pass http://rustfs:9000/blog$dir/index.html;
}
# 其余按 key 直接取 (sitemap.xml / atom.xml / app.css / favicon.ico ...)
location / {
proxy_pass http://rustfs:9000/blog$uri;
}第二,404。 对象不存在时 RustFS 返回的是 S3 的 XML 错误文档,直接吐给浏览器很难看,而且不利于 SEO。要拦截掉换成站点自己的 404 页面,同时保住 404 状态码:
proxy_intercept_errors on; # 包在上面的 include 里
error_page 404 =404 /__404.html; # =404 强制保留状态码
location = /__404.html {
internal;
proxy_pass http://rustfs:9000/blog/404.html;
}=404 这个等号不能漏。漏了的话 nginx 会用 404.html 的内容配 200 状态码返回,搜索引擎会把「页面不存在」当成正常页面收录。
三个真踩到的坑
1. Host 头必须改掉。 我的 nginx 全局配了 proxy_set_header Host $host;,反代到 RustFS 时会带上 Host: blog.laofu.online。S3 会拿 Host 去解析 bucket,结果就是所有请求 404。必须覆盖成存储端点:
proxy_set_header Host rustfs:9000;更阴的一点是:nginx 的 proxy_set_header 是整组继承——只要在 location 里写了任意一条,http 层的四条就全部失效。所以覆盖 Host 的同时,X-Forwarded-* 那些也得一起补上,否则会静默丢掉。
2. 缓存串要丢掉。 Linkita 给 css/js 挂了 ?h=<内容hash> 做缓存刷新。直接把 $request_uri 转发给 S3 的话,?h= 会被当成签名参数,可能直接报错。用 $uri 而不是 $request_uri 就解决了——带变量的 proxy_pass 里 nginx 不会自动追加 $args,查询串自然被丢掉,而浏览器那边照样按完整 URL(含 ?h=)缓存,两边都对。
3. include 的路径是相对 nginx prefix 的。 写 include snippets/blog-proxy.conf; 会去找 /etc/nginx/snippets/,而不是配置所在目录 /etc/nginx/conf.d/。要用绝对路径。
SEO
Linkita 自带的已经覆盖大半:canonical、og:*、article:published_time、article:tag、feed 的 <link rel="alternate">、og:image。
我在 templates/injects/head_end.html 里补了这些(这个文件会覆盖主题的同名文件):
1. JSON-LD 结构化数据。 文章页输出 BlogPosting + BreadcrumbList:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "用 Zola + Linkita 把博客搬到自己的对象存储上",
"datePublished": "2026-09-28T10:00:00+08:00",
"dateModified": "2026-09-30T21:30:00+08:00",
"inLanguage": "zh-CN",
"author": { "@type": "Person", "name": "bigfish" },
"timeRequired": "PT7M"
}首页输出 WebSite 并带上 SearchAction。这是让搜索引擎显示富摘要的前提。
2. Twitter Card。 主题只输出 OG 标签,但 twitter:card 是独立的一套,微信/飞书/Twitter 的分享卡片会用到:
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://blog.laofu.online/og-cover.png" />3. 时区。 这个容易忽略:front matter 里写 date = 2026-09-28(不带时区),Zola 会当成 UTC,生成的 article:published_time 就是 +00:00。对中国的博客来说应该是 +08:00:
date = 2026-09-28T10:00:00+08:004. 自动生成的两个文件。 zola.toml 里打开:
generate_sitemap = true # sitemap.xml
generate_robots_txt = true # robots.txt (内含 Sitemap: 指令)
generate_feeds = true # atom.xml / rss.xml它们里的地址都是 base_url 拼出来的绝对路径,所以 base_url 必须和线上域名完全一致,否则搜索引擎拿到一堆指向 localhost:1111 的链接,白做。
一个不得不接受的妥协:站内搜索
Linkita 的搜索基于 elasticlunr,而 Zola 的构建期索引器不支持中文分词。只要 default_language = "zh",zola build 直接失败:
ERROR: Tried to build search index for language zh which is not supported试过 zh、zh-CN、ja、cn,都不行;改成 en/fr/de 就能过。(主题里倒是带了 js/lunr/lunr.zh.min.js,但那是客户端词干库,救不了构建期。)
我选择关掉站内搜索,而不是改 default_language。因为后者会让 <html lang> 和 feed 的语言声明出错——对 SEO 来说,语言标签写错比没有搜索严重得多。主题在这个配置下会干净地把搜索按钮也一起隐藏,不会有坏掉的入口。
访问统计
统计用自建 Umami。埋点就一行脚本,放在 head_end 注入点:
<script defer
src="https://stat.laofu.online/script.js"
data-website-id="355a9974-21d0-4b4d-84a2-820942ba1d4e"
data-domains="blog.laofu.online"></script>data-domains 挺有用——本地 zola serve 的时候数据不会进生产统计。
发布
整个流程封装成了 scripts/deploy.sh:zola build → 打包 → 传到服务器 → mc mirror 进 RustFS。有两个细节值得一提。
打包必须用 tar,不能用 PowerShell 的 Compress-Archive。 后者生成的是反斜杠路径分隔符,解包后文件名里会带着 \,上传到 S3 就变成一堆 key 里含反斜杠的垃圾对象——你会在桶里看到 tags\zola\index.html 这种东西,而正常的 /tags/zola/ 全部 404。用 tar czf 生成正斜杠路径就没问题。
mc mirror 要加 --remove。 用来删掉桶里多余的旧对象。不然改了文章路径之后,旧 URL 还留在桶里被搜索引擎反复抓取,形成软 404。
因为 RustFS 的 S3 API 只在内网暴露,mc 也得在内网跑:
docker run --rm --network app-net \
-v /tmp/site:/src:ro \
--entrypoint sh minio/mc:latest -c "
mc alias set rf http://rustfs:9000 '$AK' '$SK' >/dev/null
mc mirror --overwrite --remove /src rf/blog
"缓存策略
对象存储不会给 Cache-Control,得在 nginx 里补。分两档:
# 静态资源: 有 ?h= 内容哈希, 内容变了 URL 就变, 可以放心长缓存
location ~* \.(?:css|js|woff2?|svg|png|jpe?g|webp|ico)$ {
add_header Cache-Control "public, max-age=604800" always;
}
# HTML: 短缓存 + 允许过期期间先用旧内容, 兼顾发布即时性
add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400" always;HTML 给 5 分钟,是权衡的结果:发新文章后最多 5 分钟全网可见,同时又让回访的用户吃到缓存。
小结
- 对象存储当静态站点用,缺的是目录索引和404 处理,用 nginx 正则改写 +
error_page补上 proxy_set_header Host指向存储端点,否则 S3 的 bucket 解析会让所有请求 404- 用
templates/覆盖主题模板来注入 SEO 和统计,主题可以独立升级 base_url和 front matter 的时区是 SEO 的两个隐形雷- 打包走
tar,同步走mc mirror --remove
下一步想把 zola build 挂到 CI 上,现在还是手动跑。以及把 sitemap 自动提交给 Google 和 Bing——这个目前还是手工在站长工具里点的。