Skip to content

实战:VitePress 自动部署到宝塔服务器

本文记录将 VitePress 文档站点通过 GitHub Actions 自动部署到阿里云宝塔服务器的完整过程,涵盖踩坑经验、最终方案与可复用模板。项目地址:0science/vitepress,部署目标:8.138.28.212


一、项目背景

  • 静态站点:VitePress 构建产物在 docs/.vitepress/dist/
  • 代码托管:GitHub 私有仓库
  • 目标服务器:阿里云 ECS + 宝塔面板
  • 需求:每次推送 master 分支自动构建并部署,保留历史版本可回滚

二、架构设计

GitHub Push            Actions Runner              阿里云 ECS
   │                       │                          │
   │   push event          │                          │
   ├──────────────────────▶│                          │
   │                       │  npm ci + npm run build  │
   │                       ├──────────────────────────┤
   │                       │  SSH + rsync 同步         │
   │                       ├──────────────────────────▶│
   │                       │                          │  备份旧文件
   │                       │                          │  部署新文件
   │                       │                          │  清理旧备份
   │                       │  HTTP 200 验证            │
   │                       │◀──────────────────────────┤
   │  ✅ Deployed          │                          │

核心设计:rsync 增量同步 + --backup 自动备份 + --exclude 保护证书文件 + 历史版本保留 N 个。


三、踩坑全过程

坑 1:package-lock.json 与 package.json 不同步

现象Install dependencies 步骤报错

npm error `npm ci` can only install packages when your package.json
and package-lock.json or npm-shrinkwrap.json are in sync.

npm error Missing: search-insights@2.17.3 from lock file

原因:本地 npm install 时缓存异常,lock 文件未正确更新;或某次合并时 lock 文件冲突解决不当。

解决

bash
# 仅更新 lock 文件,不实际安装依赖
npm install --package-lock-only --no-audit --no-fund

# 验证缺失的依赖已补全
grep -c "search-insights" package-lock.json

# 提交 lock 文件
git add package-lock.json
git commit -m "fix: 同步 package-lock.json,修复 npm ci 失败"
git push

预防:本地开发用 npm install 后,提交前确保 package.jsonpackage-lock.json 同步提交;CI 永远用 npm ci(保证可重现)。


坑 2:宝塔站点配置文件被误删

早期方案的问题:工作流用 rm -rf * 清空站点目录,会删除 .user.ini.well-known/ 等 Let's Encrypt SSL 续签必需的文件,导致 SSL 证书无法续期。

解决方案:rsync 的 --exclude 精确保护关键文件:

yaml
rsync -avz --delete \
  --backup --backup-dir="$BACKUP_DIR" \
  --exclude='.well-known/' \
  --exclude='.user.ini' \
  --exclude='.htaccess' \
  --exclude='.backup_*/' \
  docs/.vitepress/dist/ \
  user@ server:/path/

踩坑教训:自动化部署前必须列出目标目录的所有文件,识别哪些是"基础设施文件"不能动。


四、最终成功方案

4.1 目录结构设计

/www/wwwroot/doc.dingjm.top/
├── index.html              # 当前部署版本
├── assets/
├── logo.jpg
├── .user.ini               # [保护] 宝塔 PHP 配置
├── .well-known/            # [保护] SSL 续签目录
├── .htaccess               # [保护] Apache 配置
├── .backup_20260710_185500/  # 历史备份(保留 5 个)
├── .backup_20260709_120000/
└── ...

4.2 完整 deploy.yml

yaml
name: Build and Deploy to Baota Server

on:
  push:
    branches: [master, main]
  workflow_dispatch:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      KEEP_VERSIONS: 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build site
        run: npm run build

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts 2>/dev/null

      - name: Deploy with backup
        run: |
          BACKUP_DIR=".backup_$(date -u +%Y%m%d_%H%M%S)"
          rsync -avz --delete \
            --backup --backup-dir="$BACKUP_DIR" \
            --exclude='.well-known/' \
            --exclude='.user.ini' \
            --exclude='.htaccess' \
            --exclude='.backup_*/' \
            -e "ssh -p ${{ secrets.SSH_PORT }} -i ~/.ssh/id_ed25519" \
            docs/.vitepress/dist/ \
            ${{ secrets.SSH_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.DEPLOY_PATH }}/

      - name: Cleanup old backups
        if: success()
        run: |
          ssh -p ${{ secrets.SSH_PORT }} -i ~/.ssh/id_ed25519 \
            ${{ secrets.SSH_USER }}@${{ secrets.SERVER_HOST }} \
            "cd ${{ secrets.DEPLOY_PATH }} && \
             ls -1dt .backup_* 2>/dev/null \
               | tail -n +$((${{ env.KEEP_VERSIONS }} + 1)) \
               | xargs -r rm -rf"

      - name: Verify site is reachable
        if: success()
        run: |
          sleep 3
          curl -sI -o /dev/null -w "HTTP Status: %{http_code}\n" \
            http://${{ secrets.SERVER_HOST }}/

