Essay
Building a personal site on Cloudflare Workers
Why I skipped Pages, how a /zh/ URL never serves English, and the two deploy mistakes that looked like success.
Published
- #cloudflare
- #astro
This is not a from-zero Astro tutorial. The site already exists. What I want to keep is the reasoning: why the files live on Workers, the language rule I would not break, and two mistakes that looked like a successful deploy.
If you want to copy the wiring, start at the repo: tyler-y-liu/personal-site.
I wanted a homepage and a blog on the same domain. English is the original. Chinese is a translation. I write Markdown in git and publish by pushing. The host had to be something I would not outgrow in a year.
Why Workers, not Pages
Cloudflare still runs Pages. For a new project it points you at Workers with Static Assets. Visitors still get HTML and CSS on a *.workers.dev subdomain, and those requests are still free.
The difference is the rest of the platform. Configuration sits in wrangler.jsonc next to the code. When I need a Cron job, KV, R2, or an actual Worker script, I add it to this project. I do not move hosts.
A custom domain can wait. Binding one later does not change the site.
The one language rule
English is the only original. I edit that file first. The Chinese file is a translation of it, not a second original. Same filename means a pair:
src/content/blog/
en/this-post.md ← source of truth
zh/this-post.md ← same name, or it does not exist
A
/zh/URL never serves English.
If a post has no translation, the Chinese list still shows it. The card keeps the English title, marks it EN, and the link leaves /zh/. I do not generate a Chinese detail route for an English body.
EN badge is the point. Click it and you are on /blog/....The language switcher does the same thing. If the other language has this post, go there. If it does not, go to that language’s blog index. A missing translation is not a 404.
Translations fall behind. That is normal. Each Chinese file can declare sourceUpdated: the English date on the day I translated it. If I change the English file on Tuesday and forget the Chinese one, Friday’s build still succeeds. It prints a warning that the translation may be stale. A late translation does not get to block a publish.
There is no Worker
wrangler.jsonc points at the Astro output. There is no main. There is no script. These are files:
{
"name": "personal-site",
"compatibility_date": "2026-08-11",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
}
}
not_found_handling: "404-page" is what makes an unknown path return the custom 404 page with a real 404 status, instead of an empty response. The page itself puts English and Chinese side by side. On a missing URL you cannot reliably know which language the visitor wanted.
Astro 7 wants Node 22.12 or newer, and it does not support odd majors such as 23. I was on 22.9. Upgrade that before you touch the homepage.
Then the first deploy is four commands:
pnpm add -D wrangler
pnpm exec wrangler login
pnpm build
pnpm exec wrangler deploy
I wrapped the last two as pnpm deploy. Wrangler prints a *.workers.dev URL. The site opens. That is when the mistakes start.
Two holes that looked like success
The first deploy lies about your URL
The site is live. Canonical tags, RSS, and the sitemap still say https://example.com until you paste the real origin into two places:
siteinastro.config.mjs- the
Sitemap:line inpublic/robots.txt
Deploy again. I check / with curl and expect 200. I check a path that does not exist and expect 404, with my page in the body.
Do not paste a Worker main
Most Wrangler snippets on the internet assume you have a script. This project does not. Adding main tells Cloudflare to run code you do not have.
The config above is the whole thing: a directory, and a 404 policy.
Push, then it deploys
I did the first deploy by hand on purpose. If something is wrong, I want to know whether the build is broken or the automation is broken.
CI on GitHub is three commands: pnpm vitest run, pnpm astro check, pnpm build. Node comes from .nvmrc, so local and CI stay on the same version.
Then, in the Cloudflare dashboard:
- Open the
personal-siteWorker - Settings → Build → Connect to Git, and pick this repo
- Build command:
pnpm build - Deploy command:
pnpm wrangler deploy
I do not have a screenshot of that screen. The four fields are the whole setup. After they are saved, a push to main is a publish.
Writing a post now
- Add
src/content/blog/en/<slug>.md - Same day in Chinese? Same filename under
zh/, and setsourceUpdatedto the EnglishupdatedDateorpubDate git push
A post marked draft: true stays off the production build. I do not need a CMS to keep a draft off the public site.
That is the pipeline I wanted: a Markdown file, a static build, and a Worker that only serves files.