+++ title = "如何优雅地部署一个静态网站?" tags = [ "Web", ] slug = "deploying-static-sites" categories = "代码炼金术" date = 2026-01-28T18:07:29 draft = false toc = true toot = "https://akk.eltr.ac/objects/45dac016-6cd7-4874-b919-5107f5d9fdf3" url = "/essays/deploying-static-sites/" +++ PaaS 软件用了太久,导致自己对部署网站这件事产生了很多不切实际的想象,总感觉不使用 Vercel、Netlify 和 Cloudflare Pages 就不够现代。实际上,静态网站就是一系列 HTML 文件和必要的静态资源而已,即便是用 Hugo 和 Hexo 等静态网站生成器,得到的也是相同的文件。这些文件如果被用户下载到本地,用浏览器直接打开,也能够正常浏览,毕竟不涉及任何服务端的计算,那为什么还要把事情弄得这么复杂呢? ## 使用 PaaS PaaS 的意思是平台即服务(Platform as a Service),可以把平台理解为开发和运行应用的基础设施,PaaS 提供了这些设施,降低了开发者进行运维工作和开发过程中部分工作的复杂度。比如,你编写了一个 Web 应用,通常情况下你需要购置一台服务器,然后配置应用的运行环境、配置 Web 服务器和反向代理、申请 SSL 证书并管理续期、设置防火墙和其他必要的安全措施、维护数据库、维护依赖等等,当应用的代码更新之后,你还需要重新部署。 PaaS 能让开发者做到,直接提交代码库给平台,应用就能跑起来并且运转良好,并且往往具有持续集成和持续部署(CI/CD)的能力,使得新的代码在推送到仓库主分支过后就能自动构建并部署应用。 与之相似的架构是 Serverless,也就是无服务器架构。这两种架构其实都极大地弱化了后端的存在,不过 Serverless 更偏向通过事件触发的云函数,而非随时在线的常规 Web 应用。这里略过不谈。 使用 Vercel、Netlify 和 Cloudflare Workers/Pages 这样的服务构建和部署静态网站是可行的,并且非常简单,一般来说步骤是这样的: 1. 将你的网站源代码推送到远程 Git 仓库。 2. 在 PaaS 平台创建一个新的应用,设置 Git 仓库并选择软件架构(如果是 Node.js 等常见技术栈,成熟的 PaaS 平台往往会自动识别) 3. PaaS 克隆仓库,运行构建脚本,然后将构建产物部署到他们提供的网络上。 4. 此后每次有新的推送,PaaS 都会重新克隆仓库、构建和部署。 简单来说,你只需要把代码准备好就行,几乎不需要做任何运维操作。 问题在于,部署一个静态网站并不复杂,运行脚本构建好网站之后,将构建产物上传到一个可以分发文件(静态资源)的服务器上就好了。这个服务器甚至不需要有计算能力,可以是对象储存桶。印象中 Vercel 会把应用部署到他们的边缘网络(edge network)上,所谓「边缘」,指的是靠近端系统(end system)也就是用户的网络,网络的边缘部分离用户最近,所以响应速度往往很快。对于静态网站而言,这跟 CDN(内容分发网络)差不多。 所以,如果不用 PaaS,能把这个过程自动化吗? ## 使用自动化工作流部署 由于我只使用过 GitHub Actions 和 Forgejo Actions,所以这里只谈这两个。Forgejo 的 Actions 几乎是和 GitHub 完全兼容的,语法也相似,所以在下文统称 Actions。 Actions 是自动化的工作流,有多种触发条件,最常见的是 `push`,也就是在有代码推送到远程仓库后触发;也可以限定分支,比如只有代码被合并到主分支之后才触发工作流。工作流可以执行命令,自然也可以执行用于构建静态网站的脚本,比如 `npm run build` 和 `hugo --minify` 等等。 需要说明的是,Actions 往往跑在与其他运行环境隔离的容器里,不能直接在宿主服务器上运行代码,并且工作流被执行完毕之后,往往会恢复现场,运行的结果不会保留在容器里,所以除了执行构建步骤,还要执行部署操作。如果要部署到一个对象储存桶,可以使用 `rclone`;如果要同步到远程服务器上,可以使用 `rsync`;如果要部署到 Cloudflare Workers 上,可以使用官方提供的 Wrangler 工具(不过你为什么不直接用 Cloudflare Workers 构建呢?)。 可以把工作流理解为「剧本」,程序员是编剧,把剧本交给 Actions 之后,它就会在规定的场合下按照剧本表演,结束后,会有人收拾舞台,一切都会恢复成原来的样子。如果要把舞台上的表演保留下来,就需要在表演进行时拍照和录像。 同样地,不同的剧目所需要的道具不同,就像不同的软件和脚本运行所需要的依赖项不同,戏班子上场的时候要把道具带上,编剧也要把道具在剧本里写清楚。Actions 一般跑在一个最小的、容器化、只有一个 CPU 核心的 Linux 系统上,以下称作 Runner。Runner 比较轻量,可以在 Docker 里运行,甚至给我一种 WSL(Windows Sub-system Linux)的感觉,上面预装的软件包很少,所以工作流里还要写安装依赖的步骤。 比如,构建一个 Hugo 网站,部署远程服务器上,工作流可能需要这样写: 1. 安装 Node.js(如果有 Node.js 依赖) 2. `npm install`[^1] 3. `npm run build`(运行 Node.js 工具,比如构建 UnoCSS) 4. 安装 Go 语言依赖 5. 安装 Hugo 6. `hugo --minify`(构建网站) 7. 安装 Rsync 8. `rsync ./public user@host.ip:/path/to/site`(部署网站到服务器)\ 由于在远程服务器上写入文件往往需要认证,最安全的方法是生成一对 SSH 密钥,在 `rsync` 之前先配置 SSH 私钥,保证目标服务器上有储存对应的公钥,这样才能正常访问目标服务器。此外,还需要在目标服务器上配置目录权限,保证工作流有权限写入。 具体的 `workflow.yml` 可能是这样的: ```yaml name: Build and Deploy Hugo on: push: branches: - master jobs: deploy: runs-on: docker steps: - uses: actions/checkout@v4 with: submodules: true fetch-depth: 0 - name: setup Go uses: actions/setup-go@v5 with: go-version: "1.25.1" - name: setup Hugo uses: https://github.com/peaceiris/actions-hugo@v3 with: hugo-version: "latest" extended: false - name: build site run: hugo --minify - name: Install rsync run: | apt-get update && apt-get install -y rsync - name: Setup SSH run: | mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts - name: Deploy via rsync run: | rsync -avz --delete public/ \ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/home/eltrac/homepage/ ``` 之后在服务器上配置好 Web 服务器,将通过自动流程部署的文件公开就好了。以下是一个 `Caddyfile` 示例: ```Caddyfile eltr.ac { root * /home/eltrac/homepage/ file_server } ``` 这个方案的缺点非常明显,我用这个方法搭了一个网站之后就再也不想搭第二个了。 1. 你需要管理一对 SSH 密钥,并且你允许了外部程序向你的服务器直接写入文件,这有安全风险。 2. Actions 没法在本地测试,必须推送到远程仓库后,Forgejo/GitHub 才会运行工作流,这使得调试工作流变得**非常**痛苦。 3. 每次运行工作流都需要下载并安装依赖项,对于 Hugo 这种构建速度非常快,甚至不会超过一秒的静态网站生成器,大部分的构建时间都花费在「准备运行环境」上了;而且,这个运行环境在跑完一次之后就会被恢复。如果你使用自己管理的 Forgejo Runner,可以在 Runner 里预安装自定义的依赖项,但复杂度不亚于维护一个单独的服务器。 4. *用 YAML 写工作流十分反人类。* 既然如此,我能不能直接在已经安装好运行环境的宿主服务器上直接运行构建脚本呢?构建产物直接在宿主服务器上生成。这样就可以省去与服务器通过 SSH 密钥认证并建立连接的过程,也不需要把大量的时间浪费在准备运行环境上了。 ## 使用 Webhook 触发远程服务器部署 构建和部署静态网站之所以使用自动化工作流,是想要实现持续集成和持续部署,也就是在**确认**代码更改后就立刻更新生产环境的代码。一般来说,将代码合并到主分支就代表代码可以运行在生产环境了,自动化工作流可以以此为触发点,在有代码推送到主分支后就执行构建和部署步骤。在服务器上直接构建代码而不是用 Git 仓库的自动化工作流部署,最大的不足在于无法通过 `git push` 自动触发构建,但这个问题并非没有解决方案。 我不清楚 GitHub 的情况,Forgejo 可以在仓库设置里配置 Webhook,即在某些事件发生时向指定的 Webhook 端口发送 HTTP 请求。只要在服务器上开放一个 Webhook 端口,在接收到 HTTP 请求时拉取新代码并重新构建网站,然后配置 Forgejo 仓库在 `master` 分支收到新的代码推送时向这个 Webhook 端口发送 HTTP 请求,就能够实现 `git push` 后自动更新网站。宿主服务器不是容器化的最小运行环境,只要配置好了依赖和必要的软件,就不需要像自动化工作流那样,每次构建都需要重新安装依赖,更新可以在几秒内完成,体验上不会比动态网站慢。 具体而言,要先在服务器上配置好必要的工具。我使用 Hugo,也就需要在服务器上安装好 Go、Git 以及 Hugo。我的 VPS 安装的是 Ubuntu 系统,所以这里用 Aptitude 包管理器安装。 ```shell sudo apt update sudo apt install golang-go sudo apt install git ``` 使用 Aptitude 安装 Hugo 的时候,我发现版本不是最新的,因此遇到了一些问题。所以我打算直接从源代码编译,先克隆仓库,然后编译,将编译后得到的二进制文件移动到 `bin`,测试能否在终端运行。 ```shell git clone https://github.com/gohugoio/hugo.git cd hugo go build . mv hugo /usr/local/bin/ hugo version ``` 其实也可以直接用 Go 安装,但我忘记当时遇到了什么问题,可能是没有把 Go 的 `bin` 添加到 `$PATH`,总之没能成功安装。 ```shell go install github.com/gohugoio/hugo@latest hugo version ``` 接下来准备构建网站的脚本。先把网站的源代码克隆到本地,安装好依赖之后,创建一个 `.sh` 文件 ```shell # 这是托管在我自己的 Forgejo 实例上的网站 git clone https://src.eltr.ac/eltrac/jar.git cd jar pnpm install # 安装 Node.js 依赖 vim build.sh ``` 这个文件里,按照**实际情况**编写构建网站的步骤。由于需要被执行,所以要在开头写上 `#!/bin/sh` ```shell #!/bin/sh git pull # 先拉取最新的更改 pnpm unocss "**/*.{html,md}" -o assets/uno.css hugo --minify ``` 运行这个 Shell 脚本,测试能够正常构建网站。如果输出正常,就可以继续配置 Webhook 了。这里使用 adnanh 的 [webhook 实现](https://github.com/adnanh/webhook),这是一个命令行工具,能够读取 JSON 或 YAML 配置。以下是示例配置文件,用 JSON 编写。 ```shell # 先安装 Webhook sudo apt-get install webhook ``` ```JSON // hooks.json [ { "id": "build-hugo", "execute-command": "/home/eltrac/jar/build.sh", "command-working-directory": "/home/eltrac/jar" } ] ``` 执行 `webhook -hooks hooks.json`,API 就会运行在 `:9000` 端口上,我们刚刚配置的 API 路径是 `localhost:9000/hooks/build-hugo`。要允许公网访问,可以用 Caddy 配置反向代理。这里建议用 `systemctl` 运行和管理 Webhook 服务,具体步骤此处略去。 ```Caddyfile hooks.example.com { reverse_proxy localhost:9000 } ``` 然后,在 Forgejo 仓库配置 Webhook,当有代码被推送到主分支上时,就向我们刚刚配置好的 API 端口发送 HTTP 请求。运行在服务器上的 Webhook 收到请求后,就会运行我们刚才编写好的 `build.sh`,网站就会被重新构建。 ![](https://img.eltr.ac/geedeapro/essays/deploying-static-sites/forgejo-repo-webhook.png "在 Forgejo 仓库设置找到 Webhook") ![](https://img.eltr.ac/geedeapro/essays/deploying-static-sites/1Capture_2026-01-28_17.42.31.png "添加 Webhook") 完成后,向 Forgejo 远程仓库推送更新,测试构建脚本有没有被触发。不出问题的话,你就能获得一个几乎能在 `git push` 一秒后更新的静态网站。 ## 对比三种方案 对于缺少经验的人来说,使用 PaaS 是最简单的,几乎去掉了维护一个网站所需要的所有运维技能,只需要关注写代码。不过,似乎大部分 PaaS 都只支持从 GitHub 和 GitLab 和拉取代码,或者自己手动上传代码,不支持和自托管的 Gitea 或者 Forgejo 实例集成。个人感觉,==使用 PaaS 就像是在和恶魔交易,出卖自己的灵魂以换取便捷,代价是会被科技公司锁死在他们的平台上==。 GitHub Actions 类的自动化工作流,想必是有用武之地的,但我认为并不适合用来部署网站。如果只是运行自动化测试或拉取数据这种操作,把工作流脚本和代码放在一个仓库里大概是合适的。如果某些操作需要大量的依赖项才能运行,那么自动化工作流的效率就会因为需要安装大量依赖而被拉低。 目前实践下来,个人认为最优雅的方案就是在服务器上直接构建,通过 Webhook 触发,这样就省去了部署的步骤。不过,如果没有一台 Web 服务器,也就用不了这个方法了。 印象中还有人会在本地构建,将构建产物使用 `rclone` 同步到储存桶,或者使用 `rsync` 同步到服务器上,我没有实践过这种做法,就不评价了。 [^1]: 其实我更喜欢使用 `pnpm`,但如果要在自动化工作流里使用,还要额外安装,所以这里就直接用 `npm` 了。