4.3 所需 Secrets 清单

在 GitHub 仓库 → Settings → Secrets and variables → Actions 中添加:

Secret 名用途示例值
SSH_PRIVATE_KEYSSH 私钥完整内容(含 BEGIN/END 行)-----BEGIN OPENSSH PRIVATE KEY-----...
SERVER_HOST服务器 IP8.138.28.212
SSH_USERSSH 用户名root
SSH_PORTSSH 端口22
DEPLOY_PATH部署目标路径/www/wwwroot/doc.dingjm.top

五、服务器端配置清单

5.1 宝塔创建站点

宝塔面板 → 网站 → 添加站点:

  • 域名doc.dingjm.top(或 IP)
  • PHP 版本:纯静态
  • 数据库:不创建
  • SSL:申请 Let's Encrypt 证书

5.2 SSH 密钥配置

本地生成密钥对:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key

服务器添加公钥:

bash
# 用密码登录服务器
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "你的公钥内容" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

本地验证免密登录:

bash
ssh -i ~/.ssh/deploy_key root@8.138.28.212

5.3 防火墙放行

阿里云安全组 + 宝塔安全 → 放行 22 端口(SSH)和 80/443 端口(HTTP/HTTPS)。


六、关键设计要点

6.1 为什么用 rsync 而不是 scp

方式优点缺点
scp简单全量传输,慢;无增量;无排除功能
rsync增量同步、自动备份、排除文件、压缩传输需要服务器安装 rsync

宝塔服务器默认装了 rsync,无需额外配置。

6.2 为什么用 --backup 而不是手动备份

rsync --backup --backup-dir=DIR 在删除/覆盖旧文件时,自动把这些文件挪到 DIR 中:

  • 只备份变化的文件(增量备份,磁盘占用小)
  • 原子操作(备份和部署一步完成,不会有"备份了一半"的中间态)
  • 自动时间戳命名(每次备份独立目录)

6.3 为什么要 --exclude 保护特定文件

宝塔环境的几个关键文件:

文件用途删除后果
.user.ini宝塔 PHP open_basedir 限制PHP 站点报错
.well-known/Let's Encrypt SSL 续签验证目录证书到期无法续签
.htaccessApache 重写规则路由失效
.backup_*/历史备份目录递归备份自身,磁盘爆炸

6.4 为什么要 concurrency 控制

yaml
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true
  • 同一分支连续推送多次时,自动取消历史运行
  • 避免多个部署同时跑导致备份目录冲突
  • 节省 Actions 配额

6.5 为什么用 npm ci 而不是 npm install

命令用途速度安全性
npm install本地开发,会修改 lock可能引入意外更新
npm ciCI 环境,严格按 lock 安装可重现构建

代价是:lock 文件必须与 package.json 同步,否则报错(我们踩的坑 1)。


七、故障排查清单

7.1 常见错误及解决

错误信息原因解决方案
Permission denied (publickey)私钥格式错或公钥没加到对应用户检查私钥含 BEGIN/END;公钥在目标用户的 authorized_keys
Load key: invalid format私钥换行被破坏重新粘贴私钥到 Secret,确认换行符完整
Host key verification failedknown_hosts 没写入工作流的 ssh-keyscan 步骤已处理
npm ci EUSAGElock 文件与 package.json 不同步本地 npm install --package-lock-only 后提交
rsync: connection unexpectedly closed端口错或防火墙拦截确认 SSH_PORT;阿里云安全组放行
部署成功但访问 404Nginx 根目录不对宝塔站点设置确认根目录指向 DEPLOY_PATH
部署成功但访问空白SSL 证书损坏检查 .well-known 是否被误删
mkdir: cannot create directorySSH 用户对目标路径无写权限chown -R user:user /www/wwwroot/your-site

7.2 本地调试技巧

在本地复现 CI 行为,避免反复推送触发 Actions:

bash
# 模拟 CI 构建
npm ci && npm run build

# 模拟 SSH 连接
ssh -i ~/.ssh/deploy_key root@8.138.28.212 "echo ok"

