GitHub Actions 自动部署网站实战:从 push 到上线的免费 CI/CD 流水线

「改完代码 → 本地打包 → scp 上传 → SSH 里重启服务」这套流程,只要你手动做过三次以上,就该考虑自动化了。GitHub Actions 是目前最适合个人站长的一块免费蛋糕:公开仓库完全免费不限时长,私有仓库每月也有 2000 分钟的免费额度,对于每天部署几次的小站来说绰绰有余。本文从零开始,把「push 代码自动部署到服务器」这条流水线讲透,包括密钥管理、部署脚本、回滚策略和几个必踩的坑。

一、为什么选 GitHub Actions

自动化部署的方案很多:Jenkins、GitLab CI、Drone、自建 webhook 脚本……对个人站长来说它们各有门槛。Jenkins 要单独维护一台机器,GitLab CI 需要 GitLab Runner,自建 webhook 又容易有安全和可靠性问题。

GitHub Actions 的优势很直接:

  • 零成本起步:代码托管在 GitHub 的情况下,不需要额外服务器。
  • 免运维:Runner 由 GitHub 提供(Ubuntu/Windows/macOS 镜像),不用管环境。
  • 生态成熟:现成的 Action 覆盖了大部分需求(构建、部署、通知)。
  • 与代码仓库天然集成:触发条件就是代码事件,不需要额外的钩子。

需要注意的边界:单次 job 最长 6 小时,私有仓库每月 2000 分钟。纯静态站部署一次通常 30 秒到 1 分钟,完全够用。如果你是 PHP 站点、只在服务器上生成内容,也只是把「同步文件 + 执行命令」交给 Actions 而已。

二、整体架构:三种部署模式

部署模式决定了流水线的写法,先选对模式:

模式 A:构建产物推送到服务器(Push 模式)

在 Actions 里构建(比如 Hugo 生成静态文件、npm build 打包),然后把产物 rsync 到服务器的网站目录。适合静态站,服务器上只需要 Nginx,完全不需要安装 Node/Hugo 等构建工具。这是最推荐的模式。

模式 B:服务器拉取代码并构建(Pull 模式)

Actions 只负责通过 SSH 通知服务器执行 git pull && 构建脚本。适合动态站点(PHP/Node/Python),构建依赖服务器环境的情况。缺点是服务器上需要装构建工具链,且构建过程占用服务器资源。

模式 C:Docker 镜像构建推送(容器模式)

Actions 构建 Docker 镜像推到镜像仓库,服务器上 pull 并重启容器。这是最规范的做法,但复杂度也最高,适合对一致性要求高的场景。本文主要讲 A 和 B,因为个人站长用这两种就够了。

三、准备工作:SSH 密钥与 Secrets

核心原则:绝对不要把密码、私钥直接写在 workflow 文件里。仓库哪怕后来转私有,历史记录里的明文密钥也可能已经泄露。正确做法是用 GitHub Secrets。

1. 生成专用部署密钥

不要用服务器的主密钥(~/.ssh/id_rsa),专门生成一对给部署用的:

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

-t ed25519 是更现代更安全的算法,-N "" 表示空密码(Actions 里无法交互输入密码,必须留空)。生成后得到两个文件:deploy_key(私钥,给 GitHub)和 deploy_key.pub(公钥,装到服务器)。

2. 公钥安装到服务器

# 把公钥内容追加到服务器的 authorized_keys
cat ~/.ssh/deploy_key.pub

# 在服务器上执行
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "ssh-ed25519 AAAA...你的公钥内容..." >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

更规范的做法是把公钥限制成只能执行特定命令(用 command= 前缀),但如果部署需要执行多条命令就不太方便。折中方案是创建一个专用的部署用户,只给它网站目录的写权限,即使密钥泄露影响也可控。

3. 私钥存入 GitHub Secrets

进入仓库 Settings → Secrets and variables → Actions → New repository secret,依次添加:

  • SSH_PRIVATE_KEY:私钥文件的完整内容(注意首尾的 -----BEGIN/END----- 行也要包含)。
  • SSH_HOST:服务器 IP 或域名。
  • SSH_USER:登录用户名。
  • SSH_PORT:SSH 端口(默认 22,改过端口就填实际的)。

