如何用 Cloudflare Workers 搭个人站点

从空目录到自有域名上、push 即发布的静态站,以及这套搭建里五个「看起来对的答案其实是错的」的地方——它们一个都不报错。

发布于 · 更新于

  • #cloudflare
  • #astro

构建挂了,站点上线了。同一次 push,同一分钟,两个标签页并排开着:GitHub 上一个红叉,Cloudflare 上一个刚部署好的版本。

并排两个浏览器窗口。GitHub:检查未通过。Cloudflare:部署成功,站点已上线。
我花了不太好意思说的时间去判断这两个谁在撒谎。谁都没有。

什么都没坏。按最顺手的方式接线,它本来就是这样跑的,而我花了不太好意思说出口的时间才想明白为什么。

下面是从空目录到「自有域名 + push 即发布」的完整路径,全程在 Cloudflare 免费额度里。你需要 Node 22.12 或更高、一个包管理器,以及最后想要域名的话,一个域名。

第 1 到 5 步就是完整的最低路径。 走完第 5 步,你的域名上就有一个每次 push 都会重新发布的站,到这儿停下也是一件能用的东西。第 6 步是往上面放什么。再往后那节是可选的,跟 Cloudflare 无关,而且是设计笔记,不是步骤。

顺序是我现在会走的顺序,因为顺序本身就是这件事里最值钱的部分。整套搭建有五个地方,看起来对的答案是错的,而且一个都不报错:构建照样绿,浏览器照样正常。这才是这篇真正要讲的东西,Cloudflare 只是我撞上它的地方。

版本钉在这里,这类文章烂得很快:Astro 7.2、Wrangler 4.120、Node 22.23、pnpm 11.21,2026 年 8 月。如果 create astro 给你的是更新的版本,下面的形状应该还成立,提示语的措辞就不一定了。

成品是 latentyonder.com,就是你正在读的这个站。

为什么是 Workers,以及它到底做了什么

Cloudflare 有两个静态托管。Pages 还在跑,今年也不会下线,但新项目已经被指向 Workers + Static Assets,要学就学这个。

先看有人打开你的站时,实际发生了什么:

两条请求路径对比。Workers:访客、Cloudflare 边缘、你的文件。自己运维的虚拟机:访客、DNS、虚拟机、nginx、磁盘。
上面那排没有一样要你运维——这也是它不产生计算账单的原因。

构建产物会提前铺到 Cloudflare 的边缘节点。请求落在离读者最近的那个位置,就地作答。背后没有源站,而且——这点最出人意料——你的代码一行都不会执行。没有计算费用,是因为压根没有计算。

那为什么选 Workers 不选 Pages?Pages 是个静态托管,Workers 是整个平台,静态文件只是它的一种用法。哪天你想加 Cron 触发器、KV、R2,或者一个真的 API 端点,直接加在这个项目上就行,不用搬家。配置也写在仓库的文件里而不是控制台,能 diff,能 review。

已经跑得好好的 Pages 站,我不会为这个迁。新站就从这里开始。

1. 起项目

Astro 适合这活:默认零 JavaScript、Markdown 是一等公民、产物就是一堆普通文件。

node --version   # 必须 >= 22.12
pnpm create astro@latest personal-site

奇数大版本(比如 23)不受支持,所以是 22.12 以上,或者 24,别用 23。

向导会问四个问题,而这四个答案决定了你的目录树跟我的是不是同一套。我选的是:

提问 选择
想怎么开始? Empty(空项目)
安装依赖? 是
初始化 git 仓库? 是
TypeScript 严格到什么程度? Strict

选 Empty 而不是 blog 模板,是有意的。模板会塞给你一套 content collection、一个布局和三篇示例文章,这些东西你第一周就会全部重写——而下面第 6 步,自己写一遍那个 collection 会清楚得多。选 Strict 而不是 Strictest:Strictest 会打开 noImplicitReturns 那一串,在一个只有四五个 TypeScript 文件的站里,我不想打这个仗。

cd personal-site
pnpm dev

