为什么不用托管平台

托管平台的静态站部署体验确实好:推代码、自动构建、全球 CDN。但它有三个隐性成本:

  1. 域名和流量被绑定——想换平台就要动 DNS。
  2. 构建分钟数有额度——博客这种低频更新场景,额度基本用不完,但一旦要跑重构建(比如图片处理),就开始算钱。
  3. 调试不透明——重定向、缓存、响应头出问题的时候,你只能猜。

而一台最低配的云服务器(2 核 4G,一年几十块)能承载的静态站,量级远超个人博客的需求。nginx 处理静态文件的能力,是被严重低估的。

一、生成器:三个依赖就够了

核心逻辑其实很短:Markdown → HTML → 写文件。需要的库只有三个:

pip install markdown pygments jinja2
作用
markdown 解析 Markdown,产出 HTML
pygments 代码块语法高亮
jinja2 套用页面模板

构建脚本的核心就三步:

import markdown, yaml
from jinja2 import Environment, FileSystemLoader
from pathlib import Path

# 1. 读文章 + 解析 frontmatter
def load_post(path):
    raw = path.read_text(encoding="utf-8")
    meta, body = split_frontmatter(raw)      # --- YAML --- 分隔
    md = markdown.Markdown(extensions=["extra", "toc", "codehilite", "tables"])
    return {**meta, "html": md.convert(body), "toc": md.toc}

# 2. 套模板
env = Environment(loader=FileSystemLoader("templates"), autoescape=True)
page = env.get_template("post.html").render(post=load_post(p))

# 3. 写盘
out = Path("dist/posts") / slug / "index.html"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(page, encoding="utf-8")

关键设计取舍:输出目录结构用 posts/<slug>/index.html 而不是 posts/<slug>.html。这样 URL 里没有 .html 后缀,将来想换生成器也不用改任何链接。

二、nginx 配置:短,但每一行都有用

server {
    listen      80;
    listen      [::]:80;
    server_name mirror-works.net www.mirror-works.net;

    root  /home/admin/blog/dist;
    index index.html;

    # 无后缀 URL → 找同名目录
    location / {
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;

    # 静态资源长缓存:文件名带指纹才敢这么干,
    # 否则改样式用户看不到
    location ~* \.(css|js|svg|png|jpg|webp|woff2)$ {
        expires 7d;
        add_header Cache-Control "public, max-age=604800";
    }

    gzip            on;
    gzip_min_length 512;
    gzip_types      text/plain text/css application/javascript
                    application/json text/xml application/xml image/svg+xml;

    add_header X-Content-Type-Options   nosniff      always;
    add_header X-Frame-Options          SAMEORIGIN   always;
    add_header Referrer-Policy          strict-origin-when-cross-origin always;
}

四个容易踩的点:

  • try_files 的顺序必须是 $uri$uri/,反过来目录会优先,性能更差。
  • gzip_types 不会自动包含 text/html——nginx 默认已经为 text/html 开了 gzip,重复写反而会覆盖掉默认值。
  • 缓存时间要和构建策略匹配。 如果 CSS 文件名里没有内容哈希(hash),max-age=604800 会让你改完样式刷新十次都看不到效果。稳妥做法是把缓存降到 1 小时,或者加 ?v=<构建时间戳>
  • add_headerlocation 里会覆盖外层同名头,所以要么都写 always,要么都放同一层。

三、HTTPS:交给 Cloudflare

自己签 Let’s Encrypt 证书需要 80/443 都通、还要配 certbot 的自动续期。如果域名本来就托管在 Cloudflare,直接开代理模式(小云朵变橙)更省事:

  • 源站只需要暴露 80 端口
  • TLS 在边缘终结,证书自动续
  • 顺手拿到 DDoS 防护和 CDN 缓存

唯一的注意点:Cloudflare 的 SSL 模式必须从 Flexible 改成 FullFull (strict),否则源站到边缘这一段是明文,而且会触发重定向循环。

# 验证 HTTPS 与响应头
curl -sI https://mirror-works.net | grep -iE "http/|server|cf-cache-status|strict-transport"

四、自动化:一条 cron 解决发布

写文章和发布彻底解耦——文章就是仓库里的 .md 文件,发布是构建:

# 每 10 分钟检查一次是否有新 commit,有就重建
*/10 * * * * cd /home/admin/blog && \
  git pull -q --ff-only && \
  python3 build.py >> /var/log/blog-build.log 2>&1

如果想要更快的反馈,加个 inotifywait 监听 posts/ 目录,文件一变就重建:

inotifywait -m -e close_write -e moved_to posts/ | while read _ _ file; do
  case "$file" in *.md) python3 build.py ;; esac
done

五、这套方案的真实边界

诚实地讲,它不适合:

  • 日均百万 PV 的站点——没有分布式缓存,单机的连接数有上限
  • 需要动态能力的场景——评论、搜索服务端渲染、用户系统,这些得另外搭
  • 需要全球低延迟——单一地域的服务器在跨洲访问时延迟会明显

但对一个个人技术博客来说,它的性价比高得离谱:没有构建额度、没有平台锁定、没有隐藏账单,出问题时你手上有一整个 shell 可以排查。

可控性不是性能指标,但它是你在半夜三点出问题时唯一在乎的东西。