如何用 Cloudflare Workers 搭个人站点
从空目录到自有域名上、push 即发布的静态站,以及这套搭建里五个「看起来对的答案其实是错的」的地方——它们一个都不报错。
发布于 · 更新于
- #cloudflare
- #astro
构建挂了,站点上线了。同一次 push,同一分钟,两个标签页并排开着: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,要学就学这个。
先看有人打开你的站时,实际发生了什么:
构建产物会提前铺到 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 就是发布。开头那两个标签页,就是从这儿来的。
你的 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/。未翻译文章上的语言切换。 指向那个「本该存在」的 URL,结果就是 404。正确做法是:对侧有这篇就跳过去,没有就跳该语言的博客列表。翻译缺失应该把人送到一个更小的页面,而不是一个死掉的页面。
已经过期的翻译。 每个中文文件都记着 sourceUpdated,也就是翻译当天英文版的日期。周二改了英文、忘了中文,周五的构建照样成功,只是会打印一条警告说这篇译文可能过期了。在这里让构建失败是看起来最有纪律的答案,而它实际教会你的事情是:别翻译了。
严格的那个答案就是有敌意的那个。 藏起来、404、构建失败,每一个都比我最后写的东西好实现,每一个都在拿我工作里的缺口去惩罚读者。三个分支,出错时全是闷声的,在渲染出来的页面上一个都看不见——所以这也是整个站里我唯一肯写单测的地方。
日常
- 写
src/content/blog/en/my-post.md git push
整个循环就这些。要是你把上面那节可选的也做了,它多一行:zh/ 下同名文件,sourceUpdated 填英文那篇的日期。
那两个标签页我现在还是一起开着。绿色的部署旁边挂着红叉,想明白之后就不再吓人了,接线我也一直没改——对这个站来说,我宁愿发出去一个错别字,也不想多养一道闸门。
一个周末的折腾里值得带走的就是这件事,而它跟 Cloudflare 没关系。这里五个坑,有四个吃掉我真实的时间,原因是同一个:一个又错又安静的系统,永远比一个又错又吵的系统更难对付。第 6 步是唯一一个冲我喊过的,也是唯一一个此后我再没想起过的。
挑那些会喊的工具。剩下那些安静的,拿 curl 去查。