静态博客的一键部署:从zola build到nginx的零停机链路
每次写文章都要手动scp、ssh、zola build、cp到webroot——少了一步页面就断链,多了一步手指就疼。
静态博客的部署看起来简单:build完把文件丢到nginx目录就完事。但简单不等于可靠。手动操作越多,出错的概率越大。今天早上我就遇到了一次——build完忘了cp到webroot目录,nginx还是昨天的版本。
这不是什么高级话题,但恰恰是最容易被忽视的运维环节。这篇文章整理一下我们日常用的一套部署方案:从ssh直连到一键脚本,从rsync增量同步到原子化零停机部署。
适用任何静态站点生成器——Zola、Hugo、Hexo、Jekyll,逻辑都一样。
一、你现在的部署是怎么断的
先摆事实。大多数人(包括我们)在很长一段时间里的部署流程是这样的:
本地写完文章
→ scp到VPS的content目录
→ ssh登录
→ cd /var/www/zola-blog-src && zola build
→ cp -r public/* /var/www/blog.cn-res.vip/
五步。全是手动。每步都是断点:
- scp之后忘了ssh —— 文章在content目录躺着,没build,线上没有。
- build之后忘了cp —— 新页面生成了但没到webroot,nginx还是旧的。
- cp方向写反了 ——
cp -r /webroot/* public/反向覆盖,源码被清空。 - cp中途断掉 —— 部分文件到位部分没到,线上出现404。
- 前后两次build的public目录不完全一致 —— 旧文件残留,把不该暴露的页面暴露了。
这些问题单个看都"不会发生",但累积次数一多,你会发现每个月至少出一次。不是人的问题,是流程的问题。
手动流程的不可靠性不是操作者不够细心——是人的注意力无法在重复性任务上长期保持100%正确率。这是认知科学的结论,不是借口。
所以要上自动化。不是因为你懒,是因为你不可能永远不出错。
二、最小可行方案:一个deploy.sh
最简单的自动化就是从这五步里提炼出一个脚本。放在VPS上,每次写完文章,本地跑一条命令。
#!/bin/bash
# deploy.sh - Zola博客一键构建部署
set -euo pipefail
SOURCE_DIR="/var/www/zola-blog-src"
PUBLIC_DIR="$SOURCE_DIR/public"
TARGET_DIR="/var/www/blog.cn-res.vip"
BUILD_LOG="/tmp/zola-build-$(date +%Y%m%d-%H%M%S).log"
echo "[1/3] Building site..."
cd "$SOURCE_DIR"
zola build > "$BUILD_LOG" 2>&1
echo " Build log: $BUILD_LOG"
echo "[2/3] Deploying to $TARGET_DIR..."
cp -r "$PUBLIC_DIR"/* "$TARGET_DIR"
echo "[3/3] Done. $(find "$PUBLIC_DIR" -type f | wc -l) files deployed."
几个要点:
set -euo pipefail 是bash脚本的保命符:
-e:任何命令失败就退出,不继续执行后面的危险操作。-u:使用未定义的变量就报错,防止rm -rf $UNDEFINED_DIR/*这种灾难。-o pipefail:管道中任何一段失败就算整体失败。
Build log写文件:万一build失败,还能回去查原因。输出到标准输出也可以,但写文件方便回头看。
但这个方案有个竞态条件问题:cp -r不是原子的。如果cp执行过程中nginx处理了请求,客户端可能看到部分新文件、部分旧文件——页面样式错乱,或者新页面引用了旧页面的资源,404。
三、原子化部署:ln -sf 换链
解决竞态条件:不让nginx直接从working dir读文件,而是用一个符号链接指向当前版本。
/var/www/blog.cn-res.vip/ → 符号链接
├── current → releases/2026-06-16-152030
├── releases/
│ ├── 2026-06-15-090000
│ ├── 2026-06-16-152030 ← 当前
│ └── 2026-06-16-180000 ← 下一个
nginx的root配置不变,永远指向/var/www/blog.cn-res.vip/current。部署的时候:
#!/bin/bash
set -euo pipefail
SOURCE_DIR="/var/www/zola-blog-src"
RELEASES_DIR="/var/www/blog.cn-res.vip/releases"
CURRENT_LINK="/var/www/blog.cn-res.vip/current"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
echo "[1/4] Building site..."
cd "$SOURCE_DIR"
zola build
echo "[2/4] Copying to release dir..."
mkdir -p "$RELEASE_DIR"
cp -r "$PUBLIC_DIR"/* "$RELEASE_DIR"
echo "[3/4] Atomic swap..."
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
echo "[4/4] Deployed: $TIMESTAMP"
ln -sfn是整个方案的核心。符号链接的替换在Linux上是原子的——内核保证任何时刻读取current链接要么指向旧版本,要么指向新版本,不存在中间状态。
cp要花几百毫秒到几秒。ln耗时是微秒级的。nginx感知不到切换。
有人会说"nginx有缓存,换链接有什么用"——nginx缓存的是文件内容,不是路径。文件路径变了(新版本目录),nginx自然去读新路径。
四、版本留几份?回滚
原子化部署附带了一个免费福利:版本管理。
每次部署都是一个带时间戳的目录。不急着删。留最近5~10个版本,随时可以回滚。
回滚就是一条命令:
# 回滚到上一个版本
ln -sfn "$(ls -1d /var/www/blog.cn-res.vip/releases/* | tail -2 | head -1)" /var/www/blog.cn-res.vip/current
# 或者指定版本
ln -sfn /var/www/blog.cn-res.vip/releases/2026-06-15-090000 /var/www/blog.cn-res.vip/current
没有数据库迁移问题,没有API兼容性问题,没有缓存预热问题。静态站点的回滚就是换一个链接——纯文件操作,一秒内完成。
清理旧版本可以加个定时任务:
# 保留最近10个版本,其余删除
cd /var/www/blog.cn-res.vip/releases
ls -1d */ | head -n -10 | xargs -r rm -rf
或者在deploy.sh里内嵌:
# 部署完成后清理旧版本
KEEP=10
cd "$RELEASES_DIR"
ls -1d */ 2>/dev/null | head -n -$KEEP | xargs -r rm -rf
五、增量部署:cp换成rsync
对于小站点(几百个文件),cp -r没什么问题。但如果你有几千个页面、图片、PDF,全量拷贝每次都要几秒到几十秒。而且cp每次都是全量复制,不管文件有没有变化。
换成rsync:
rsync -a --delete "$PUBLIC_DIR/" "$CURRENT_DIR/"
参数含义:
-a:递归+保留权限/时间戳/所有权--delete:删除目标端有但源端没有的文件(清理旧页面)
但这里有个微妙的问题。--delete配合rsync直接写到webroot的做法,和上面原子化部署方案是两种不同的哲学。如果你用原子化部署(releases目录+符号链接),你可以直接用cp全量拷贝到新版本目录,不需要rsync,因为每次都是全新的目录。
什么时候用rsync? 当你不想用releases目录方案、直接在webroot上操作,而且每次变动量不大的时候。rsync的好处是只有变化的文件才传输,适合慢速连接(比如我们的HK VPS只有5Mbps带宽)。
# 直接同步到webroot的方案(无原子化)
rsync -av --delete --checksum "$PUBLIC_DIR/" "$TARGET_DIR/"
--checksum按文件内容校验而不是按修改时间,避免时间戳不一致导致不必要的传输。代价是多花一点CPU。
推荐方案:直接用cp到releases目录 + 符号链接原子切换。 全量拷贝在小站点上每次也就几百毫秒,不值得为了省这几百毫秒引入rsync的复杂性。版本回滚才是真正的高频价值功能。
六、自动触发:git post-receive hook
脚本+手动执行已经比五步操作好多了。但还能更进一步:git push自动触发部署。
在VPS上建一个bare repo,加一个post-receive hook:
#!/bin/bash
# /var/repo/blog.git/hooks/post-receive
# 当收到git push时,自动部署
GIT_DIR="/var/repo/blog.git"
WORK_TREE="/var/www/zola-blog-src"
while read oldrev newrev refname; do
branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
if [ "$branch" = "main" ]; then
echo "=== Deploying $branch ==="
git --work-tree="$WORK_TREE" --git-dir="$GIT_DIR" checkout -f main
cd "$WORK_TREE"
/usr/local/bin/deploy.sh
fi
done
本地工作流就变成了:
git add .
git commit -m "新文章:好人综合税"
git push vps main # 自动构建+部署
但有一个安全注意事项:git push的时候,VPS上运行的deploy.sh脚本需要检查build是否成功,然后再做原子swap。set -euo pipefail在这里不是锦上添花——是刚需。
如果构建失败还继续部署,线上就是404或者半成品。git hook里调用deploy.sh的时候,必须捕获错误:
if /usr/local/bin/deploy.sh; then
echo "=== Deploy successful ==="
else
echo "=== Deploy FAILED — site unchanged ==="
exit 1
fi
七、不要过度工程
看到这里,你可能觉得"这么简单的事写这么长"。
但恰恰因为简单,才值得一次性搞好。运维的价值不在于架构有多复杂,在于日常操作有多无脑。
一个合格的生产部署方案应该满足四条标准:
- 一键完成:一个命令或一次push,没有中间手动步骤。
- 原子切换:部署过程中不存在"正在更新"的中间状态。
- 可回滚:任何时候可以回到上一个已知正常版本,步骤不超过两步。
- 失败安全:构建失败不覆盖线上版本,脚本错误不造成数据损失。
满足这四条,就是一个合格的部署方案。不满足——无论你的博客有多小,无论访问量有多大——你的部署都有可预见的断点。
而我们最终用的方案是最简单的那种:
rsync本地 → git push到VPS → post-receive hook触发 →
zola build → cp到releases/timestamp目录 → ln -sfn原子切换 → 清理旧版本
七步,但全是自动的。每次发文章,只做一件事:git push。
这就够了。
One-Click Static Blog Deployment: Zero-Downtime from Zola Build to Nginx
Every article involved the same manual dance: scp the file, ssh in, zola build, cp to webroot. Miss one step and the site breaks. Do all five and your fingers hurt.
Static blog deployment looks simple — build the site and drop files into nginx's root. Simple doesn't mean reliable. The more manual steps, the higher the failure rate. I caught myself this morning: built the site, forgot to cp it to the webroot. Nginx was still serving yesterday's version.
This isn't an advanced topic. It's the kind of ops work everyone knows they should automate but rarely does until something breaks. This article walks through the pipeline we actually use: from manual SSH to one-click script, from rsync incremental sync to atomic zero-downtime swaps.
Works for any static site generator — Zola, Hugo, Hexo, Jekyll. The logic is identical.
I. Where Your Pipeline Breaks
Let's state the facts. Most people (including us) deploy like this:
Write article locally
→ scp to VPS content directory
→ ssh in
→ cd source && zola build
→ cp -r public/* /var/www/blog/
Five steps. All manual. Every step is a failure point.
- scp done, ssh forgotten — article sits in content/ unbuilt. Site unchanged.
- build done, cp forgotten — new pages exist nowhere but the build cache.
- cp direction reversed —
cp -r /webroot/* public/overwrites your source. - cp interrupted mid-stream — partial files on disk, 404s all over the page.
- Two consecutive builds produce slightly different file sets — stale
.htmllingers, exposing drafts.
Each failure "would never happen" in isolation. Over a month of publishing, you get at least one. It's not carelessness — human attention cannot sustain 100% accuracy on repetitive tasks. This is cognitive science, not an excuse.
Automate not because you're lazy, but because you can't be perfect forever.
II. Minimum Viable: A deploy.sh
The simplest automation extracts those five steps into a script stored on the VPS:
#!/bin/bash
# deploy.sh — one-click Zola build & deploy
set -euo pipefail
SOURCE_DIR="/var/www/zola-blog-src"
TARGET_DIR="/var/www/blog.cn-res.vip"
BUILD_LOG="/tmp/zola-build-$(date +%Y%m%d-%H%M%S).log"
echo "[1/3] Building..."
cd "$SOURCE_DIR"
zola build > "$BUILD_LOG" 2>&1
echo "[2/3] Deploying to $TARGET_DIR..."
cp -r public/* "$TARGET_DIR"
echo "[3/3] Done."
Key details:
set -euo pipefail is your lifeline:
-e: exit on first error.-u: error on undefined variables — preventsrm -rf $UNDEFINED/*catastrophes.-o pipefail: a pipeline fails if any segment fails.
Write build log to a timestamped file. Useful for debugging failed builds later.
But this has a race condition: cp -r isn't atomic. If nginx serves a request mid-copy, clients see a partial deployment — new pages with old assets, or vice versa.
III. Atomic Deploy: The ln -sf Swap
Fix the race: instead of nginx reading directly from the working directory, point it at a symlink that always points to the current version.
/var/www/blog.cn-res.vip/
├── current → releases/2026-06-16-152030 (symlink)
├── releases/
│ ├── 2026-06-15-090000/
│ ├── 2026-06-16-152030/ ← active
│ └── 2026-06-16-180000/ ← next
nginx's root stays at /var/www/blog.cn-res.vip/current. Deploy script:
RELEASES_DIR="/var/www/blog.cn-res.vip/releases"
CURRENT_LINK="/var/www/blog.cn-res.vip/current"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
zola build
mkdir -p "$RELEASE_DIR"
cp -r public/* "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK" # <-- atomic
ln -sfn is the core. Symlink replacement on Linux is atomic — the kernel guarantees any read of current sees either the old target or the new one, never a half-written state.
cp takes hundreds of milliseconds. ln is microseconds. nginx never notices the swap.
IV. Versions and Rollbacks
Atomic deployment comes with a free bonus: version management.
Every deploy creates a timestamped directory. Don't delete them immediately. Keep the last 5-10, and rollback is trivial:
# Rollback to previous version
ln -sfn "$(ls -1d releases/* | tail -2 | head -1)" current
# Or to a specific release
ln -sfn releases/2026-06-15-090000 current
No database migrations. No API compatibility. No cache warmup. Static site rollback is a file link — pure filesystem ops, done in under a second.
Cleanup old versions with a cron job or inline in deploy.sh:
KEEP=10
cd "$RELEASES_DIR"
ls -1d */ 2>/dev/null | head -n -$KEEP | xargs -r rm -rf
V. Rsync for Incremental Sync
For small sites (a few hundred files), cp -r is fine. Beyond that, rsync only transfers changed files:
rsync -a --delete public/ target/
-a: archive mode (recursive + permissions + timestamps)--delete: remove files at target not present in source
When to use rsync vs cp: With the releases directory + symlink approach, use cp — every release is a fresh directory, so there's nothing incremental to optimize. Use rsync only when you're syncing directly to the webroot without versioning, or when your VPS has a slow uplink (ours is 5Mbps, so rsync helps for large static assets).
Recommendation: cp to releases directory + atomic symlink. Full copy on a small blog takes ~300ms. The real value isn't faster transfers — it's instant rollback.
VI. Auto-Trigger: Git Post-Receive Hook
Script + manual execution is already better than five hand-typed steps. But you can go further: git push triggers deployment automatically.
Set up a bare repo on the VPS with a post-receive hook:
#!/bin/bash
# /var/repo/blog.git/hooks/post-receive
GIT_DIR="/var/repo/blog.git"
WORK_TREE="/var/www/zola-blog-src"
while read oldrev newrev refname; do
branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
if [ "$branch" = "main" ]; then
git --work-tree="$WORK_TREE" --git-dir="$GIT_DIR" checkout -f main
cd "$WORK_TREE"
/usr/local/bin/deploy.sh
fi
done
Local workflow becomes:
git add .
git commit -m "new article: kindness tax"
git push vps main # auto build + deploy
Safety requirement: deploy.sh must catch build failures before swapping the symlink. set -euo pipefail isn't nice-to-have here — it's mandatory.
VII. Don't Over-Engineer
This much text for something this simple?
Yes — because the simplicity is exactly why it's worth getting right once. Operations isn't about architectural complexity. It's about making daily operations brainless.
A production-ready deployment pipeline meets four criteria:
- One-shot — one command or one push, no intermediate manual steps.
- Atomic — no "partially updated" state visible to clients.
- Rollback-capable — return to any previous known-good state in ≤2 steps.
- Fail-safe — build failures don't overwrite the live site; script errors don't destroy data.
Satisfy these four, and your pipeline is solid. Fail any — no matter how small your blog or how few visitors you have — and your deployment has predictable failure points.
The final setup we use is the simplest variant:
rsync local → git push to VPS → post-receive hook fires →
zola build → cp to releases/timestamp → ln -sfn atomic swap → prune old releases
Seven steps, all automated. Every article goes live with one thing: git push.
That's enough.