上一篇文章中我们已经把博客跑起来了。但这里留下一个问题:服务器是 2 核 2G 的轻量云,pnpm build 跑一次接近一分钟,CPU 直接打满。每次写完文章还要 SSH 上去 git pull && pnpm build,太笨重了。
这一篇搭建一套自动化流程:本地写完文章 git push,GitHub Actions 在云端构建,构建完成后 Webhook 通知服务器自动拉取。之后更新博客只需要一条命令。
思路
服务器只负责「展示」,不负责「构建」。把构建拆到 GitHub Actions 上——免费、配置高、速度快的 CI 机器,构建完产物推到独立的 build 分支,服务器定时或者被 Webhook 触发去拉这个分支。
整体链路:
git push main
│
▼
GitHub Actions: checkout → install → build → push dist/ 到 build 分支
│
▼
GitHub Webhook 发 POST 到服务器
│
▼
服务器 webhook 服务:校验 HMAC → git pull → Nginx 自动生效
改造 GitHub Actions
编写一个新的 deploy.yml ,用于构建博客静态内容后推送到 build 分支。
# .github/workflows/deploy.yml
name: Build and Push to build branch
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: "11.3.0"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Push dist to build branch
run: |
cd dist
git init
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "Build: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" || exit 0
git push --force \
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" \
HEAD:build
关键点:
permissions: contents: write允许 Action 推送代码。- 最后一步进入
dist/目录,初始化独立 git 仓库,force push 到build分支。每次只保留一个 commit,不膨胀 git 历史。 GITHUB_TOKEN是 GitHub 自动提供的,不需要额外配置 secrets。
服务器 Webhook 配置
接下来让服务器感知到 build 分支的更新。方案是用 webhook 这个轻量工具——GitHub 收到 push 后向服务器发一个带签名的 POST 请求,服务器校验后执行 git pull。
安装 webhook
sudo apt install -y webhook
配置 hooks.json
sudo mkdir -p /etc/webhook
sudo vim /etc/webhook/hooks.json
[
{
"id": "blog-deploy",
"execute-command": "/etc/webhook/deploy.sh", <---- 触发脚本
"command-working-directory": "/var/www/blog/dist", <---- 目标文件夹
"trigger-rule": {
"match": {
"type": "payload-hmac-sha256",
"secret": "替换成你自己的随机密钥",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
}
}
]
HMAC 签名校验保证只有 GitHub(持有同一个 secret)才能触发脚本。生成密钥:
openssl rand -hex 32
deploy.sh
这是 webhook 触发后实际执行的脚本。逻辑很简单:
- 写日志:每次触发都记录时间戳到
/var/log/webhook-deploy.log,方便事后排查「这次 webhook 到底有没有执行、什么时候执行的」。 cd到工作目录:失败(目录不存在、权限不足)直接退出并记录 FAIL,不继续往下走。git fetch+git reset --hard:之所以不用git pull,是因为 GitHub Actions 对build分支是 force push,本地和远端历史完全不重合,git pull会报冲突。fetch拿到最新远端状态后reset --hard到origin/build,干净利落地覆盖本地所有文件。- stderr 重定向到日志:
2>> "$LOG_FILE"把 git 命令的错误输出也写进日志,不然 webhook 静默执行,失败了你根本不知道原因。
sudo tee /etc/webhook/deploy.sh <<'SCRIPT'
#!/bin/bash
LOG_FILE="/var/log/webhook-deploy.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy triggered" >> "$LOG_FILE"
cd /var/www/blog/dist || {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] FAIL: cannot cd" >> "$LOG_FILE"
exit 1
}
git fetch origin build 2>> "$LOG_FILE" || {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] FAIL: git fetch failed" >> "$LOG_FILE"
exit 1
}
git reset --hard origin/build 2>> "$LOG_FILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy done" >> "$LOG_FILE"
SCRIPT
sudo chmod +x /etc/webhook/deploy.sh
systemd 服务
webhook 需要以守护进程方式一直在后台运行,用 systemd 管理最合适。几个关键配置:
Type=simple:webhook 是一个长期运行的前台进程,不会自己 fork 到后台,simple是 systemd 对这种进程的标准处理方式。ExecStart:-hooks指定配置文件路径,-port 9000让 webhook 只监听本地回环(等会儿用 Nginx 反向代理对外暴露),-verbose把执行日志打到 journald,方便journalctl -u webhook查看。Restart=always:进程崩溃或意外退出时自动重启,保证 webhook 始终可用。User=<your_username>:用你自己的用户名运行 webhook,这样/var/www/blog/dist的权限和你手动 clone 时的用户一致,不需要额外chown。如果用www-data,clone 仓库后还得把目录所有者改过去,多一步操作,还容易忘。
sudo tee /etc/systemd/system/webhook.service <<'SYSTEMD'
[Unit]
Description=Webhook server
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/webhook -hooks /etc/webhook/hooks.json -port 9000 -verbose
Restart=always
User=<your_username> # <---- 修改成自己的用户名
[Install]
WantedBy=multi-user.target
SYSTEMD
sudo systemctl daemon-reload
sudo systemctl enable --now webhook
daemon-reload 让 systemd 识别新写的 service 文件,enable --now 设置开机自启并立即启动。
Nginx 反向代理
在 blog.conf 的 443 server 块中加一个 location,把 /hooks/ 代理到 webhook:
location /hooks/ {
proxy_pass http://127.0.0.1:9000/hooks/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
重载 Nginx:
sudo nginx -t && sudo systemctl reload nginx
GitHub Webhook
去 GitHub 仓库 Settings → Webhooks → Add webhook:
| 字段 | 值 |
|---|---|
| Payload URL | https://huazaiki.com/hooks/blog-deploy |
| Content type | application/json |
| Secret | 用 openssl rand -hex 32 生成的那个 |
| Events | Just the push event |
添加后 GitHub 发一个 ping 测试连通性,显示绿勾就算通了。
踩坑记录
实际配置中踩了几个坑,记下来免得下次再掉进去。
#!/bin/bash 前面不能有空格。 用 tee 写脚本时如果 heredoc 缩进了,可能导致第一行变成 #!/bin/bash。内核看到不是以 #! 开头,报 fork/exec: exec format error。用 xxd /etc/webhook/deploy.sh | head -1 确认第一字节是 23(#)而不是 20(空格)。
webhook 的 trigger-rule 只对带有 HMAC 签名的 POST 请求放行。 在浏览器访问 https://huazaiki.com/hooks/blog-deploy 会看到 “Hook rules were not satisfied”——这是正常的安全行为,不是 bug。只有 GitHub 用你的 secret 签名的请求才能触发。
webhook 用哪个用户运行。 一开始我照着教程把 User 设成了 www-data,结果 webhook 触发后 git fetch 因为权限不足静默失败——仓库是我自己的用户 clone 的,www-data 读不了。后来直接把 service 文件里的 User 改成自己的用户名(比如 blogadmin),就绕过了权限问题。比 chown -R www-data:www-data /var/www/blog 更直接,也不用担心以后自己手动进去 git pull 调试时又把文件所有者改回去。
日常使用
配置完成后的日常流程:
-
在本地机器上编写文章
-
git 推送,剩下的全自动
git add . && git commit -m "新文章" && git push origin main
1-2 分钟后访问博客,文章已经在上面了。不需要 SSH,不需要等服务器编译,不需要担心 CPU 飙到 100%。一次配置,长期省心。