把刚用的版本钉住,免得本机和 CI 以后跑偏:

node --version > .nvmrc

接下来先忍住,别去设计首页。我没忍住,代价也付了:下一步才是容易出事的地方,而仓库里只有三个文件时出事,比一周后带着一个你已经在乎的站出事便宜得多。

2. 先部署,再写东西 —— 坑 1/5

脚手架的部署坏了,五分钟能修好。同样的故障拖到下周,会和这中间写的所有东西缠在一起,你分不清是哪一半的锅。

pnpm add -D wrangler
pnpm exec wrangler login

在 package.json 旁边建 wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "personal-site",
  "compatibility_date": "2026-08-11",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "404-page"
  }
}

配置就这些:构建产物在哪,以及路径不存在时怎么办。

别粘贴 main。 网上几乎所有 Wrangler 片段都假设你有一个 Worker 脚本,开头就是 "main": "src/index.ts"。把这行抄过来,等于让 Cloudflare 去执行一个你根本没有的文件。静态站没有脚本,这行不要写。

not_found_handling: "404-page" 的作用是让不存在的路径返回你的 404.html,并且带上真正的 404 状态码。不写这行,你会得到一个空响应,浏览器里看着正常,对其他所有东西都是错的:爬虫、可用性监控、curl。它需要一个页面可发,所以用它之前先写出能用的最小那版:

---
// src/pages/404.astro
---
<html lang="zh-CN">
  <head>
    <title>404 · 页面不存在</title>
  </head>
  <body>
    <h1>404</h1>
    <p><a href="/">回首页</a></p>
  </body>
</html>

Astro 会把它构建成 dist/404.html,这正是 Wrangler 要找的文件名。好看的事以后再说。

发出去:

pnpm build
pnpm exec wrangler deploy

Wrangler 会打印一个 *.workers.dev 地址,你的脚手架就在互联网上了。顺手把这两步串起来:

"scripts": {
  "deploy": "astro build && wrangler deploy"
}

3. 绑域名 —— 坑 2/5

觉得 *.workers.dev 够用就跳过这节,下面的内容照样成立,以后回来补也不用改代码。

这是唯一一步要跟 Cloudflare 之外的公司打交道,也是唯一一步你可能把原本好好的东西弄坏。域名的 nameserver 必须转到 Cloudflare,而换 nameserver 换掉的是这个域名的全部 DNS,不只是网站那部分。

先在 Cloudflare 控制台把站点加进去。它会扫描你注册商那边现有的解析记录,把扫到的抄过来。动 nameserver 之前,先把这份扫描结果读一遍。 A 和 CNAME 它一般抄得对,漏掉会疼的那条是 MX。如果这个域名收邮件,新 nameserver 一生效邮件就不再到达,而且不会有任何退信来告诉你。MX,以及承载 SPF、DKIM 的那些 TXT,都对着旧的解析表手动核一遍,缺的补上。

然后去注册商那里,把 nameserver 改成 Cloudflare 给的那两个。生效之后 Cloudflare 会发邮件通知你——我这次是二十分钟,官方口径是最长 24 小时,缓存激进的注册商还能再拖。在它翻过来之前,旧的 nameserver 还在应答,所以这是交接不是断档:中间没有东西是挂的,只是还不归你管。

等区域激活了,路由写进同一个配置文件,别去控制台点:

{
  "workers_dev": false,
  "routes": [
    { "pattern": "latentyonder.com", "custom_domain": true },
    { "pattern": "www.latentyonder.com", "custom_domain": true }
  ]
}

custom_domain: true 会替你建好 DNS 记录。裸域和 www 都绑上:一个是正规写法,另一个是为了让打错的访客也能到。

写了 routes,你的 workers.dev 子域就没了。 你一直用来测试的那个地址会直接解析不到。轮到我头上的时候,最顺理成章的理解就是我刚把部署搞坏了。并没有。我把 workers_dev: false 显式写出来,是为了让文件自己说清这是有意的,省得以后的我再查一遍。

