「改完代码 → 本地打包 → 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: falsecancel-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
fineeds 建立了 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 的站长了。