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.

The English homepage: name, tagline, and the personal-site project card.
A name, one line, and the project this post is about.

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.

Chinese latest-posts list. An untranslated title keeps an EN badge beside it.
I left an English-only post on the Chinese list long enough to take this picture. The 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:

  1. site in astro.config.mjs
  2. the Sitemap: line in public/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.

Flow from a Markdown file through git push, GitHub CI, and Workers Builds to the live workers.dev URL.
After the manual deploy works, push is enough.

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:

  1. Open the personal-site Worker
  2. Settings → Build → Connect to Git, and pick this repo
  3. Build command: pnpm build
  4. 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

  1. Add src/content/blog/en/<slug>.md
  2. Same day in Chinese? Same filename under zh/, and set sourceUpdated to the English updatedDate or pubDate
  3. 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.

Back to blog