CI/CD 自动部署流水线:GitHub Actions 推送到服务器

先讲一个真实场景。某团队四个人维护一个日活两万的社区站,部署一直靠"负责人手动 SSH 上去 git pull 再重启"。一次周一早上,有人把一段没跑过测试的代码合并进了 main,十点上线后线上报错,等他们发现已经是午休后,那一整个上午的转化都受影响。事后复盘,问题不在代码,而在流程:发布依赖人、没有自动化、没有回滚。

CI/CD 的最终目标是"代码合并后自动发布到生产"。对大多数跑在 VPS 或云服务器上的中小项目,最常见的落地方式是:GitHub Actions 在云端构建,通过 SSH 把产物推送到服务器,再在服务器上执行部署命令。这也是Git 自动部署工作流的自动化升级版。

一、整体架构

push main
  → GitHub Actions(构建、测试)
    → scp 上传构建产物到服务器
      → SSH 执行部署脚本(rsync / 拉镜像 / 重启容器)
        → 健康检查

这条链路里,CI 负责"可重复地构建出产物",服务器只做"替换产物并重启",职责清晰,出问题容易定位。

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

  1. 在服务器上创建专用部署用户,并生成密钥对:
adduser deployer
mkdir -p /var/www/app && chown -R deployer:deployer /var/www/app
ssh-keygen -t ed25519 -a 200 -f ~/.ssh/deploy_key
cat ~/.ssh/deploy_key.pub | ssh deployer@server 'cat >> ~/.ssh/authorized_keys'
  1. 把私钥与连接信息存入 GitHub Secrets(不要写进仓库):DEPLOY_KEYSERVER_HOSTSERVER_USERSERVER_PORT

  2. 建议限制部署用户只能操作目标目录,比如用 sudo -u 或把可执行命令白名单化,避免密钥泄露后攻击者能横向操作整台服务器。服务器初始化可参考服务器初始化脚本

三、Workflow:SCP 上传 + SSH 执行

以下示例使用社区常用的 appleboy/scp-actionappleboy/ssh-action

name: Deploy to Server

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Upload via SCP
        uses: appleboy/scp-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          source: "dist/*"
          target: /var/www/app/dist

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            cd /var/www/app
            systemctl reload nginx
            curl -fsS http://127.0.0.1/healthz || exit 1

几个关键点:

  • SSH 密钥而非密码,私钥只存在于 GitHub Secrets,日志里也不会暴露口令;
  • source 只上传构建产物(dist/*),避免覆盖服务器上的其他文件;
  • 部署后执行健康检查,失败则让流水线标红,不让坏版本"静默上线"。

为了让"标红"之后还能快速恢复,建议在服务器上维护一个可回滚的部署方式。以静态站为例,保留最近两次构建,用软链切换当前版本:

# 服务器上的 deploy.sh(由 SSH 步骤调用)
VERSION=$1
ln -sfn /var/www/releases/$VERSION /var/www/current
systemctl reload nginx
curl -fsS http://127.0.0.1/healthz || {
  ln -sfn /var/www/releases/$((VERSION-1)) /var/www/current
  systemctl reload nginx
  exit 1
}

这样"新版本失败自动回退上一版",比直接覆盖更稳。无论流程重跑多少次,软链切换都是幂等的,服务器不会进入"半部署"状态;把"部署"与"验证"拆成独立步骤,验证失败时也只需重跑验证,不必重复上传。

四、容器化部署

如果目标服务器跑 Docker,SSH 脚本改为在服务器上构建/拉取镜像并滚动重启容器:

script: |
  cd /opt/app
  docker compose pull web
  docker compose up --no-deps -d web
  sleep 5
  curl -fsS http://127.0.0.1:8080/healthz || exit 1

这种做法配合 Docker Compose 生产部署 中的健康检查与重启策略,可以实现基本自动化的滚动发布。更完善的蓝绿/金丝雀策略见零停机部署策略

五、部署方式对比

方式 自动化程度 回滚速度 适合
手动 SSH + git pull 起步阶段
Actions + SCP/rsync 静态产物 中(软链秒切) 静态站、前端 SPA
Actions + 镜像仓库 + 容器重启 中(镜像即版本) Node/Python 服务、多实例
完整 K8s / ArgoCD 极高 高(自动回滚) 大规模微服务

对大多数中小项目,第二、三行已经足够,不必为了"上 CD"而引入 K8s。

六、密钥安全

  • 密钥永远存在 GitHub Secrets,workflow 里用 ${{ secrets.XXX }} 引用;
  • 不给部署用户 root 权限,限制目标目录写权限;
  • 建议定期轮换部署密钥(例如 90 天),并在服务器上移除旧的 authorized_keys 条目;
  • 更先进的方案是用 OIDC 短时令牌替代长期密钥,见Docker 支持 GitHub Actions OIDC

七、进阶:构建镜像推送到仓库

规模稍大时,更稳妥的做法是:Actions 构建 Docker 镜像 → 推送到镜像仓库(GHCR/Docker Hub)→ 服务器只负责拉取并重启。好处是构建与运行环境彻底分离、产物可追溯:

- name: Build and push image
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/yourorg/app:${{ github.sha }}

镜像的 SHA 就是版本号,回滚 = 重新拉上一个 tag,排查问题时也能精确定位到某次提交。

八、常见问题

Q:Actions 报 "Permission denied (publickey)"? 多半是密钥没配好:确认私钥完整粘贴进 DEPLOY_KEY、服务器 authorized_keys 权限为 600、部署用户的家目录不能被其他用户写。

Q:每次部署都很慢? 先看是否每次全量重装依赖。用 actions/cache 缓存 node_modules 或 pnpm store,能省下一大半安装时间;同时用 concurrency 保证同一分支只有一个部署在跑。

Q:部署完线上没变化? 检查上传的 target 目录是否和 Web 服务器配置的根目录一致,以及 Nginx/Apache 是否真的 reload 成功。

九、与 GitLab 对比

使用 GitLab 的团队可参考GitLab CI/CD 最佳实践,两种平台思路一致:构建在 CI 侧,部署靠 SSH/容器。完整的流水线概念见网站 CI/CD 流水线搭建,GitHub Actions 入门见GitHub Actions CI/CD 配置教程

16IDC 观察

"GitHub Actions + SSH"是中小项目性价比最高的自动部署形态:不引入 K8s、不维护自建 CI 服务器,就能把"手工 SSH 上传、手工重启"变成"push 即发布"。它最大的价值不是省去那几分钟,而是让发布过程可重复、可追溯、可回滚。从 SCP 上传起步,再逐步演进到容器镜像 + 滚动发布,是一条稳妥的成长路径。

参考:https://docs.github.com/en/actions 、https://github.com/appleboy/scp-action 、https://github.com/appleboy/ssh-action