← 返回上一页

· 文章

Hexo 与 Nginx:静态博客构建与部署

从 Hexo 源码生成静态页面,再用 Docker 中的 Nginx 托管,梳理构建、发布、验证与回退。

静态站点维护分为三份:Hexo 源码保存文章、布局和配置;public/ 是可再生成产物;发布端目录只接收已生成文件。因此云端无需 Node 或 Hexo,构建问题与服务问题也可以分层处理。

维护说明(2026-10-10): 青柠小站现由 GitHub Pages 托管。本文保留 Docker/Nginx 自托管静态站点的实践,说明构建与托管如何分层,不是当前站点的日常发布指南。下文旧式 Hexo deploy 示例可能强推并覆盖远端后来加入的工作流,不应直接用于现行 Pages 仓库;新的交付应保留控制文件、审查同一份构建产物并正常推送。

适用范围与变量

BLOG_DIR='<博客源码目录>'
DEPLOY_DIR='<部署目录>'
CONTAINER_NAME='<容器名称>'
HTTP_PORT='<HTTP端口>'
IMAGE_REF='<完整镜像引用>' # 例如 nginx:固定版本 或 nginx@sha256:摘要

先按实际环境填写变量。本文采用本地构建、产物仓库交付、发布端拉取,以及 Docker 内 Nginx 只读挂载发布目录的架构。

修改位置