# 模拟 rsync 同步(先 dry-run 看会改什么)
rsync -avzn --delete \
  --exclude='.well-known/' --exclude='.user.ini' \
  docs/.vitepress/dist/ \
  root@8.138.28.212:/www/wwwroot/doc.dingjm.top/

# 确认无误后去掉 -n 实际执行

八、运维经验总结

8.1 部署前必查清单

  • [ ] 本地 npm run build 成功
  • [ ] package.jsonpackage-lock.json 同步
  • [ ] SSH 免密登录验证通过
  • [ ] rsync dry-run 确认变更范围正确
  • [ ] 目标目录的"基础设施文件"已加入 --exclude
  • [ ] Secrets 名称与 yml 中引用完全一致
  • [ ] 防火墙放行 SSH 端口

8.2 安全实践

  • 最小权限:生产环境建议创建专用 deployer 用户而非用 root
  • 密钥隔离:GitHub Action 用的密钥与日常 SSH 密钥分离
  • 密钥轮换:密钥泄露(如粘贴到对话、聊天工具)后立即轮换
  • 保护文件:用 --exclude 防止自动化误删证书、配置
  • 审计日志:Actions 运行历史本身就是审计记录,保留期默认 90 天

8.3 回滚方案

部署失败或发现问题时三种回滚方式:

方式 1:SSH 手动回滚(最快)

bash
ssh root@8.138.28.212
cd /www/wwwroot/doc.dingjm.top

# 查看所有备份
ls -1dt .backup_*

# 用最近一个备份覆盖当前
rm -rf .rollback_tmp
cp -a .backup_20260710_185500 .rollback_tmp
rm -rf index.html assets/  # 删当前版本(保留保护文件)
mv .rollback_tmp/* .
rm -rf .rollback_tmp
# 注意:手动操作要小心 .well-known 等保护文件

方式 2:GitHub Actions 重跑历史版本

在 Actions 页面找到稳定的 commit → Run workflow → 部署该版本

方式 3:git revert 触发重新部署

bash
git revert <bad-commit>
git push  # 自动触发部署到回滚后的版本

8.4 持续优化方向

当前方案的局限与未来优化:

优化点当前状态改进方向
部署速度全量 rsync 约 30s启用 rsync --compress 和 SSH 多路复用
健康检查仅 HTTP 200增加关键页面内容校验
通知机制仅 Actions 日志接入钉钉/企业微信机器人
多环境单一生产增加 staging 环境先发后切
蓝绿部署备份式回滚改用软链接原子切换
监控接入 Uptime Robot 或宝塔监控

九、整个流程的关键认知

  1. CI/CD 不是工具而是流程:核心是用自动化替代手动,可重现替代"在我电脑上能跑"。
  2. lock 文件是契约:开发环境和 CI 环境的一致性靠 package-lock.json 保证,不能随意修改或遗漏提交。
  3. 保护比删除重要:自动化部署最大的风险是误删,--exclude 优先于 rm
  4. 备份要可回滚:备份不能只是"留档",必须能快速恢复,rsync --backup-dir 自带这个能力。
  5. 本地调试远快于 CI 调试:每次推送后等几分钟看结果,比本地 dry-run 慢 10 倍。
  6. Secrets 是密钥唯一存放点:永远不要把密钥写进代码或日志,GitHub Secrets 是加密存储。
  7. 最小权限原则:哪怕是个人项目,也建议用专用 deployer 用户而非 root。
  8. 密钥泄露必须立即轮换:在对话、Issue、日志里贴过密钥的,全部视为已泄露。

十、参考资料


附:从零开始复刻此部署流程的步骤清单

如果你要在自己的项目中复刻这套部署:

  1. 本地ssh-keygen -t ed25519 -f ~/.ssh/deploy_key 生成密钥对
  2. 服务器:宝塔建站,公钥写入 ~/.ssh/authorized_keys
  3. 本地ssh -i ~/.ssh/deploy_key user@host 验证免密登录
  4. 本地:把 .github/workflows/deploy.yml 复制到你的项目
  5. 本地:根据你的构建命令调整 npm run builddist 路径
  6. GitHub:添加 5 个 Secrets(私钥、IP、用户、端口、路径)
  7. 本地git push 触发首次部署
  8. 服务器:检查 ls -la /www/wwwroot/your-site/ 确认文件就位
  9. 浏览器:访问域名/IP 验证站点可访问
  10. 服务器:配置宝塔 Nginx 指向站点根目录,申请 SSL 证书