跳至正文
Jisua Tech Tools & Knowledge Hub
Share 约 21 分钟阅读

Moving a Blog to Cloudflare Workers: A Typecho-Workers Deployment Log

Spent a whole night on this, and the blog is finally off a traditional server and running on Cloudflare Workers.

Here's the short version: if you want a personal blog that costs almost nothing to run and never needs server maintenance, this setup is genuinely one of the best options right now. But the deployment has one counterintuitive wrinkle — the config file isn't there waiting for you, you have to create it yourself. More on that below.

What it actually is

Typecho-Workers takes the database design of the classic blog system Typecho and rewrites the whole thing in TypeScript on top of Astro + Cloudflare Workers.

The stack is refreshingly restrained:

Layer Technology
Framework Astro (SSR)
Runtime Cloudflare Workers
Database Cloudflare D1 (SQLite)
ORM Drizzle ORM
File storage Cloudflare R2
Package manager Bun

It keeps Typecho's core table structure, which means migrating data from the PHP version can go through the official tooling instead of hand-exporting tables. Old posts, notes, categories, tags, comments — all of it comes across. That was the part I cared about most; after years of writing, I'm not willing to lose content just because I changed platforms.

The runtime lives on Cloudflare's global edge nodes, and when a public page hits the edge cache the Worker doesn't execute at all, so it doesn't consume CPU billing. My own numbers: roughly 20ms Worker startup, 17.44 seconds for the first full deploy, and 2.48 seconds for incremental updates after that.

Before you start: a few things to get straight

These are the points where my experience diverged from a lot of the tutorials out there, and they're exactly where people get stuck.

There is no wrangler.toml in the repo root.

I saw a tutorial say "find wrangler.toml and update the config," then searched the project root and found only wrangler.toml.example. If you go looking to edit a file that doesn't exist, you'll burn a lot of time right there. The first step has to be:

cp wrangler.toml.example wrangler.toml

wrangler is already in the project dependencies.

It's listed in package.json under devDependencies, so you don't need npm install -g wrangler. Call it with bunx wrangler throughout and avoid a global version fighting with the project version.

PBKDF2_ITERATIONS is commented out by default.

In the template file that line looks like this:

[vars]
# PBKDF2_ITERATIONS = "50000"

Workers Free gives you 10ms of CPU per request, which isn't enough to finish the default 600,000 PBKDF2 rounds. If you don't uncomment it, the install wizard will time out and you'll never get an admin account created. It's an easy one to miss and a hard one to diagnose.

You have to install with bun install.

