在之前的折腾中,我们的独立博客运行在远程 Linux 服务器上,采用 Hugo + Nginx (Docker Compose) 的架构。但日常写作若每次都直接在服务器上编辑或手动传输文件,既不方便实时预览,也存在权限与版本管理隐患。

本文记录一次完整的工程化演进:将博客项目映射至本地开发环境,配合本地 Hugo 实时热重载预览,并通过创建服务器专属受限账户与 GitHub Actions 流水线,实现只要 git push 即可全自动安全部署到生产服务器。


一、整体架构设计

演进后的整体工作流如下:

[ 本地工作站 (Windows) ]
    │  hugo server -D (实时本地预览 http://localhost:1313)
    │  git push origin main
    ▼
[ GitHub 仓库 (私有/公开) ]
    │  触发 GitHub Actions 自动化流水线
    │  容器内执行 hugo --minify 生成静态资源
    │  通过 SSH (专用 Ed25519 密钥) 登录远端
    ▼
[ 远程 Linux 服务器 ]
    └── 受限用户 blog (UID 1000)
         ├── 仅拥有 /data/blog 读写与编译权限
         ├── 杜绝整机 root 权限泄漏风险
         └── Nginx 容器自动挂载 public/ 目录,静态页面毫秒级生效

二、远程服务器端:最小权限安全加固

将部署权限交给自动化 CI/CD 工具时,绝不能直接提供 root 账户与私钥。最佳做法是创建仅能管理博客的普通系统用户。

1. 创建受限系统账户

在服务器上新建名为 blog 的普通账户:

# 创建普通用户 blog(不设置交互密码,仅允许公钥登录)
sudo useradd -m -s /bin/bash blog

# 创建 .ssh 密钥目录
sudo mkdir -p /home/blog/.ssh
sudo chmod 700 /home/blog/.ssh

2. 生成 GitHub 专用 Ed25519 部署密钥

专钥专用是安全运维的核心准则。我们在服务器上为 GitHub Actions 独立生成一对密钥:

# 生成专用密钥对
sudo ssh-keygen -t ed25519 -N "" -f /home/blog/.ssh/github_deploy_key -C "github-actions-blog-deploy"

# 将部署公钥加入 authorized_keys 认证列表
sudo cat /home/blog/.ssh/github_deploy_key.pub | sudo tee -a /home/blog/.ssh/authorized_keys

# 规范权限
sudo chmod 600 /home/blog/.ssh/authorized_keys
sudo chmod 600 /home/blog/.ssh/github_deploy_key
sudo chmod 644 /home/blog/.ssh/github_deploy_key.pub
sudo chown -R blog:blog /home/blog/.ssh

提示:如果在本地也希望通过 blog 账户直推,只需把本地已有的公钥(如 ~/.ssh/id_ed25519.pub)也一并追加到 /home/blog/.ssh/authorized_keys 中即可。

3. 移交博客工作目录所有权

将博客部署目录的所有权由 root 完整移交给 blog 用户:

sudo chown -R blog:blog /data/blog
sudo chmod -R u=rwX,g=rX,o=rX /data/blog

因为 Hugo 是静态文件编译器,blog 用户拥有 /data/blog 后,即可直接运行 hugo --minify 将页面产物输出到 /data/blog/public/,整个编译和发布过程完全无需 root 权限。

4. 精确限制 Docker / Nginx 重载权限

由于将用户加入 docker 组等同于赋予其整机 root 权限(可挂载根目录逃逸),因此严禁给 blog 用户分配 docker 组。我们使用 sudoers 白名单,精确放行 Nginx 语法检查与热重载命令:

编辑 /etc/sudoers.d/blog-manage:

blog ALL=(ALL) NOPASSWD: /usr/bin/docker exec blog-nginx nginx -t, /usr/bin/docker exec blog-nginx nginx -s reload

执行语法检查:

sudo visudo -c -f /etc/sudoers.d/blog-manage

三、本地环境搭建与镜像

1. 安装 Hugo Extended

Hugo 的 PaperMod 主题依赖 Extended 版本以支持 Sass/SCSS 处理。

在 Windows 本地终端安装:

winget install Hugo.Hugo.Extended

验证安装:

hugo version
# 输出示例:hugo v0.167.0+extended windows/amd64

2. 镜像远端博客源文件

使用 ssh + tar 流管道将远端源码拉取到本地开发目录(排除生成物 public/):

mkdir -p blog && cd blog
ssh <your-server-alias> "tar -C /data/blog --exclude='public' --exclude='.hugo_build.lock' -cf - ." | tar -xf - -C .

3. 本地启动开发服务器

在本地 blog 根目录执行:

hugo server -D
  • 本地访问:http://localhost:1313
  • -D 参数表示允许预览草稿 (draft: true)。
  • 当你在本地保存 Markdown 文件时,浏览器会自动热重载刷新页面。

四、GitHub Actions CI/CD 流水线配置

1. 编写工作流文件

在项目根目录下创建 .github/workflows/deploy.yml:

name: Deploy Blog to Remote Server

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Source Code
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.146.0'
          extended: true

      - name: Build Static Site with Hugo
        run: hugo --minify

      - name: Deploy to Remote Server
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_PORT: ${{ secrets.SSH_PORT || '22' }}
          TARGET_DIR: '/data/blog'
        run: |
          if [ -z "$SSH_HOST" ] || [ -z "$SSH_USER" ] || [ -z "$SSH_KEY" ]; then
            echo "::error::Missing required secrets. Please configure them in repository settings."
            exit 1
          fi

          mkdir -p ~/.ssh
          chmod 700 ~/.ssh
          echo "$SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa

          # 收集主机指纹(不使用 ping)
          ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null

          # 增量安全同步源码与编译产物到服务器
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='.hugo_build.lock' \
            -e "ssh -p $SSH_PORT -i ~/.ssh/id_rsa -o StrictHostKeyChecking=accept-new" \
            ./ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"

          echo "Deploy completed successfully! Blog is updated on remote server."

2. 在 GitHub 仓库配置 Secrets

进入 GitHub 仓库页面:Settings -> Secrets and variables -> Actions -> New repository secret,添加以下三项:

Secret 键名取值说明获取方式
SSH_HOST服务器公网 IP 或解析域名服务器网络配置
SSH_USERblog填入受限账户名 blog
SSH_PRIVATE_KEY专属部署私钥完整文本服务器执行 sudo cat /home/blog/.ssh/github_deploy_key

五、日常写作与发布流程

完成以上配置后,日常撰写博客将变得无比轻松:

  1. 新建文章:
    hugo new posts/my-awesome-post.md
    
  2. 本地实时预览:
    hugo server -D
    
    打开 http://localhost:1313 边写边看效果。
  3. 提交与自动发布:
    git add .
    git commit -m "feat: 发布新文章:我的精彩分享"
    git push origin main
    
    推送成功后,GitHub Actions 会在几十秒内自动完成代码拉取、静态编译并推送到远端 VPS,网站即刻更新上线!

附录:解决 Git 执行时反复弹出 CredentialHelperSelector

现象描述

在 Windows 环境下运行 Git 命令(如 git push、git fetch)时,界面反复弹出窗口:

CredentialHelperSelector
“Select a credential helper: manager / wincred …”

原因分析

某些集成环境或便携版 Git(如 WorkBuddy 或 PortableGit)在系统的全局配置文件(etc/gitconfig)中默认注册了 credential.helper = helper-selector。每次需要凭据交互时,Git 都会调用该选择器弹窗让用户挑选,即使选择了也可能因为权限问题未能将选择持久化到全局配置中。

彻底解决方案

打开终端,执行以下两条命令重置凭据助手:

# 1. 传入空字符串,清除系统底层继承的所有 helper-selector
git config --global credential.helper ""

# 2. 明确将全局凭据助手锁定为官方的 Git Credential Manager
git config --global --add credential.helper manager

设置后再次执行 Git 操作,弹窗将彻底消失,Git 会静默调用 Windows 系统的凭据管理器进行认证。