把博客从 GitHub Pages 迁移到个人服务器 growdu.cn,CI 自动构建并通过 rsync over SSH 部署。总体布局、构建链路见 架构.md,日常维护见 博客搭建指南.md。本文只讲部署。
目标
- 域名
growdu.cn(及www.growdu.cn跳转主域),博客跑在站点根路径/。 - push 到
master后,GitHub Actions 自动构建并用 rsync 部署到本机 nginx。 - HTTPS 全站,证书自动签发、自动续期。
- 部署原子化(symlink 切换),失败可秒级回滚。
环境
| 项 | 值 |
|---|---|
| 服务器 | 本机,CentOS Stream 9 |
| Web 服务器 | nginx 1.20.1(已装,未运行) |
| 域名 | growdu.cn / www.growdu.cn |
| 代码仓库 | git@github.com:growdu/blog.git |
| 现有占用 | /etc/nginx/conf.d/pgv.conf 用 server_name _(catch-all)跑 pgv 应用,需让出 80/443 给主域 |
注意:本机已有
pgv.conf用server_name _做 catch-all。nginx 按 server_name 精确匹配优先,growdu.cn的 server block 会先命中,_只兜底其它域名,两者不冲突。
整体链路
push master
│
▼
GitHub Actions (ubuntu)
│ 1. sync-hexo / patch-theme / copy-vendor
│ 2. hexo generate -> public/ (用已改好的 _config.yml: url=growdu.cn, root=/)
│ 3. rsync public/ -> deploy@growdu.cn:/var/www/blog/releases/<时间戳>/
│ 4. ssh 切 symlink /var/www/blog/current -> releases/<时间戳>
▼
nginx (本机 443)
│ root /var/www/blog/current;
│ SSL 证书由 acme.sh 管理,自动续期
▼
访客 https://growdu.cn/
一、DNS 解析
在域名注册商处添加记录,指向本机公网 IP:
| 主机记录 | 记录类型 | 值 |
|---|---|---|
@ |
A | <本机公网 IP> |
www |
A | <本机公网 IP> |
如果用 CDN(如 Cloudflare),把 A 记录改成 CNAME 指向 CDN,或在 Cloudflare 开代理。但首次签 SSL 证书建议先关代理(DNS only),签完再开。
二、服务器准备
2.1 创建部署用户
# 在本机执行
useradd -m -s /bin/bash deploy
mkdir -p /var/www/blog/releases
chown -R deploy:deploy /var/www/blog
2.2 配置 SSH 公钥
GitHub Actions 用一个专用 SSH 私钥部署。在本机生成并授权:
# 在本机生成部署密钥(也可在本地生成后把公钥贴上来)
ssh-keygen -t ed25519 -f /root/blog-deploy-key -N "" -C "github-actions-deploy"
# 公钥写入 deploy 用户
mkdir -p /home/deploy/.ssh
cp /root/blog-deploy-key.pub /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
# 私钥内容稍后填入 GitHub Secrets(见第四节),生成后可删除本机私钥
2.3 给 deploy 用户 nginx reload 权限
部署只改静态文件,nginx 直接读磁盘,内容更新不需要 reload。只有 nginx 配置变更才需要。为防万一,给 deploy 一个受限 sudo:
echo 'deploy ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload, /usr/sbin/nginx -t' > /etc/sudoers.d/deploy-blog
chmod 440 /etc/sudoers.d/deploy-blog
visudo -c # 校验语法
2.4 nginx 站点配置
新建 /etc/nginx/conf.d/blog.conf:
# HTTP → HTTPS 跳转
server {
listen 80;
listen [::]:80;
server_name growdu.cn www.growdu.cn;
# acme.sh HTTP-01 验证用,放在跳转之前
location /.well-known/acme-challenge/ {
root /var/www/blog/current;
}
location / {
return 301 https://growdu.cn$request_uri;
}
}
# www → 主域跳转
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.growdu.cn;
ssl_certificate /etc/nginx/ssl/growdu.cn/fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/growdu.cn/growdu.cn.key;
return 301 https://growdu.cn$request_uri;
}
# 主站
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name growdu.cn;
ssl_certificate /etc/nginx/ssl/growdu.cn/fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/growdu.cn/growdu.cn.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /var/www/blog/current;
index index.html;
# gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 1024;
# 静态资源长缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# sitemap / feed 不缓存
location = /sitemap.xml { add_header Cache-Control "no-cache"; }
location = /atom.xml { add_header Cache-Control "no-cache"; }
# hexo 生成的 permalink 目录结构
location / {
try_files $uri $uri/ /index.html;
}
# 健康检查(供监控用)
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
CentOS 9 nginx.conf 注意:默认
nginx.conf的http {}块里没有include /etc/nginx/conf.d/*.conf;,需手动在keepalive_timeout行之后加一行:include /etc/nginx/conf.d/*.conf;否则
blog.conf不会被加载。本机原有的 pgv 配置直接写死在nginx.conf的server {}块里,conf.d/pgv.conf是未生效的副本,备份为.bak即可。
blog.conf 的
listen 80需加default_server,确保 growdu.cn 请求优先命中 blog server block 而非 nginx.conf 里的server_name _catch-all。
root /var/www/blog/current指向一个 symlink,CI 每次部署切 symlink,实现原子切换。首次部署前需要先建一个空目录占位:mkdir -p /var/www/blog/releases/init && ln -sfn /var/www/blog/releases/init /var/www/blog/current chown -R deploy:deploy /var/www/blog
2.5 启动 nginx
nginx -t
systemctl enable --now nginx
三、SSL 证书(acme.sh)
用 acme.sh 签发 Let’s Encrypt 证书,纯 shell 实现,不依赖 Python/snap,适合 CentOS 9。
3.1 安装
# 用 root 安装
curl https://get.acme.sh | sh -s email=growdu@gmail.com
source ~/.bashrc # 或重新登录
3.2 签发证书(TLS-ALPN-01 模式)
重要:本机在阿里云上,端口 80 的 HTTP-01 验证会被云层拦截返回 403(webroot 和 nginx 模式都不行)。改用 TLS-ALPN-01 模式(走 443 端口)绕过。
签发前需临时停掉 nginx 释放 443 端口:
systemctl stop nginx
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue -d growdu.cn -d www.growdu.cn --alpn --force
前提:DNS 已生效、443 端口可从公网访问。验证
dig +short growdu.cn返回本机 IP 后再签。
3.3 安装证书到 nginx 目录
mkdir -p /etc/nginx/ssl/growdu.cn
acme.sh --install-cert -d growdu.cn \
--key-file /etc/nginx/ssl/growdu.cn/growdu.cn.key \
--fullchain-file /etc/nginx/ssl/growdu.cn/fullchain.cer \
--reloadcmd "nginx -s reload" \
--pre-hook "systemctl stop nginx" \
--post-hook "systemctl start nginx"
续期时 acme.sh 会先停 nginx(
--pre-hook)释放 443 端口给 ALPN 验证,签完再启动(--post-hook),最后 reload。停机仅几秒。
acme.sh 会自动注册 cron 每天检查续期,到期前自动续签。
四、站点配置变更
4.1 _config.yml
URL 和 root 从 GitHub Pages 切到个人域名根路径:
# 改前
url: https://growdu.github.io/blog
root: /blog/
# 改后
url: https://growdu.cn
root: /
root从/blog/改成/后,所有站内链接前缀会从/blog/2026/...变成/2026/...。这是预期的——个人域名就该跑在根路径。本地npm run server也会跟着变,访问http://localhost:4000/即可。
4.2 _config.matery.yml
Gitalk 的 OAuth App 和 GoatCounter 都注册在 growdu.github.io/blog,需要更新:
Gitalk(评论用 GitHub Issues,clientID/Secret 不变,但要更新 OAuth App 配置):
到 GitHub → Settings → Developer settings → OAuth Apps → 找到现有 App,把 Homepage URL 和 Callback URL 改成:
Homepage URL: https://growdu.cn/
Callback URL: https://growdu.cn/
_config.matery.yml 里的注释也同步更新:
gitalk:
enable: true
clientID: "Ov23liyvdyO0U0lfqI2u"
clientSecret: "__GITALK_CLIENT_SECRET__"
repo: blog
owner: growdu
# Homepage URL: https://growdu.cn/
# Callback URL: https://growdu.cn/
Gitalk 的 issue 是按
location.pathname做 key 的。域名/路径变了之后,旧文章的评论会重新初始化 issue(评论不会丢,GitHub Issue 还在,只是 Gitalk 会给文章新建一个 issue 关联)。如果在意旧评论连续性,可在init-gitalk-issues.js里用固定 label 映射。多数情况下重新初始化即可接受。
GoatCounter(PV/UV 统计):
到 goatcounter.com 后台把站点 URL 改成 https://growdu.cn/。_config.matery.yml 里 code: 'growdu' 不用改。
4.3 搜索引擎 / 站长工具
- Google Search Console / Bing Webmaster Tools:添加
growdu.cn,用 HTML 文件或 DNS 验证。 - 在
public/放验证文件的话,跟现有的 Vercount 验证文件一样,CI 里cp到public/.well-known/或根目录。 - 老的 GitHub Pages 地址如果还在,可在 GitHub Pages 保留一段时间做 301 跳转,或直接弃用。
五、CI 自动部署
5.1 GitHub Secrets
在仓库 Settings → Secrets and variables → Actions 添加:
| Secret 名 | 值 | 说明 |
|---|---|---|
DEPLOY_SSH_KEY |
blog-deploy-key 私钥全文 |
第二节生成的 ed25519 私钥 |
DEPLOY_HOST |
growdu.cn(或本机 IP) |
部署目标 |
DEPLOY_USER |
deploy |
服务器用户 |
DEPLOY_PORT |
22 |
SSH 端口(改过就填实际值) |
GITALK_CLIENT_SECRET和GITHUB_TOKEN已有,不用动。
5.2 修改 .github/workflows/hexo.yml
在现有 build job 之后新增 deploy-server job,并让 build 产出 public/ 供两个部署 job 消费。完整改动如下:
name: Deploy Hexo
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: deploy
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Inject Gitalk secret
env:
GITALK_SECRET: ${{ secrets.GITALK_CLIENT_SECRET }}
run: |
python3 -c "
import os
with open('_config.matery.yml','r') as f: c=f.read()
c=c.replace('__GITALK_CLIENT_SECRET__',os.environ.get('GITALK_SECRET',''))
with open('_config.matery.yml','w') as f: f.write(c)
print('Gitalk secret injected' if os.environ.get('GITALK_SECRET') else 'WARNING: GITALK_CLIENT_SECRET not set')
"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Hexo dependencies
run: npm install
- name: Sync content to Hexo
run: python3 tools/sync-hexo.py
- name: Apply theme patches
run: python3 tools/patch-theme.py
- name: Copy vendor assets
run: python3 tools/copy-vendor.py
- name: Build with Hexo
run: npx hexo generate
- name: Publish verification files
run: |
mkdir -p public/.well-known
cp source/.well-known/vercount-verify-zd5pj09shdfkbm6h83ra5zzk.txt public/.well-known/ 2>/dev/null || true
- name: Fix XML escaping in sitemap and feed
run: |
python3 -c "
import re
for fpath in ['public/sitemap.xml', 'public/atom.xml']:
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
content = re.sub(r'&(?!amp;|lt;|gt;|quot;|apos;|#\\d+;|#x[0-9a-fA-F]+;)', '&', content)
with open(fpath, 'w', encoding='utf-8') as f:
f.write(content)
print(f'Fixed XML escaping in {fpath}')
except FileNotFoundError:
print(f'{fpath} not found, skipping')
"
- name: Initialize Gitalk issues
run: node tools/init-gitalk-issues.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload build artifact
id: upload
uses: actions/upload-artifact@v4
with:
name: blog-public
path: ./public
retention-days: 3
deploy-server:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: blog-public
path: ./public
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{ secrets.DEPLOY_PORT }} ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
run: |
set -euo pipefail
TIMESTAMP=$(date -u +%Y%m%d%H%M%S)
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
# 1. rsync 到新的 release 目录
rsync -avz --delete \
-e "ssh -p ${DEPLOY_PORT}" \
./public/ \
"${REMOTE}:/var/www/blog/releases/${TIMESTAMP}/"
# 2. 原子切换 symlink
ssh -p ${DEPLOY_PORT} "${REMOTE}" "
ln -sfn /var/www/blog/releases/${TIMESTAMP} /var/www/blog/current.new && \
mv -Tf /var/www/blog/current.new /var/www/blog/current
"
# 3. 清理 7 天前的旧 release(保留最近 10 个)
ssh -p ${DEPLOY_PORT} "${REMOTE}" "
cd /var/www/blog/releases && \
ls -1t | tail -n +11 | xargs -r rm -rf
"
echo "Deployed: ${TIMESTAMP}"
echo "Live at: https://growdu.cn/"
5.3 关键设计说明
- 原子切换:rsync 先写到
releases/<时间戳>/,完成后mv -T切 symlink。访客不会看到半截文件。 - 保留 10 个历史版本:回滚只需把 symlink 指回旧 release。
- 不用 reload nginx:静态文件更新不需要 reload。symlink 切换对 nginx 透明。
- **
--delete**:rsync 删除远端多余文件,保证与public/完全一致。只在 release 子目录内删,不影响其它 release。 - **
ssh-keyscan**:CI 容器没有已知主机,首次自动写入指纹。安全性靠私钥+known_hosts 双保险。如果服务器指纹变了(重装系统),需手动更新 keyscan 或用 SSH CA。
5.4 与 GitHub Pages 的关系
改了 root: / 之后,GitHub Pages 上的链接会变成 /2026/...(不带 /blog/),而 GitHub Pages 实际 URL 是 growdu.github.io/blog/2026/...,链接会 404。所以:
- 推荐:停用 GitHub Pages 部署,服务器作为唯一出口。
- 要保留 GitHub Pages:需要单独构建一份
root: /blog/的产物。在 CI 里加第二个 build job,build 前用 sed 覆写_config.yml的url/root,再走原有deploy-pagesjob。两份产物、两个 deploy job 互不干扰。
如果只是过渡期留个跳转,可以在 GitHub Pages 仓库放一个全站 301 的 index.html,跳到 https://growdu.cn/。
六、首次部署步骤(按顺序执行)
- DNS:添加 A 记录,
dig +short growdu.cn确认返回本机 IP。 - 建 deploy 用户 + SSH 密钥:第二节 2.1–2.2。
- 装 acme.sh 签证书:第三节。证书就位后
/etc/nginx/ssl/growdu.cn/有fullchain.cer和growdu.cn.key。 - 写 nginx 配置 + 建空占位目录 + 启动 nginx:第 2.4–2.5。
- **改
_config.yml和_config.matery.yml**:第四节。 - 更新 GitHub OAuth App(Gitalk)和 GoatCounter 站点 URL。
- 添加 GitHub Secrets:第 5.1。
- **替换
.github/workflows/hexo.yml**:第 5.2。 - push 到 master,观察 Actions 日志,
deploy-serverjob 跑完。 - 验证:
curl -sI https://growdu.cn/ # 200 curl -sI https://www.growdu.cn/ # 301 → growdu.cn curl -sI http://growdu.cn/ # 301 → https curl -s https://growdu.cn/healthz # ok - **浏览器打开
https://growdu.cn/**,检查首页、文章页、分类页、评论、搜索。
七、SSL 证书续期
acme.sh 装好后自动写了一条 crontab:
crontab -l | grep acme # 确认存在
每天检查,到期前 30 天自动续签,续签后执行 --reloadcmd "nginx -s reload"。无需人工干预。
续期流程(cron 自动执行):停 nginx -> ALPN 验证签发 -> 安装证书 -> 启动 nginx -> reload。
手动续签测试:
acme.sh --renew -d growdu.cn --force
八、回滚
CI 每次部署保留 10 个 release。回滚到上一个:
# 在服务器上
cd /var/www/blog/releases
ls -1t # 看有哪些版本
PREV=$(ls -1t | sed -n '2p') # 第二新的就是上一个
ln -sfn /var/www/blog/releases/${PREV} /var/www/blog/current.new
mv -Tf /var/www/blog/current.new /var/www/blog/current
秒级生效,不需要 nginx reload。
如果要快速禁用站点(比如被刷流量),把 symlink 指向空目录或临时维护页即可。
九、监控与告警
| 项 | 方法 |
|---|---|
| 站点存活 | 外部 uptime 监控(如 UptimeRobot)打 https://growdu.cn/healthz |
| SSL 到期 | acme.sh 自动续期;额外可用 SSL Labs 或监控工具告警 |
| 磁盘空间 | release 目录自动清理只留 10 个;public/ 约 100–200MB,问题不大 |
| CI 失败 | GitHub Actions 自带邮件通知;失败不会切 symlink,线上仍是上一个版本 |
十、检查清单
- DNS:
growdu.cn和www.growdu.cnA 记录指向本机 - deploy 用户创建,
/var/www/blog归属 deploy - SSH 密钥:公钥在
deploy的authorized_keys,私钥填入 GitHub Secrets - sudoers:deploy 可
nginx -s reload/nginx -t - acme.sh 安装,证书签发并安装到
/etc/nginx/ssl/growdu.cn/ -
blog.conf写入/etc/nginx/conf.d/,nginx -t通过 - nginx 启动并设开机自启
-
_config.yml:url: https://growdu.cn,root: / -
_config.matery.yml:Gitalk 注释更新 - GitHub OAuth App:Homepage / Callback URL 改为
https://growdu.cn/ - GoatCounter:站点 URL 改为
https://growdu.cn/ - GitHub Secrets:
DEPLOY_SSH_KEY、DEPLOY_HOST、DEPLOY_USER、DEPLOY_PORT - workflow 替换,push 后
deploy-serverjob 成功 -
https://growdu.cn/可访问,HTTP/HTTPS 跳转正常 - 文章页、分类页、搜索、评论正常