4. 告诉站点它自己的地址 —— 坑 3/5

站点已经上线,并且正在悄悄谎报自己住在哪。canonical 标签和 RSS 链接,Astro 都从同一个配置项生成,而那一项还写着 https://example.com。没有任何警告,每个页面渲染出来一模一样。

sitemap 则根本不在脚手架里,它是个集成:

pnpm astro add sitemap

这会装上 @astrojs/sitemap 并替你改好 astro.config.mjs。它在构建时产出 sitemap-index.xml,而 site 没设的时候它什么都不产出,也不吭声——这正是下面要修的东西。

要改的是两个地方,不是一个。

// astro.config.mjs
export default defineConfig({
  site: 'https://latentyonder.com',
});
# public/robots.txt
Sitemap: https://latentyonder.com/sitemap-index.xml

重新部署,然后从外面查,别信浏览器:

curl -sI https://latentyonder.com | head -1              # 期望 200
curl -sI https://latentyonder.com/nope | head -1         # 期望 404
curl -s  https://latentyonder.com/ | grep -i canonical   # 期望你的域名

第二行就是我当初跳过的那行。配错的静态托管会对每个不存在的路径返回 200 加空 body,在爬虫收录了一千个空白页之前,没有任何东西会告诉你。

5. 让 push 变成发布 —— 坑 4/5

两块:CI 负责告诉你构建是好的,Workers Builds 负责把它发出去。

CI 要用两样空脚手架里没有的东西——一个测试运行器,和 Astro 的类型检查器:

pnpm add -D vitest @astrojs/check typescript

然后是完整的 .github/workflows/ci.yml,之所以给全,是因为这个文件给一半比不给还糟:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm vitest run
      - run: pnpm astro check
      - run: pnpm build

有两处细节各让我红过一次。pnpm/action-setup 必须排在 setup-node 前面,因为 cache: pnpm 得先在机器上找得到一个 pnpm 才知道要缓存什么。还有 vitest run 在一个测试文件都没有时是非零退出的,所以第一天要么先把这行删掉,要么写成 pnpm vitest run --passWithNoTests——我倾向于删掉,因为这个 flag 很容易活得比它该活的那天长得多。

第 1 步写 .nvmrc 就是为了这里的 node-version-file:本机和 CI 锁在同一个版本上,「我这儿是好的」才有意义。

然后去 Cloudflare 控制台:打开这个 Worker,Settings → Build → Connect to Git,选仓库,填两个字段。

字段 值
Build command pnpm build
Deploy command pnpm exec wrangler deploy

写 pnpm exec,不写 pnpm wrangler。短的那个确实能跑——没有同名脚本时 pnpm 会回落到本地的可执行文件——但那是个回落行为,换成别的包管理器就不成立,而这个输入框不是适合耍机灵的地方。

保存,push 到 main 就是发布。开头那两个标签页,就是从这儿来的。

一次 push 分成两路:GitHub Actions 产出一个对勾,Workers Builds 执行部署。
我以为这是一条流水线,以为了很久,久到不太想承认。它是两条。

你的 CI 不是闸门。 GitHub Actions 和 Workers Builds 订阅的是同一次 push,各跑各的。Workers Builds 没有任何设置能让它等一个外部的状态检查。红叉和上线同时发生,因为这两边谁也没在看谁。

这句话的边界要说清楚。Workers Builds 自己会跑 pnpm build,所以编译不过的代码发不出去,构建步骤会死在那里,部署根本轮不到。漏过去的是 CI 查而构建不查的那些:vitest 挂了、astro check 抓得到但构建能忍的类型错误。这些会带着一片红的测试上线。

个人博客我觉得这样没问题,也就一直这么留着了,最坏的情况是一个错别字被公开。真想要那道闸门,就别用 Workers Builds 的自动部署——关掉它,把部署做成 CI 的最后一步,它自然被前面几步守住:

      - run: pnpm exec wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

两种选择都站得住。不知道自己用的是哪种,站不住。