读取私钥的一个细节:本地 cat 出来的内容直接粘贴即可。如果粘贴后验证失败,多半是缺了末尾换行,或者粘贴过程中加了缩进。

四、模式 A 实战:静态站自动部署

假设你用 Hugo 或 Vite 构建静态站,服务器目录是 /var/www/html。workflow 文件放在仓库的 .github/workflows/deploy.yml

name: Deploy to Server

on:
  push:
    branches: [ main ]
  workflow_dispatch:        # 允许手动触发

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Install deps
        run: npm ci

      - name: Build
        run: npm run build

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

      - name: Deploy with rsync
        run: |
          rsync -avz --delete \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=yes" \
            ./dist/ \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/html/

逐段说明关键点:

  • on.push.branches:只在 main 分支推送时触发。如果你用 dev 分支开发,就写成需要合并到 main 才部署。
  • workflow_dispatch:在 GitHub 网页上出现一个「Run workflow」按钮,方便手动重跑。
  • cache: 'npm':缓存 node_modules,第二次起构建时间大幅缩短。
  • ssh-keyscan:把服务器指纹写进 known_hosts。如果省略,第一次连接会交互式询问是否信任主机,在非交互环境下会直接失败。注意 -p 参数在 ssh-keyscan 里要写在前面。
  • rsync --delete:删除服务器上目标目录里多余的文件,保证与构建产物一致。⚠️ 这个参数很危险:如果目标路径写错(比如写成 /var/www),会把整个目录清空。务必先用 --dry-run 验证一次。

五、模式 B 实战:拉取代码并重启服务

PHP 或 Node 动态站,服务器上已有 git 仓库。workflow 只做 SSH 远程执行:

name: Deploy Dynamic Site

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          port: ${{ secrets.SSH_PORT }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            set -e
            cd /var/www/mysite
            git fetch origin main
            git reset --hard origin/main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            sudo systemctl reload php8.2-fpm
            echo "deploy done at $(date)"

appleboy/ssh-action 是社区里用得最多的 SSH 执行 Action,省去了自己处理密钥的麻烦。几个注意点:

  • git reset --hard origin/main 会丢弃服务器上的本地改动。如果服务器上有运行时会修改的文件(比如缓存、上传目录),把它们加进 .gitignore,否则每次部署都会被覆盖。
  • set -e:任何一条命令失败就中止,避免「构建失败但服务还重启了」的中间状态。
  • 需要 sudo 的命令要提前给部署用户配免密 sudo,或改用 systemctl --user

六、部署失败自动回滚

自动化部署最大的风险是「把坏版本推上生产」。最简单的兜底方案是保留上一个版本的符号链接,部署失败时切回去。目录结构设计:

/var/www/mysite/
├── releases/
│   ├── 20260913010101/
│   └── 20260913020202/
├── current -> releases/20260913020202
└── shared/
    ├── uploads/
    └── .env

这是 Capistrano 风格的发布结构:每次部署新建一个带时间戳的目录,完成后把 current 软链指向新目录。Nginx 的 root 指向 /var/www/mysite/current。回滚就是改一下软链,秒级完成:

ln -sfn /var/www/mysite/releases/20260913010101 /var/www/mysite/current
systemctl reload nginx

在 Actions 里加一个失败通知和手动回滚入口:

      - name: Notify on failure
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: '部署失败: ' + context.sha.slice(0,7),
              body: '查看运行日志: ' + context.payload.head_commit.url
            })

更完整的做法是给自己发邮件或 Webhook 通知(钉钉/飞书/Telegram bot),个人站长用 Telegram Bot 最简单:一个 HTTP POST 就能推消息到手机。

七、七个必踩的坑

1. 私钥格式错误

最常见的问题是粘贴 Secrets 时缺少换行。OpenSSH 私钥的最后一行必须换行结尾。解决方法:用 cat ~/.ssh/deploy_key | base64 -w0 存成 base64,在 workflow 里解码:

- name: Setup SSH
  run: |
    mkdir -p ~/.ssh
    echo "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | base64 -d > ~/.ssh/deploy_key
    chmod 600 ~/.ssh/deploy_key

这样彻底避免了换行丢失的问题。

2. SSH 首次连接交互卡死

