专题报告
从 GitHub 到云服务器:可回滚的静态网站自动部署
用 Astro、GitHub Actions、SSH、Docker Compose 和 Caddy,搭建一条不依赖固定电脑、支持健康检查与自动回滚的发布链路。
先说明示例边界
本文展示的是一套可复用的技术流程,不是某台真实服务器的配置清单。所有地址、路径和 Secret 名称均为教学占位符:203.0.113.10 是专用于文档的保留 IP,域名使用 example.com,服务器目录使用 /srv/site。实际使用时应替换这些值,但不要把私钥、真实指纹或生产环境变量提交到仓库。
- 构建位置
- GitHub
- 发布方式
- SSH
- 切换策略
- 原子
生产服务器不安装前端构建工具
专用低权限账户接收静态文件
健康检查失败自动恢复上一版
目标与适用范围
这套方案适合个人网站、文档站、作品集和轻量内容门户。它优先解决四个问题:
- 不绑定某一台电脑。任何设备只要能向 GitHub 提交内容,就能触发发布。
- 不在小型服务器上执行前端构建,把 CPU 和内存留给线上服务。
- 发布过程中不覆盖正在使用的文件,避免用户读到半个新版本。
- 新版本异常时自动回滚,不把故障版本留在线上。
它暂时不解决登录、在线编辑、数据库和用户上传。这些能力出现真实需求后,再以独立 API 服务扩展。
一次发布如何完成
- 01 提交内容
在本地或 GitHub 网页修改 Markdown、MDX 或页面代码。
- 02 云端检查
Actions 安装锁定依赖,并执行类型检查和静态构建。
- 03 生成版本
构建结果写入提交哈希,作为线上健康检查标记。
- 04 安全上传
临时 Runner 使用部署私钥,以低权限账户通过 SSH 和 rsync 上传。
- 05 原子切换
新文件进入独立目录,上传完成后再切换 current 软链接。
- 06 验证或回滚
公网读取版本标记;不匹配就恢复发布前的软链接。
职责分工很清晰:
| 位置 | 负责什么 | 不负责什么 |
|---|---|---|
| 编辑设备 | 修改内容、提交 Git | 不直接上传服务器 |
| GitHub 仓库 | 保存源代码和历史 | 不提供生产流量 |
| GitHub Actions | 检查、构建、上传、验证 | 不长期保存运行环境 |
| Linux 服务器 | 保存发布版本、运行 Caddy | 不执行 Astro 构建 |
| Docker Compose | 声明并恢复 Web 容器 | 不管理 Git 或内容版本 |
| Caddy | 静态文件、压缩、HTTPS | 不修改网站内容 |
第一步:准备服务器边界
服务器需要 OpenSSH、rsync、Docker Engine 和 Docker Compose 插件。安全组通常开放:
| 端口 | 协议 | 用途 |
|---|---|---|
| 22 | TCP | Actions 和管理员 SSH |
| 80 | TCP | HTTP 和证书验证 |
| 443 | TCP | HTTPS |
| 443 | UDP | HTTP/3,可选 |
不要让 Actions 使用 root。创建一个专用账户,例如 deploy,只让它拥有发布目录:
sudo useradd --create-home --shell /bin/bash deploy
sudo install -d -m 755 -o deploy -g deploy /srv/site/releases
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
将部署公钥写入 /home/deploy/.ssh/authorized_keys,权限设为 600。服务器只保存公钥;私钥保存在 GitHub Repository Secret 中。
人工管理应使用另一套密钥和另一个 sudo 账户。部署密钥即使泄露,其权限也不应足以修改系统服务。
第二步:用 Compose 运行 Caddy
下面是精简后的 compose.yaml:
services:
web:
image: caddy:2-alpine
container_name: site-web
restart: unless-stopped
environment:
SITE_ADDRESSES: "${SITE_ADDRESSES:-http://203.0.113.10}"
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./releases:/releases:ro
- caddy_data:/data
- caddy_config:/config
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
caddy_data:
caddy_config:
这里有三项重要设计:
releases以只读方式挂载,Web 容器无法篡改发布文件。- Caddy 的证书和运行状态进入命名数据卷,重建容器不会丢失。
- 日志轮转限制单个容器最多保留约 30 MiB,避免长期占满系统盘。
restart: unless-stopped 表示服务器重启后,Docker 会自动恢复容器,但前提是 Docker 服务已设置开机启动:
sudo systemctl enable --now docker
cd /srv/site
sudo docker compose up -d web
Compose 不是常驻进程。服务器启动时是 systemd 启动 Docker,再由 Docker 根据重启策略恢复 Caddy。手动执行 docker compose stop 会让容器保持停止;执行 docker compose down 会删除容器;日常维护不要使用 docker compose down -v,因为它还会删除 Caddy 数据卷。
第三步:配置静态文件服务
一个足够稳健的 Caddyfile 可以保持很短:
{$SITE_ADDRESSES:http://203.0.113.10} {
root * /releases/current
encode zstd gzip
file_server
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
}
}
/releases/current 不是固定目录,而是指向某个版本目录的软链接。Caddy 始终读取它,因此发布脚本只需切换软链接,不必重启容器。
在域名和合规手续准备完成后,把环境变量改为真实域名:
SITE_ADDRESSES=example.com, www.example.com
重新执行 docker compose up -d web 后,Caddy 会自动申请并续期 HTTPS 证书。域名所在地区有备案或页面展示要求时,应先完成当地要求再切换公开访问。
第四步:建立两向 SSH 信任
SSH 连接同时验证两件事:Runner 是谁,以及服务器是不是预期目标。
| 内容 | 保存位置 | 作用 |
|---|---|---|
| 部署私钥 | GitHub Secret DEPLOY_SSH_PRIVATE_KEY | 证明 Runner 有部署权限 |
| 部署公钥 | 服务器 deploy 的 authorized_keys | 验证私钥签名 |
| 主机公钥记录 | GitHub Secret SERVER_KNOWN_HOSTS | 防止连接到伪造服务器 |
私钥不会通过网络发送。Runner 用私钥签名,服务器用公钥验证;known_hosts 则让 Runner 验证服务器身份。每次 Actions 结束后,临时 Runner 和其中的密钥文件会被销毁。
轮换密钥的顺序必须是:先安装新公钥,再更新 GitHub 私钥,成功部署一次后才删除旧公钥。反过来操作会中断发布通道。
第五步:让 Actions 负责构建
以下工作流省略了与本站点无关的配置,但保留了核心边界:
name: Deploy website
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: website-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check && pnpm build
- name: Add deployment marker
run: |
mkdir -p dist/.well-known
printf '%s' "$GITHUB_SHA" > dist/.well-known/deploy-version
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SERVER_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/known_hosts
- name: Deploy
env:
DEPLOY_HOST: 203.0.113.10
DEPLOY_USER: deploy
RELEASE_ID: ${{ github.run_number }}-${{ github.sha }}
DEPLOY_VERSION: ${{ github.sha }}
HEALTHCHECK_URL: http://203.0.113.10
run: bash scripts/deploy.sh
contents: read 把工作流的仓库权限压到最低。concurrency 防止两个生产发布同时切换版本,cancel-in-progress: false 则避免新的提交在旧发布切换到一半时强行终止它。
第六步:原子发布与自动回滚
服务器目录最终类似这样:
/srv/site/releases/
├── 41-a1b2c3d...
├── 42-d4e5f6a...
├── 43-7a8b9c0...
└── current -> 43-7a8b9c0...
发布脚本的核心顺序是:
# 1. 记录发布前的目标
previous_release="$(ssh "$remote" "readlink /srv/site/releases/current" || true)"
# 2. 上传到全新的独立目录
ssh "$remote" "mkdir -p /srv/site/releases/$RELEASE_ID"
rsync -az --delete dist/ "$remote:/srv/site/releases/$RELEASE_ID/"
# 3. 完成上传后,原子替换 current
ssh "$remote" "ln -sfn '$RELEASE_ID' /srv/site/releases/current.next"
ssh "$remote" "mv -Tf /srv/site/releases/current.next /srv/site/releases/current"
# 4. 公网读取构建时写入的提交哈希
deployed_version="$(curl --fail --silent "$HEALTHCHECK_URL/.well-known/deploy-version")"
# 5. 不匹配就把 current 恢复为 previous_release
直接覆盖 current 目录存在一个窗口:Caddy 可能在上传过程中读到新旧文件混合状态。独立版本目录加软链接切换消除了这个窗口。
健康检查不能只验证首页返回 200,因为缓存或旧版本也可能返回成功。读取提交哈希可以确认公网真正提供的是本次构建。
版本目录应自动清理,例如保留最近 5 个,同时始终保留当前版本和发布前版本。这样既能回滚,也不会无限占用磁盘。
失败时线上会发生什么
| 失败位置 | 线上影响 | 处理方式 |
|---|---|---|
| 依赖安装或类型检查 | 无 | 工作流停止,线上仍是旧版本 |
| 静态构建 | 无 | 修复代码后重新提交 |
| SSH 连接 | 无 | 检查密钥、端口和 known_hosts |
| rsync 上传 | 通常无 | current 尚未切换,重新运行即可 |
| 健康检查 | 短暂切换后恢复 | 脚本自动恢复上一个软链接 |
| Caddy 容器退出 | 网站不可用 | Docker 根据重启策略恢复 |
| 整台服务器损坏 | 网站不可用 | 从 GitHub、环境配置和密钥备份重建 |
日常维护只剩一件事
发布新内容时,不需要登录服务器,也不需要执行 Docker 命令:
git add src/content/
git commit -m "Add report"
git push origin main
也可以直接在 GitHub 网页编辑内容。提交进入 main 后,后续步骤完全相同。服务器登录只用于升级 Docker/Caddy、修改域名环境变量或排查系统故障。
建议定期检查:
- Actions 最近一次运行是否成功。
- 公网版本标记是否等于目标提交。
- Docker 服务是否为
enabled和active。 - Caddy 容器是否为
running。 - 发布目录和系统盘是否接近容量上限。
- 管理员私钥、部署私钥和生产环境变量是否存在加密异机备份。
什么时候需要扩展
静态部署不是架构终点,只是把动态复杂度推迟到真正需要时:
- 需要搜索时,先尝试构建期索引或浏览器端搜索。
- 需要跨设备保存数据时,在 Compose 中增加 API 服务,并由 Caddy 反向代理
/api/*。 - 需要可靠事务时,再引入数据库和自动备份。
- 图片和附件显著增加后,再迁移到对象存储和 CDN。
- 引入任何不可从 GitHub 重建的数据前,先定义备份保留周期并实际演练恢复。
这套流程的价值不在于工具数量,而在于每个组件职责单一:GitHub 保存事实和历史,Actions 生成可发布结果,SSH 只传输,软链接负责切换,Caddy 负责访问,Docker 负责进程恢复。边界清晰之后,小型服务器也能拥有稳定、可审计并且容易扩展的发布系统。