为什么静态站也需要一个「版本化部署」闭环
静态站点有个很容易被忽视的运维问题:它「太简单」了,简单到你觉得不需要流程。手动 scp 上传、直接改服务器上的 HTML,一开始很爽,直到某天你改错一个文件、想回滚却发现「上一版是什么样已经记不清了」,或者上传到一半网络断了、线上留下一堆半成品文件。更麻烦的是缓存:如果不做版本管理,浏览器和 CDN 会继续拿旧文件,你明明改了却没生效,排查半天以为是代码问题。
静态站真正的护城河不是「生成快」,而是「部署可回滚、可追溯、可自动化」。把生成产物用 Git 管起来、每次发布自动带上版本号、Nginx 用原子切换指向当前版本、保留最近几版随时 ln -sfn 回滚——这套闭环一旦搭好,个人站长就能像大厂一样「一键发布、秒级回滚」,而成本只是一个 Git 仓库和几条 Nginx 配置。
本文以「本地生成静态站 → Git 推送 → 服务器钩子自动部署 → Nginx 原子切换」为主线,完整走一遍从零搭建到上线、再到回滚的流程。生成器用 Hugo 举例(Astro/Hexo/Jekyll 的产物都是纯静态文件,部署部分完全通用),重点在部署与切换机制,而非生成器本身。全程 Debian/Ubuntu,命令可直接执行。
第一步:把生成产物纳管为「发布制品」
先厘清一个概念:源站文件(Markdown、模板、配置)和生成产物(public/ 里的 HTML/CSS/JS)是两样东西。源文件你应该已经在 Git 里了;这里要把产物也变成可追溯的制品,做法是「每次发布打一个 tag,产物按 tag 归档」。这样任何历史版本都能精确重建。
# 目录约定
/var/www/mysite/
├── releases/ # 每个版本一份产物
│ ├── 20261010-143022/
│ └── 20261011-090015/
├── current -> releases/20261011-090015 # 软链,Nginx 指向它
└── shared/ # 跨版本共享(上传目录、证书等)
└── uploads/这个 releases/current/shared 三目录结构是 Capistrano/Deployer 等专业部署工具的经典布局,好处是:发布 = 新建一个 releases 子目录;切换 = 改一个软链;回滚 = 把软链指回上一个目录。没有任何「覆盖式上传」的风险,因为新旧版本物理上是不同目录,切换是原子的。
本地生成产物并做基本校验,避免把一个「空产物」或「报错页面」推上去:
# 本地:生成并自检产物
hugo --minify
test -f public/index.html || { echo "生成失败:无 index.html"; exit 1; }
# 产物里不该出现本地开发地址
if grep -rq "localhost" public/ 2>/dev/null; then
echo "警告:产物里含 localhost,可能 baseURL 配错了"; exit 1
fi
du -sh public/
find public -type f | wc -l这两条自检能挡掉最常见的两类翻车:生成器静默失败留下旧产物、以及 baseURL 没改导致线上链接全指向本机。
第二步:服务器端用 Git 钩子实现自动部署
最简的自动化方式:服务器上建一个裸仓库,本地推送到它,它通过 post-receive 钩子自动把产物展开到 releases 目录并切软链。这样你的发布动作就只是 git push,不需要手动连服务器。
# 服务器:创建裸仓库
mkdir -p /var/git/mysite.git
cd /var/git/mysite.git
git init --bare
# 建好站点目录骨架
mkdir -p /var/www/mysite/{releases,shared}
chown -R www-data:www-data /var/www/mysite然后写 post-receive 钩子。它的核心逻辑是:收到推送 → 用时间戳新建 release 目录 → 把仓库内容检出到该目录 → 建共享目录软链 → 切换 current 软链 → 清理旧版本。
#!/usr/bin/env bash
# /var/git/mysite.git/hooks/post-receive
set -euo pipefail
SITE=/var/www/mysite
RELEASE="$SITE/releases/$(date +%Y%m%d-%H%M%S)"
KEEP=5 # 保留最近 5 个版本
mkdir -p "$RELEASE"
# 把推送进来的内容检出到 release 目录(裸仓库用 --work-tree)
git --work-tree="$RELEASE" --git-dir=/var/git/mysite.git \
checkout -f
# 共享目录软链(uploads 等不随版本走)
ln -sfn "$SITE/shared/uploads" "$RELEASE/uploads"
# 原子切换 current 软链(-T 避免把它当目录,-n 避免解引用)
ln -sfn "$RELEASE" "$SITE/current.tmp"
mv -T "$SITE/current.tmp" "$SITE/current"
# 也可直接用:ln -sfn "$RELEASE" "$SITE/current"
# 清理旧版本,只留最近 KEEP 个
cd "$SITE/releases"
ls -1dt */ | tail -n +$((KEEP + 1)) | xargs -r rm -rf
chown -R www-data:www-data "$SITE"
echo "deployed: $RELEASE"chmod +x /var/git/mysite.git/hooks/post-receive关键点解释:ln -sfn 里的 -n 很重要——如果 current 已经是个指向目录的软链,不加 -n 会让 ln 把新链建到目标目录里面(变成 releases/xxx/current),这是新手第一次部署最常踩的坑。用 mv -T 的方式更稳妥,因为 mv 在同一个文件系统内是原子的重命名,不会出现「软链短暂不存在」的窗口。
第三步:本地配置远程,让 git push 即发布
# 本地:加一个远程指向服务器的裸仓库
git remote add deploy ssh://root@your-server/var/git/mysite.git
# 首次推送(把产物提交到 main 或单独的 deploy 分支)
git add public && git commit -m "release 2026-10-11"
git push deploy main:main
# 输出里应看到钩子回显:remote: deployed: /var/www/mysite/releases/20261011-090015如果你不想把 public/ 提交进主仓库,可以用一个独立的 deploy 分支专门放产物,主分支放源码,发布时 git subtree push 或用一个临时分支推送。对个人站,简单起见把产物提交进仓库也能接受——产物体积一般不大,且天然获得了「每个版本对应一个 commit」的可追溯性。
有一点务必注意:如果产物目录名(比如 public/)在你的 .gitignore 里,那么「提交产物」这个动作会被 Git 静默忽略,推上去的是空目录,部署出来的站点自然一片空白,而且 git push 还提示成功。推送前用 git check-ignore -v public/index.html 确认产物没有被忽略规则挡住;被忽略时要显式提交(git add -f public/)或干脆取消那条忽略规则。这类「推成功但部署空站」的故障排查起来非常费时,因为它同时在两个层面都「看起来正常」。
第四步:Nginx 指向 current 软链 + 缓存策略
Nginx 的 root 直接指向 current 软链即可。每次切换软链后,Nginx 会读到新的物理路径(因为它在解析路径时会跟随软链)。注意 root 用软链路径、try_files 回退到 index.html,这是静态站(尤其 SPA)的标配。
server {
listen 80;
server_name mysite.com;
root /var/www/mysite/current;
index index.html;
# 静态站常用:先找文件,找不到再回退到 404.html 或 index.html
location / {
try_files $uri $uri/ $uri.html =404;
}
# 带内容哈希的资源:长缓存、immutable
location ~* \.(js|css|woff2?|png|jpg|jpeg|webp|avif|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# HTML:不缓存,保证每次发布立即生效
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
}缓存策略是静态站「发布了却不生效」的头号元凶。原则很简单:带哈希指纹的资源长缓存,HTML 永远不缓存。Hugo/Astro 等生成器默认会给 JS/CSS 加内容哈希(如 app.3f9a2b.js),所以它们可以 immutable 缓存一年——内容变了文件名就变,不会冲突。而 HTML 文件名固定,必须让浏览器每次回来问一次,否则用户会一直看到旧版本。这套组合让部署既「瞬间生效」又「极致缓存」,是静态站相比动态站的最大性能红利。
第五步:一键回滚
因为保留了几份历史 release,回滚只是重指软链,几毫秒完成,无需重新构建:
# 服务器:列出所有版本
ls -1dt /var/www/mysite/releases/*/
# 回滚到上一版(或任意指定版本)
cd /var/www/mysite
prev=$(ls -1dt releases/*/ | sed -n '2p') # 取第二新的
ln -sfn "${prev%/}" current
echo "rolled back to $prev"
# 验证
curl -sI https://mysite.com/ | grep -i last-modified
curl -s https://mysite.com/ | grep -o '<title>' | head -1把回滚也做成脚本挂到别处(如 /usr/local/bin/rollback-mysite),出问题时你能在 10 秒内恢复,而不是手忙脚乱重新走一遍构建部署。回滚能力才是静态站部署闭环真正的价值所在——发布谁都会,能安全地「撤回来」才是专业。
第六步:部署后自动验证
别假设 git push 成功就等于「线上正常」。钩子里的 set -e 会在检出失败时中断,但「检出了错误内容」它发现不了。加一段部署后自检:
# 追加到 post-receive 末尾
if curl -sf "http://127.0.0.1/" >/dev/null; then
echo "post-deploy check: OK"
else
echo "post-deploy check FAILED — 自动回滚" >&2
cd "$SITE"
prev=$(ls -1dt releases/*/ | sed -n '2p')
ln -sfn "${prev%/}" current
exit 1
fi这段「部署自检失败就自动回滚」的逻辑,能把「推了个坏版本、站点挂了、几小时后才发现」变成「自动退回上一版,站点始终可用」。配合前面 HTTP 状态码检查(curl -sf 对 4xx/5xx 返回非零),是很廉价的一道保险。
常见坑与排查
| 现象 | 根因 | 修复 |
|---|---|---|
ln -sfn 后链嵌进旧目录 | 少了 -n,被当成目录处理 | 用 mv -T 原子切换,或严格带 -n |
| 发布成功但页面没变 | 浏览器/CDN 缓存了旧的 HTML | HTML 设 no-cache,资源带哈希长缓存 |
| uploads 图片 404 | 共享目录没软链进 release | 钩子里补 ln -sfn shared/uploads |
钩子里 git 报 not a git repository | 裸仓库要显式指定 --git-dir | 用 git --work-tree=... --git-dir=... checkout -f |
| 权限 403 | release 目录属主不是 web 用户 | 钩子末尾 chown -R www-data:www-data |
| 旧版本堆满磁盘 | 没做 releases 清理 | 钩子里 ls -1dt | tail -n +6 | xargs rm |
整套流程搭下来,你的静态站就从一个「手动传文件的小站」变成了「push 即发布、出错即回滚、版本可追溯」的正经站点。它不引入任何额外服务(没有 CI runner、没有容器),成本几乎为零,却解决了静态站最容易长期失血的一环——发布与回滚的可靠性。对个人站长来说,这种「用最少的组件拿到最大的确定性」才是值得追求的方向。