不加 ssh-keyscan 也不加 StrictHostKeyChecking=no 的话,任务会卡在「Are you sure you want to continue connecting」然后超时。三种解法:ssh-keyscan 写 known_hosts(推荐)、-o StrictHostKeyChecking=accept-new、或 -o StrictHostKeyChecking=no(最省事但最不安全,有中间人风险)。

3. rsync 的 --delete 清空目录

路径末尾的斜杠决定语义:./dist/ 同步的是 dist 目录的内容./dist 同步的是 dist 目录本身。前者才是想要的。目标路径同理。上线前一定先加 --dry-run 跑一次看要删哪些文件。

4. 权限问题(Permission denied)

rsync 上传后文件属主是登录用户,Nginx 若以 www-data 运行可能读不了。解决方案:部署后执行 chown -R www-data:www-data /var/www/html,或者把部署用户加入 www-data 组并设好目录 setgid 位。

5. Actions 里的 sudo 不可用

GitHub 托管的 Runner 里 sudo 是有的(因为 runner 用户配了免密 sudo),但如果你的部署脚本在远程服务器上执行 sudo,就得单独给那个用户配 /etc/sudoers.d/ 免密规则。示例:

# 在服务器上:visudo -f /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx, /bin/systemctl reload php8.2-fpm

只授权必要的命令,不要写 NOPASSWD: ALL

6. 部署并发冲突

连续两次快速 push 会触发两个并行的 workflow,两个 rsync 同时写同一个目录可能产生破损文件。解决方法是在 workflow 里加并发控制:

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

cancel-in-progress: false 表示不取消正在跑的,而是排队等待,保证部署按顺序执行。

7. 部署过程中网站短暂不可用

rsync 覆盖文件时,访问者可能拿到「一半新一半旧」的状态,或文件删除瞬间出现 404。对静态站,推荐的做法是 rsync 到临时目录再原子切换:

rsync -az --delete ./dist/ host:/var/www/releases/${{ github.run_id }}/
ssh host "ln -sfn /var/www/releases/${{ github.run_id }} /var/www/html"

软链切换是原子操作,访问者要么看到旧的完整版本,要么看到新的完整版本。

八、加入部署前后的检查

一次成熟的部署流水线不只是「拷文件」,还应该包含检查步骤。一个实用的三段式:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run lint
      - run: npm test --if-present

  deploy:
    needs: test          # test 通过才部署
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... 构建与部署

  verify:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - name: Health check
        run: |
          sleep 5
          code=$(curl -s -o /dev/null -w "%{http_code}" https://www.example.com/)
          echo "HTTP $code"
          if [ "$code" != "200" ]; then
            echo "部署后健康检查失败"; exit 1
          fi

needs 建立了 job 之间的依赖顺序。verify 这一步的 curl 其实是「用行为验证结果」的最小形式,比人肉点开网站确认可靠得多。

九、成本与安全提醒

  • 免费额度:公开仓库无限量;私有仓库 2000 分钟/月(Linux runner 按 1 倍计费)。部署几分钟一次,正常用不会超。
  • Secrets 不会输出到日志:GitHub 会自动掩码,但仍要避免 echo $SECRET 这类操作,尤其是调试时。
  • 密钥最小权限:部署用户只给网站目录权限,不要给 root。
  • Actions 供应链安全:第三方 Action 用固定版本号(@v1.0.3)而不是 @master,避免上游被篡改。
  • 避免 fork 触发pull_request_target 事件在 fork PR 场景下可能泄露 secrets,除非确实需要,否则用 push 触发。

十、小结

把部署自动化之后,个人站长的发布流程会变成:本地 git push → 等一分钟 → 网站已更新。整个过程不需要登录服务器,也不依赖你的电脑在线。这套流水线搭一次大概花一小时,之后每天都能省下重复操作、而且拉平了「忘了哪台机器是什么版本」的风险。

建议的推进顺序是:先做模式 A 的静态站 rsync 部署(最简单)→ 加上 test / verify 检查 → 再加上原子切换和失败通知。不要一上来就追求完整的 CI/CD 体系,能自动把代码安全地放到服务器上,就已经击败 90% 还在手动 scp 的站长了。

Last modification:September 13th, 2026 at 07:56 am

Leave a Comment