内容 文件
文章 source/_posts/*.md
公开站点配置 _config.yml
私密部署覆盖 _config.deploy.local.yml
首页/导航 themes/ops-journal/layout/index.ejs、_partial/header.ejs
文章页 themes/ops-journal/layout/post.ejs
CSS/JS themes/ops-journal/source/css/style.css、source/js/site.js

历史自托管方案曾在公开配置中使用无效域名占位,再通过未跟踪的本地配置覆盖 URL 和部署目标。正式站点的公开域名并不是秘密:现在建议把它统一放在受版本管理的站点配置中,让检查、订阅和发布使用同一地址;凭据和私有部署参数仍不得入库。下例仅解释旧的多配置方式,不能用它重建未经检查的另一份产物后直接发布:

# _config.deploy.local.yml:本地私密文件
url: https://<正式域名>
deploy:
type: git
repo: <私密产物仓库地址>
branch: <发布分支>

本地预览与构建

cd "$BLOG_DIR"
git status -sb
git diff --check
node --version
npm --version
test -f package-lock.json
npm ls --depth=0
# 仅在需要预览时启动,并只监听本机回环地址;完成后按 Ctrl+C 退出
npx hexo server --ip 127.0.0.1

预览服务不会作为后台服务或 systemd 单元启动,也不会修改当前已运行的服务。确认本地预览无误后,使用现有依赖生成静态文件。public/ 和构建缓存必须只是可重建产物;clean 或 build 任一步失败都立即停止,不继续运行后续检查或交付。下例仅展示历史手工检查,当前项目优先使用串联检查、遇错停止的 npm run check:

(
set -euo pipefail
# 只清理可重建产物;子 Shell 遇错停止,不退出当前交互会话
npm run clean
npm run build
test -f public/index.html
test -f public/search.json
find public -type f -name '*.html' | wc -l
# 再检查实际页面和订阅链接,文件存在不等于内容正确
)

构建完成后检查首页、文章和搜索索引;只有使用正式 canonical URL 的部署构建才应生成 Atom 订阅。

历史交付方式

当时采用的发布链是:Hexo deploy 推送产物仓库,发布端再用 git pull --ff-only 更新。源码仓库保存 Markdown、主题和配置,产物仓库只保存可公开部署的静态文件。

发布前先确认源码和发布端工作区:

cd "$BLOG_DIR"
git status -sb
git remote -v
git branch --show-current
git -C "$DEPLOY_DIR" status -sb

以下命令只保留作历史说明,不用于当前 Pages 仓库。 hexo-deployer-git 的旧部署缓存可能缺少远端新建的工作流,强推会把它覆盖;现行发布应采用独立产物检出、先同步远端、保留控制文件、审查差异后正常推送。历史方案在确认分支、远程地址和工作区后曾执行:

npx hexo --config _config.yml,_config.deploy.local.yml deploy
git -C "$DEPLOY_DIR" pull --ff-only

hexo deploy会更新产物仓库,git pull --ff-only会更新发布目录。如果分支发生分歧,先检查双方提交和差异,不要直接强制重置。当前项目未安装链接检查工具,可先检查目标页面和不存在路径的 HTTP 行为:

curl -fsSI "http://127.0.0.1:$HTTP_PORT/<页面路径>/"
curl -fsSI "http://127.0.0.1:$HTTP_PORT/<不存在路径>/" || true

发布端首次准备

首次部署时,DEPLOY_DIR还不是 Git 工作区,需要先从产物仓库克隆。不要用 sudo git clone:那会让工作区归 root 所有,之后普通发布账号无法安全执行 git pull。先确认目标完全不存在,再仅用 sudo 创建并交还目录,克隆操作由当前发布账号执行:

(
set -euo pipefail
test -n "$DEPLOY_DIR"
test "$DEPLOY_DIR" != /
test ! -e "$DEPLOY_DIR"
test ! -L "$DEPLOY_DIR"
sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 0755 "$DEPLOY_DIR"
git clone --branch '<发布分支>' --single-branch '<产物仓库只读地址>' "$DEPLOY_DIR"
git -C "$DEPLOY_DIR" status -sb
test -f "$DEPLOY_DIR/index.html"
test "$(stat -c %U "$DEPLOY_DIR")" = "$(id -un)"
)

如果目标已经存在或非空,先确认它的用途,不要覆盖,也不要直接 chown -R 一个用途未知的目录。产物仓库可使用公开只读地址,私有仓库则使用专用只读 Deploy Key;不要把 Token 写进远程 URL。日常更新才使用 git pull --ff-only。

机房中的服务器机柜和网络设备
机房设备 · Brett Sayles / Pexels

首次 Docker/Nginx 托管

安全前提:不要直接把 Git 工作区根目录作为公开站点根目录。 下列传统挂载示例中的 DEPLOY_DIR 含 .git/;只读挂载只限制写入,不能防止 HTTP 读取 .git/config、对象或历史文件,远程地址也可能泄露。正式上线应使用不含 Git 元数据的独立静态目录,或先配置并验证 Nginx 对 /.git 及其所有子路径的拒绝规则(含别名/其他入口);未完成隔离或拒绝验证,不得照搬下面默认镜像挂载方案开放公网。rsync 保留 .git 只是维护工作区,不是 HTTP 访问保护。

创建前先确认名称、端口、目录不冲突:

sudo docker ps -a --filter "name=^/$CONTAINER_NAME$"
sudo ss -lntp | grep -E ":$HTTP_PORT\b" || true
sudo test -e "$DEPLOY_DIR" && sudo ls -ld "$DEPLOY_DIR" || true
sudo test -d "$DEPLOY_DIR"
sudo test -f "$DEPLOY_DIR/index.html"
sudo docker pull "$IMAGE_REF"
sudo docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--log-opt max-size=10m --log-opt max-file=3 \
-p "$HTTP_PORT:80" \
-v "$DEPLOY_DIR:/usr/share/nginx/html:ro" \
"$IMAGE_REF"

固定版本标签可减少使用 latest 带来的意外升级,但标签仍可能被重新指向;需要固定镜像内容时使用摘要。只读 Bind Mount 限制容器通过该挂载改写发布文件,但不阻止 HTTP 读取敏感文件;日志轮转限制单容器日志持续增长。

验证跨越容器、挂载、监听、应用和公网:

sudo docker ps --filter "name=^/$CONTAINER_NAME$"
sudo docker inspect "$CONTAINER_NAME" --format '{{range .Mounts}}{{println .Source "->" .Destination .RW}}{{end}}'
sudo docker port "$CONTAINER_NAME"
sudo ss -lntp | grep -E ":$HTTP_PORT\b"
sudo docker exec "$CONTAINER_NAME" nginx -t
curl -fsSI "http://127.0.0.1:$HTTP_PORT/"
curl -fsSI "http://127.0.0.1:$HTTP_PORT/<页面路径>/"
sudo docker logs --tail 50 "$CONTAINER_NAME"
sha256sum "$DEPLOY_DIR/index.html"

管理端公网验证:

curl -fsSI "http://<服务器地址>:$HTTP_PORT/"

如果本机请求成功但公网失败,再检查云网络入站规则和外部访问路径。

更新、重建与回退

修改 Markdown 或主题后,先本地预览并重新构建,再发布新产物。Nginx 始终读取同一 Bind Mount,因此仅更新静态文件时不需要重建容器;只有镜像、端口、容器参数、Nginx 配置或挂载关系变化时才需要重建。

本节历史自托管方案使用上面的 git pull --ff-only 更新发布目录,不代表现行 Pages 的发布步骤。下面的 rsync --delete 只用于发布端需要从一份已经验收的静态产物目录做受控替换或回退时;这些命令在 VM/云端发布主机 上执行,不是在本地 Hexo 源码目录中执行。--delete 会删除目标中来源没有的文件,变量为空、方向写反或跨错主机都可能破坏发布目录。

先在发布主机设置来源、目标和独立备份目录。三者必须已经存在、用途明确,并且规范化后两两不相等、不互相嵌套。备份根目录由发布账号拥有且可写;不要为通过检查对未知目录递归改属主。本例由发布账号执行 rsync,以 --no-owner --no-group 避免沿用来源属主,目标中已有文件也须具有所需读写权限。

下面是一个完整子 Shell:校验或备份失败会终止整个块;dry-run 后需要输入 yes 才会继续。不要拆开执行后半段,变量只在该子 Shell 内有效。路径检查不能保证任意路径绝对安全,不能防止并发替换、所有符号链接、特殊挂载或错误的业务目录选择;须人工确认静态内容、权限、空间和目标边界,并避免同步期间其他发布任务修改这些目录。

(
set -euo pipefail
SOURCE_DIR='<发布主机上的已验证静态产物目录>'
BACKUP_ROOT='<发布主机上的独立备份目录>'
for dir in "$SOURCE_DIR" "$DEPLOY_DIR" "$BACKUP_ROOT"; do
test -n "$dir"
test -d "$dir"
done
source_real=$(realpath -e -- "$SOURCE_DIR")
deploy_real=$(realpath -e -- "$DEPLOY_DIR")
backup_root_real=$(realpath -e -- "$BACKUP_ROOT")
paths=("$source_real" "$deploy_real" "$backup_root_real")
for ((i=0; i<3; i++)); do
test "${paths[i]}" != /
for ((j=0; j<3; j++)); do
if ((i != j)); then
case "${paths[i]}/" in
"${paths[j]}/"*) printf '路径相同或嵌套,停止同步\n' >&2; exit 1 ;;
esac
fi
done
done
test -f "$source_real/index.html"
test -f "$deploy_real/index.html"
test -d "$deploy_real/.git"
test -w "$deploy_real"
test -w "$backup_root_real"
test "$(stat -c %u "$deploy_real")" = "$(id -u)"
test "$(stat -c %u "$backup_root_real")" = "$(id -u)"
BACKUP_DIR=$(mktemp -d "$backup_root_real/site.XXXXXXXX")
printf '记录本次 BACKUP_DIR=%s\n' "$BACKUP_DIR"
cp -a -- "$deploy_real/." "$BACKUP_DIR/"
test -f "$BACKUP_DIR/index.html"
test -d "$BACKUP_DIR/.git"
rsync -a --no-owner --no-group --delete --dry-run --itemize-changes \
--filter='protect /.git/' --exclude='/.git/' \
"$source_real/" "$deploy_real/"
read -r -p '已核对主机、路径、备份及删除清单?输入 yes 继续:' confirm
test "$confirm" = yes
rsync -a --no-owner --no-group --delete --itemize-changes \
--filter='protect /.git/' --exclude='/.git/' \
"$source_real/" "$deploy_real/"
test -d "$deploy_real/.git"
curl -fsSI "http://127.0.0.1:$HTTP_PORT/"
)

根目录级 protect 与 exclude 规则用于保留目标 .git/;不要添加 --delete-excluded。异常时不要继续同步。回退使用同一个完整块,但将 SOURCE_DIR 填为本次打印并核验过的 BACKUP_DIR,将 BACKUP_ROOT 设为与此来源、目标均不嵌套的另一个已准备备份目录;重新检查路径、备份当前状态和审阅 dry-run 后再确认。不能将原备份根目录直接沿用,因为它包含回退来源。回退保留当前 .git/,不会回退 Git 历史;恢复后另行执行 git -C "$DEPLOY_DIR" status -sb 并核对内容与版本,不要未经检查直接 pull 覆盖恢复内容。

容器启动失败先查 docker logs、nginx -t、挂载和端口,不删除运行目录或容器作为默认修复。

后续接入与验收

先完成公网 HTTP 验证,再单独处理域名、备案、DNS 和 HTTPS。上线前检查:public/包含首页、文章和 search,配置正式 canonical URL 后还应包含 Atom;发布端版本或哈希正确;容器、挂载、端口、本机 HTTP 和公网 HTTP 均正常;上一版产物可以恢复。

发布前根据实际环境填写私密部署配置,并确认仓库权限、云网络规则和容器参数。凭证不要写入源码或生成文件。