The project uses workspaces (src/plugins/*, src/themes/*) and the lockfile is bun.lock. Installing with npm breaks the workspace symlinks, which shows up as 404s on the admin plugin page and plugins that simply don't take effect.

The full deployment walkthrough

1. Clone and install

git clone https://github.com/Tokinx/typecho-workers.git
cd typecho-workers
bun install

2. Log in and create the edge resources

bunx wrangler login

# D1 database
bunx wrangler d1 create typecho-db

# R2 bucket (images and media attachments)
bunx wrangler r2 bucket create typecho-uploads

# KV namespace (optional, needed by the Edge Cache plugin)
bunx wrangler kv namespace create TYPECHO_CACHE

3. Create and fill in the config file

This is the step people skip most often:

cp wrangler.toml.example wrangler.toml

Then paste in the D1 database_id that the previous step printed, and uncomment the PBKDF2_ITERATIONS line:

[vars]
# Workers Free's 10ms CPU can't finish 600k PBKDF2 rounds; drop it to 50k
PBKDF2_ITERATIONS = "50000"

[[d1_databases]]
binding = "DB"
database_name = "typecho-db"
database_id = "your-D1-database-ID"

wrangler.toml is in .gitignore and won't be committed. That's by design, so you don't need to worry about leaking it.

4. Inject the password pepper

openssl rand -hex 32 | tee ~/pepper-backup.txt | bunx wrangler secret put PASSWORD_PEPPER

⚠️ The pepper cannot be rotated transparently. If you delete, lose, or replace this secret, every password encrypted with the old pepper stops working, and the only way back is the "forgot password" page or a CLI reset tool. So the tee to a local file in that command, followed by storing it in a password manager, is not optional.

I skipped it the first time and the value was gone for good. Fortunately I hadn't created the admin account yet, so there was no real damage — but the lesson stuck.

Optionally, set an install token as well to keep the initialization page from being claimed by someone else:

bunx wrangler secret put INSTALL_TOKEN

5. Deploy

bun run deploy

That command actually does three things: builds the Astro project, generates a deployment config with all the resource bindings at dist/server/wrangler.json, and publishes the Worker. So it depends on wrangler.toml existing — another reason step 3 isn't skippable.

When it finishes, the terminal prints your domain:

Uploaded typecho-workers (17.44 sec)
Deployed typecho-workers triggers (2.48 sec)
https://typecho-workers.<your-subdomain>.workers.dev

6. Run the install wizard

Open the domain in a browser and it redirects to /install, where you fill in the site details and the admin account. Once that's done, /admin gets you into the dashboard.

Don't put this off. The domain is public, and until installation is complete anyone can reach /install and register themselves as the administrator. Setting INSTALL_TOKEN makes this considerably safer.

7. Turn on plugins

Go to Plugins in the dashboard. These are the ones I'd recommend enabling:

  • Edge Cache — the performance backbone; it cuts database lookups site-wide significantly
  • Antispam — dynamic honeypot protection against comment spam
  • Turnstile — Cloudflare's human verification (you'll need to request a sitekey in the dashboard first)

8. Bind a custom domain

The workers.dev domain isn't especially reliable from mainland China, so binding your own domain is worth it.

Cloudflare dashboard → Workers & Pages → find your Worker → Settings → Domains & Routes → Add → Custom domain. Once DNS resolves, run bun run deploy once more.

The pitfall I hit: the terminal that wouldn't exit

During deployment I ran into something odd: the command had already printed ✨ Success!, but the cursor just sat there without returning to the prompt, as if it had hung.

It turned out not to be a bug but wrangler's behavior — some subcommands don't hand the tty back properly when they finish. Press Enter once, or hit Ctrl + C, and it turns out the job was done long ago.

I ran into this with both secret put and deploy. If you see "Success" or similar and then nothing new, don't panic — just press Enter.

Email notification templates: three limitations

While setting up comment email notifications I ran into a few things worth writing down.

Limitation one: only 13 placeholders, and no date or time.

The complete list:

Site: {site.name} {site.url} {site.description}
Post: {post.title} {post.url}
New comment: {reply.author} {reply.content} {reply.mail} {reply.avatarUrl}
Original comment: {comment.author} {comment.content} {comment.mail} {comment.avatarUrl}

There's no {date} and no {time}, so there's no way to display a timestamp. If you need one in the email, you'd have to add the field yourself in baseVars() in index.ts and redeploy.

Limitation two: system notifications and comment notifications don't use the same placeholders.

System notifications (password resets and so on) additionally support {subject}, {body}, {text}, {reason}, and {to} — but only on the WebHook channel. When system notifications go out by email, they pass the core email content through as-is rather than going through the template you filled in on the settings page. So your template has no effect on password reset emails, and it's not worth fighting with.

Limitation three: comment notifications and reply notifications share a single template box.

Looking at notifier/index.ts, both the admin notification and the visitor reply notification end up going through the same line, renderEmail(config.mailSubject, config.mailBody, vars). In other words, you can't configure two different HTML templates for these two kinds of notification.

If you want to switch copy based on the scenario, the only lever is a CSS empty selector:

/* The anchor is always hidden; it exists only to give a sibling
   selector something to react to */
.tc-a { display: none; }
/* {comment.author} empty ⟺ no comment being replied to ⟺ new comment */
.tc-a:empty + .tc-c { display: block; }
.tc-a:not(:empty) + .tc-r { display: block; }
/* Fallback goes last */
.tc-c, .tc-r { display: none; }

The idea: put an invisible <span class="tc-a">{comment.author}</span> in the template as a state anchor, follow it with two blocks of copy ("new comment" and "reply"), and let CSS decide which one shows based on whether the anchor is empty.

There's a detail here that'll bite you: an !important on the fallback rule will override the active rule. While debugging I briefly thought :not(:empty) wasn't working, when it was really a CSS specificity problem. Removing !important and moving the fallback to the end fixed it.

One more thing: the email body is automatically HTML-escaped, and the plain-text version is generated by stripping tags from the plugin, so there's no XSS concern. The flip side is that you can't put <script> in a template — it won't execute even if you do.

A short summary

The biggest change after the migration is that the maintenance burden drops to zero. No more agonizing over the server bill, no Nginx, PHP-FPM, and MySQL processes to keep alive, no ports to worry about being scanned. Between CDN edge caching and lightweight storage, day-to-day traffic uses a rounding error's worth of the free quota.

If you want to build one too, remember the three things that matter most:

  1. cp wrangler.toml.example wrangler.toml — don't skip it
  2. Uncomment PBKDF2_ITERATIONS — otherwise the install wizard times out
  3. Back up the pepper — if you lose it, it's gone for good

The project is at Tokinx/typecho-workers. If you run into trouble, the repo's Issues are the place to ask.


This article is a record of my own deployment. The actual hands-on work took about two hours; most of the rest of that night went into chasing down the problems the documentation doesn't spell out.