Typecho、Hugo、Hexo 这些博客方案都有一个共同的痛点:Markdown 写完要等渲染,主题换一次要重新构建,插件多了构建时间从 3 秒涨到 40 秒。Zola(Rust 写的单二进制静态站生成器)和 Eleventy(Node 生态的 JS 生成器)是目前两个比较务实的选择:前者编译极快、零依赖、二进制丢到服务器上就能跑;后者模板语言灵活、生态大、能直接读 JSON/YAML 做数据驱动页面。这篇讲的是从搭建到部署到 Nginx 上的完整流程,重点放在个人站长真正会踩的坑上:URL 与旧站不兼容、构建产物权限、缓存头设置、以及增量发布的取舍。
一、Zola 与 Eleventy 到底差在哪
选型先看两件事:你的内容规模,和你能不能忍受 Node 生态的依赖树。
Zola 是单个可执行文件,没有 node_modules,没有 package-lock 冲突,主题是打包在仓库里的模板文件。它用 Tera 模板引擎(语法接近 Jinja2),配置全部在一个 config.toml 里。一个 500 篇文章的站点,在 1 核 VPS 上全量构建通常不到 2 秒。缺点是插件生态几乎没有,想要额外的功能只能改模板或写 Tera 宏。
Eleventy 是 Node 包,依赖用 npm 管理,模板语言可选 Nunjucks、Liquid、Handlebars、EJS、Pug,还能混用。它的强项是数据驱动:把 JSON、CSV、远程 API 的返回放在 _data/ 目录里,模板直接遍历。缺点是 node_modules 通常几十 MB 到几百 MB,构建前必须 npm ci,在内存小的机器上偶尔会因 GC 抖动慢到几秒。
# Zola:一个二进制文件,一条命令
zola build # 产物在 public/
zola serve --interface 0.0.0.0 --port 1111
# Eleventy:需要 node 环境
npm ci
npx @11ty/eleventy # 产物默认在 _site/
npx @11ty/eleventy --serve判断标准很简单:如果你只是写文章、偶尔改主题样式,选 Zola;如果你需要根据外部数据生成页面(比如把 TMDB 的影视数据渲染成分类页),选 Eleventy。
二、Zola 的最小可用骨架
先建目录,再把配置写清楚。Zola 的 URL 生成规则由 config.toml 里的 [permalinks] 决定,这一块决定了你能不能兼容旧站链接,别等构建完才发现所有 URL 都变了。
base_url = "https://www.example.com"
compile_sass = true
build_search_index = true
feed_filename = "atom.xml"
taxonomies = [
{ name = "tags", feed = true },
]
[markdown]
highlight_code = true
highlight_theme = "base16-ocean-dark"
smart_punctuation = false
[permalinks]
posts = "/:year/:slug/"
文章放在 content/posts/ 下,每篇一个 .md,front matter 用 TOML:
+++
title = "Nginx 静态站的缓存头该怎么写"
date = 2026-10-20
[taxonomies]
tags = ["nginx", "cache"]
[extra]
summary = "从 immutable 到 stale-while-revalidate,逐条说明。"
+++
正文第一段可以留空,摘要取 summary 字段。slug 字段如果不写,Zola 会用文件名。中文文件名会产生 URL 编码的问题,所以一定要显式写英文 slug:
+++
title = "静态站部署到 Nginx 的完整流程"
date = 2026-10-20
slug = "deploy-static-site-to-nginx"
+++zola build 之后 public/ 目录就是完整的站点,里面每个文章一个 index.html,路径是 /posts/2026/deploy-static-site-to-nginx/index.html。这个目录结构直接决定 Nginx 的 try_files 该怎么写。
三、Eleventy 的数据驱动与增量构建
Eleventy 的最小骨架需要 .eleventy.js(新版叫 eleventy.config.js)和一个输入目录:
module.exports = function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("assets");
eleventyConfig.addPassthroughCopy("admin");
eleventyConfig.addFilter("date", function (d) {
return new Date(d).toISOString().slice(0, 10);
});
return {
dir: { input: "src", output: "_site", includes: "_includes", data: "_data" },
templateFormats: ["md", "njk", "html"],
markdownTemplateEngine: "njk",
htmlTemplateEngine: "njk",
};
};数据文件是它最实用的部分。src/_data/movies.json 会被自动加载成模板里可用的 movies 变量,遍历即可生成几百个详情页:
---
pagination:
data: movies
size: 1
alias: movie
permalink: "/movie/{{ movie.slug }}/index.html"
---
<h1>{{ movie.title }}</h1>
<p>{{ movie.overview }}</p>这段 front matter 会为 movies 数组里的每一项生成一个页面,permalink 里用 slug 拼路径。这正是 Eleventy 相对 Zola 的优势场景:Zola 要做同样的事得靠 Tera 的模板继承加上顶层 section 逻辑,写起来绕得多。
增量构建上,Eleventy 提供 --incremental,但它在使用 pagination 和数据文件时表现不稳定,经常需要全量重建才正确。Zola 没有增量构建,但它全量构建只要两秒,实际上也不需要。所以别把"支持增量"当成优势,对几百篇规模的站,全量构建是最省心的方案。
四、部署到 Nginx:三种方式与各自的风险
静态站的部署方式其实是三个不同的权衡,选错了后期维护会很难受。
方式一:本地构建后 rsync 到服务器。最简单,也最适合个人站。构建在你自己的机器上完成,服务器只负责放文件。
zola build
rsync -avz --delete --exclude 'uploads/' \
public/ deploy@host:/srv/www/blog/--delete 会删掉目标端多余的文件,这是它和普通同步的关键区别:文章删了、改 slug 了,旧页面必须一起清掉,否则搜索引擎会一直在旧 URL 上抓到 200 的僵尸页。--exclude 'uploads/' 是为了保护用户上传目录——如果你的 CMS 和静态站共用一个域名,uploads 是唯一的状态数据,被 --delete 删掉就全没了。这个坑一旦踩过一次,一定要养成写 exclude 的习惯。
方式二:服务器上构建,产物直接写 web 根目录。适合有 CI 或定时任务的场景。
cd /srv/src/blog
git pull --ff-only
zola build --output-dir /srv/www/blog.new
rsync -a --delete /srv/www/blog.new/ /srv/www/blog/
rm -rf /srv/www/blog.new注意先构建到新目录再同步,而不是让生成器直接输出到正在服务的目录。原因很简单:直接输出时,构建的几十毫秒里访客可能拿到半成品页面(缺 CSS、缺图片)。虽然静态站构建很快,但并发访客碰到这一步的概率并不为零。
方式三:容器里构建,产物挂载出来。如果你已经在用 Docker,可以让构建容器只负责产出,Nginx 容器只负责服务,两者通过 volume 共享目录。这种方式的好处是构建环境可复现,坏处是多了一个容器和一次数据卷同步,对个人站是负收益。
五、Nginx 配置:静态站与动态站最大的不同
静态站的 Nginx 配置比 PHP 站简单,但有几个地方必须特别对待。首先是 try_files,Zola 输出的是目录形式(posts/xxx/index.html),Eleventy 也是 index.html 形式,所以两种写法都能兼容:
server {
listen 443 ssl http2;
server_name www.example.com;
root /srv/www/blog;
index index.html;
location / {
try_files $uri $uri/ $uri.html =404;
}
location = /404.html {
internal;
}
error_page 404 /404.html;
}try_files $uri $uri/ $uri.html =404 这三段依次尝试:精确文件、目录下的 index、加 .html 后缀,全都没有才返回 404。最后必须是 =404,不能写成 /index.html——后者会让所有不存在的 URL 都返回首页内容且状态码 200,直接制造大量重复页面,Search Console 里会立刻出现成百上千的"重复网页"。
缓存头的设置分三类,这是静态站最容易做错的地方:
# 1) 带指纹的静态资源,永久缓存
location ~* \.(css|js|woff2|png|jpg|webp|svg)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# 2) HTML 页面,短缓存 + 回源校验
location ~* \.html$ {
add_header Cache-Control "public, max-age=0, must-revalidate";
add_header X-Static "1";
}
# 3) sitemap 与 feed,中等缓存
location ~* ^/(sitemap\.xml|atom\.xml|robots\.txt)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
}关键点在于:带指纹的资源和 HTML 必须分开设缓存。如果给 CSS/JS 设了一年 immutable 却没做文件名指纹(比如一直叫 style.css),那改样式后所有老访客一年内都看不到更新,这是静态站最常见的"我改了但线上没变"事故。Zola 和 Eleventy 默认都不给资源加 hash,需要在模板里自己做:
{# Zola: 用内置 hash 过滤器 #}
<link rel="stylesheet" href="{{ get_url(path='style.css', cachebust=true) }}"><!-- Eleventy: 用构建时间戳或内容 hash -->
<link rel="stylesheet" href="/assets/style.css?v={{ buildTime }}">cachebust=true 会在 URL 后加 ?h=内容哈希,内容变了链接就变了,老访客自然会拿到新文件。用构建时间戳的写法更粗暴但有效,唯一的问题是每次构建所有资源都会失效,CDN 回源量会涨一波,个人站可以忽略。
六、迁移旧站的 URL:最容易丢流量的地方
如果你的站原来跑 Typecho/WordPress,有几百条 /123.html 形式的旧链接,换静态站后这些 URL 必须全部 301 到新地址,否则就是几百个 404,收录会掉一大截。做法是在 Nginx 里维护一张映射表:
map $uri $new_uri {
default "";
~^/(\d+)\.html$ /posts/legacy/$1/;
/old-feed.xml /atom.xml;
}
server {
if ($new_uri != "") {
return 301 $new_uri;
}
}更稳妥的方式是直接在构建阶段生成这些跳转页:在 content/posts/ 里放一份 aliases 的映射,构建时生成一个带 <meta http-equiv="refresh"> 的 HTML。
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<link rel="canonical" href="https://www.example.com/posts/2026/new-url/">
<meta http-equiv="refresh" content="0; url=https://www.example.com/posts/2026/new-url/">
<meta name="robots" content="noindex">
</head><body></body></html>两种做法可以并存:Nginx 层做粗粒度正则跳转,构建层针对特殊文章生成跳转页。注意跳转页必须带 canonical 和 noindex,否则会被当成内容重复的低质量页面。另外,跳转页只保留三个月到半年就够,搜索引擎完成重新抓取后可以删掉,一直留着会让站点里堆一堆空页面。
七、发布流程与验收清单
把发布固化成一个脚本,每次只跑一条命令。脚本里做四件事:构建、预检、同步、抽查。
#!/usr/bin/env bash
set -euo pipefail
cd /srv/src/blog
zola build
# 预检:首页与 sitemap 必须存在且非空
test -s public/index.html
test -s public/sitemap.xml
# 同步(保留 uploads)
rsync -a --delete --exclude 'uploads/' public/ /srv/www/blog/
# 抽查:首页 200、随便一篇 200、不存在的 URL 必须 404
curl -s -o /dev/null -w 'home %{http_code}\n' http://127.0.0.1/
curl -s -o /dev/null -w 'notfound %{http_code}\n' http://127.0.0.1/this-should-not-exist最后那条"不存在的 URL 必须 404"是这个脚本里最有价值的一行。静态站最典型的事故就是 try_files 兜底写错,所有错误 URL 都返回 200 的首页,几周后搜索引擎把整个站点判为软 404。每次发布顺手跑一下,能省掉后面很多的排查时间。
再给一个上线后的观测习惯:每周看一次 Search Console 的"网页索引"报告,重点盯两个数字——404 数量和"已抓取但未编入索引"的数量。静态站因为构建速度快,很容易不小心生成大量重复的标签页、分页页,这些页面本身没有错,但数量失控会稀释站点的抓取预算。在模板里给分页页加 noindex, follow,给标签页保留但限制每页数量,是比较常规的处理方式。
最后提醒一句:静态站没有数据库,意味着没有评论、没有搜索、没有访问统计的写入口。评论可以接第三方(或用 GitHub Issues 方案),搜索可以用 Zola 内建的 build_search_index 加前端 JS 检索,统计用服务端的 access log 分析即可。把这些提前想清楚,换静态站才不会在发布第二天发现"少了半个网站"。