返回报告

专题报告

从 GitHub 到云服务器:可回滚的静态网站自动部署

用 Astro、GitHub Actions、SSH、Docker Compose 和 Caddy,搭建一条不依赖固定电脑、支持健康检查与自动回滚的发布链路。

发布于 2026年8月8日
  • 部署
  • GitHub Actions
  • Docker
  • Caddy

先说明示例边界

本文展示的是一套可复用的技术流程,不是某台真实服务器的配置清单。所有地址、路径和 Secret 名称均为教学占位符:203.0.113.10 是专用于文档的保留 IP,域名使用 example.com,服务器目录使用 /srv/site。实际使用时应替换这些值,但不要把私钥、真实指纹或生产环境变量提交到仓库。

构建位置
GitHub

生产服务器不安装前端构建工具

发布方式
SSH

专用低权限账户接收静态文件

切换策略
原子

健康检查失败自动恢复上一版

目标与适用范围

这套方案适合个人网站、文档站、作品集和轻量内容门户。它优先解决四个问题:

  1. 不绑定某一台电脑。任何设备只要能向 GitHub 提交内容,就能触发发布。
  2. 不在小型服务器上执行前端构建,把 CPU 和内存留给线上服务。
  3. 发布过程中不覆盖正在使用的文件,避免用户读到半个新版本。
  4. 新版本异常时自动回滚,不把故障版本留在线上。

它暂时不解决登录、在线编辑、数据库和用户上传。这些能力出现真实需求后,再以独立 API 服务扩展。

一次发布如何完成

  1. 01 提交内容

    在本地或 GitHub 网页修改 Markdown、MDX 或页面代码。

  2. 02 云端检查

    Actions 安装锁定依赖,并执行类型检查和静态构建。

  3. 03 生成版本

    构建结果写入提交哈希,作为线上健康检查标记。

  4. 04 安全上传

    临时 Runner 使用部署私钥,以低权限账户通过 SSH 和 rsync 上传。

  5. 05 原子切换

    新文件进入独立目录,上传完成后再切换 current 软链接。

  6. 06 验证或回滚

    公网读取版本标记;不匹配就恢复发布前的软链接。

职责分工很清晰:

位置负责什么不负责什么
编辑设备修改内容、提交 Git不直接上传服务器
GitHub 仓库保存源代码和历史不提供生产流量
GitHub Actions检查、构建、上传、验证不长期保存运行环境
Linux 服务器保存发布版本、运行 Caddy不执行 Astro 构建
Docker Compose声明并恢复 Web 容器不管理 Git 或内容版本
Caddy静态文件、压缩、HTTPS不修改网站内容

第一步:准备服务器边界

服务器需要 OpenSSH、rsync、Docker Engine 和 Docker Compose 插件。安全组通常开放:

端口协议用途
22TCPActions 和管理员 SSH
80TCPHTTP 和证书验证
443TCPHTTPS
443UDPHTTP/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 有部署权限
部署公钥服务器 deployauthorized_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 服务是否为 enabledactive
  • Caddy 容器是否为 running
  • 发布目录和系统盘是否接近容量上限。
  • 管理员私钥、部署私钥和生产环境变量是否存在加密异机备份。

什么时候需要扩展

静态部署不是架构终点,只是把动态复杂度推迟到真正需要时:

  1. 需要搜索时,先尝试构建期索引或浏览器端搜索。
  2. 需要跨设备保存数据时,在 Compose 中增加 API 服务,并由 Caddy 反向代理 /api/*
  3. 需要可靠事务时,再引入数据库和自动备份。
  4. 图片和附件显著增加后,再迁移到对象存储和 CDN。
  5. 引入任何不可从 GitHub 重建的数据前,先定义备份保留周期并实际演练恢复。

这套流程的价值不在于工具数量,而在于每个组件职责单一:GitHub 保存事实和历史,Actions 生成可发布结果,SSH 只传输,软链接负责切换,Caddy 负责访问,Docker 负责进程恢复。边界清晰之后,小型服务器也能拥有稳定、可审计并且容易扩展的发布系统。