还有一件事:即便自动化已经配好,第 2 步那样的首次手动部署也要做一遍。把自动化接到一条你从没见它成功过的流水线上,等于同时调试两个未知数。

最低路径到此为止。域名上有站,push 即发布,读到这儿停下完全可以。后面讲的是往上面放什么。

6. 内容就是 Markdown

Astro 的 content collections 在构建时校验 front-matter,日期写坏了会直接构建失败,而不是渲染出一个坏页面:

// src/content.config.ts
const blog = defineCollection({
  loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

一篇文章现在就是一个文件。draft: true 让它不进生产构建,但 astro dev 里照常能看见——我想从 CMS 那儿得到的全部功能,一个布尔值就够了。

注意这一步和前面五步不一样的地方:它出错的时候是喊出来的。 前面每一个都是闷声出错,所以每一个都吃掉了我一个下午,而这一个到今天为止一分钟都没花过我的时间。

可选:第二种语言 —— 坑 5/5

这一节里没有步骤,也跟 Cloudflare 无关。这是我这个站里某一块的设计笔记,之所以放在搭建后面,是因为它的出错方式跟前面是同一种,而且整套东西里只有这块有真正的设计决定。只写一种语言的话直接跳过。

代码是三个小模块——utils.ts 管 URL 和 id 解析,posts.ts 管列表构建和配对,freshness.ts 管过期告警——它们也是整个站唯一带单元测试的文件,原因在这节最后。

一条规则派生出其余全部:英文是唯一的原本,/zh/ 的 URL 永远不吐英文。 同名即成对,en/this-post.md 是真相来源,zh/this-post.md 要么跟上它,要么不存在。

有三种情况必须给出答案,而每一种都存在一个更严格、更好写、并且错误的答案。

还没翻译的文章。 藏起来是最干净的答案,代价是中文列表开始谎报我到底写过什么。所以卡片留着,标题保持英文,加一个 EN 标记,链接指向 /blog/...。我拒绝生成的是 /zh/blog/this-post/ 里面装着英文正文。一个 URL 承诺了一种语言却给出另一种,比一个诚实的标记糟糕得多。

中文列表上的两个条目:已翻译的指向 /zh/,未翻译的带 EN 标记并指向英文 URL。
这个标记同时干两件事:承认这篇没翻,以及预告这个链接会离开 /zh/。

未翻译文章上的语言切换。 指向那个「本该存在」的 URL,结果就是 404。正确做法是:对侧有这篇就跳过去,没有就跳该语言的博客列表。翻译缺失应该把人送到一个更小的页面,而不是一个死掉的页面。

已经过期的翻译。 每个中文文件都记着 sourceUpdated,也就是翻译当天英文版的日期。周二改了英文、忘了中文,周五的构建照样成功,只是会打印一条警告说这篇译文可能过期了。在这里让构建失败是看起来最有纪律的答案,而它实际教会你的事情是:别翻译了。

严格的那个答案就是有敌意的那个。 藏起来、404、构建失败,每一个都比我最后写的东西好实现,每一个都在拿我工作里的缺口去惩罚读者。三个分支,出错时全是闷声的,在渲染出来的页面上一个都看不见——所以这也是整个站里我唯一肯写单测的地方。

日常

  1. 写 src/content/blog/en/my-post.md
  2. git push

整个循环就这些。要是你把上面那节可选的也做了,它多一行:zh/ 下同名文件,sourceUpdated 填英文那篇的日期。

那两个标签页我现在还是一起开着。绿色的部署旁边挂着红叉,想明白之后就不再吓人了,接线我也一直没改——对这个站来说,我宁愿发出去一个错别字,也不想多养一道闸门。

一个周末的折腾里值得带走的就是这件事,而它跟 Cloudflare 没关系。这里五个坑,有四个吃掉我真实的时间,原因是同一个:一个又错又安静的系统,永远比一个又错又吵的系统更难对付。第 6 步是唯一一个冲我喊过的,也是唯一一个此后我再没想起过的。

挑那些会喊的工具。剩下那些安静的,拿 curl 去查。

返回博客