Code9 Developer Blog
Table of contents

Why I Built My Own Blog Engine in Rust

I didn't start with Rust. Over time I'd tried building a version of this blog in Laravel with PHP, in WordPress, in Go, in Django with Python — and none of them stuck. Laravel and Django both want a real, always-on server behind them, which meant paying for hosting just to keep a personal blog online. WordPress came with its own database, its own PHP runtime, and a plugin ecosystem to maintain — a third-party CMS in every sense I was trying to avoid. Go came closest, but Cloudflare Workers' tooling around it never felt as first-class as what Rust gets through the worker crate. Every one of them, one way or another, needed something I didn't want to pay for or maintain.

What did land was a Rust application on a single Cloudflare Worker, talking to D1 for content and R2 for media. One constraint decided that, and it decided almost everything that came after it: this had to live entirely inside Cloudflare's free tier — no paid hosting, no third-party CMS, no monthly bill for a personal blog.

Key Takeaways

  • One requirement — free-tier-only, no paid hosting, no third-party CMS/SaaS — eliminated hosted platforms before any code was written and forced a real backend instead of a static site generator.
  • Rust's payoff wasn't philosophical: it kept the Worker's Wasm bundle small enough to skip a heavy syntax-highlighting crate, and Cloudflare's 10ms-per-request CPU budget shaped pagination, caching, and even which page needed zero database reads.
  • I set the requirements, made the architecture calls, and reviewed and verified every phase — but I'm not a Rust developer by background, and a structured, AI-assisted workflow did most of the actual Rust typing.
  • Every push to main runs through a GitHub Actions pipeline — automated tests gate an automated deploy — but the riskiest steps, database migrations and the local integration smoke test, stay manual on purpose.

One Constraint, Zero Off-the-Shelf Options#

The requirement was blunt, and it ruled things out fast. A personal developer blog, single author, no comments, no newsletter, running entirely on Workers, D1, and R2 — nothing paid, nothing third-party. Run that constraint against the usual options and watch them fall away one by one. A hosted CMS means paying for it, or handing my content and uptime to someone else's platform — either way, gone. A static site generator would have handled the read-only pages fine, but this also needed an authenticated admin surface: create, edit, publish, delete, upload images, without redeploying code for every typo. That's not a build step, that's a backend. So the option left standing was the one already half-built: the Cloudflare Workers scaffold sitting in the repo, extended into a full Rust CMS rather than replaced with a third tool.

That's also where the honest part of this story starts. I set the requirements, made the architecture calls in review, and verified every phase before it shipped — but I'm not a Rust developer by background (more on that on the About page). A structured, AI-assisted workflow did most of the actual Rust typing. What I brought was the constraint, the review, and the decision to build rather than adopt; what follows is what that combination produced, decision by decision.

Where Every Byte and Every Millisecond Went#

Cloudflare Workers gives each request roughly 10ms of CPU time on the free tier, and that number turns out to be the real author of half this project's design.

Take syntax highlighting. A proper Rust crate for it, like syntect, is powerful and heavy — and every byte in a Workers Wasm bundle counts against the free-tier script size limit. So the design pushes highlighting out of Rust entirely: pulldown-cmark, small and pure Rust, turns Markdown into HTML on the server, and highlight.js, loaded from a CDN, does the coloring in the browser. Rust does the part that has to be fast and safe under a tight CPU budget; the browser absorbs the part that would have bloated the binary.

The same 10ms budget shows up again in how pages get built. The admin dashboard fetches page_size + 1 rows instead of running a COUNT(*) to decide whether a "next page" link exists — one avoided query, repeated on every single list view, forever. The /about page is a static Askama template for a blunter reason: it can serve a response with zero D1 reads at all, which is the fastest thing this app can possibly do under that budget.

Search got the same treatment for a different reason. Instead of reaching for an external search service, D1 runs its own FTS5 virtual table — because Algolia or similar would have added a paid dependency for a blog running well under 10,000 requests a month, for no benefit at that scale. Uploaded media lives in a private R2 bucket for the same logic: it's streamed back to readers through a Worker-owned /media/* route rather than a public bucket or a second custom domain, so access control lives in exactly one place instead of two configurations that could drift apart.

There's no KV caching layer yet, either. Computing list, tag, search, RSS, and sitemap responses straight from D1 on every request is simpler than building a cache-invalidation story, and the assumed traffic volume stays comfortably inside D1's free-tier read limits. If that assumption stops holding, KV caching is the documented next lever, not a rewrite.

Even the templates carry a version of this trade-off. Askama type-checks templates against Rust structs at compile time, so a broken template binding fails cargo build instead of a production request. The cost is that editing a template means a rebuild-and-redeploy round trip — automated by CI now, but still slower than a hosted CMS's live-edit-and-save loop. Catching the error before a reader ever sees it was worth that.

Laid out together, the pattern behind each call is the same one:

WhereWhat it usesWhat it skips, and why
Code highlightingpulldown-cmark (server) + highlight.js (browser)syntect — too heavy for the Wasm bundle size limit
SearchD1 FTS5An external search service — a paid dependency for no benefit at this traffic
TemplatesAskama (compile-time checked)minijinja — trades live-editing for catching errors before deploy
MediaPrivate R2 behind /media/*A public bucket or second domain — one access-control point instead of two
List/tag/search/RSS pagesDirect D1 reads per requestA KV caching layer — not worth the invalidation complexity yet

Cloudflare Access Guards the Admin Panel#

The entire /admin/* surface — create, edit, publish, delete, upload — sits behind Cloudflare Access, Cloudflare's edge authentication layer. It checks who's logging in before a request ever reaches the Worker, so there's no custom login form, password table, or session store to build and secure — Cloudflare's identity layer does that job, restricted to my own identity.

The Worker doesn't stop at trusting the edge, though. It also validates the Cf-Access-Jwt-Assertion JWT itself, against Cloudflare's own Access public keys, on every /admin/* request, and rejects with a 403 if it's missing or invalid. That extra check matters because every Worker is also reachable through its default workers.dev subdomain, a fallback address that an Access application scoped to the custom domain won't automatically cover. Validating the token inside the Worker itself means the admin panel stays protected even if that second address is never separately locked down.

Continuous Integration and Deployment, With Deliberate Limits#

Every push and pull request against main runs through a GitHub Actions workflow (.github/workflows/deploy.yml). A test job checks cargo fmt --check, cargo test, and a cargo check --target wasm32-unknown-unknown build, so a change that breaks formatting, fails a test, or won't compile to WebAssembly never gets near production. A deploy job runs only after test passes, and only on a push to main — it uses cloudflare/wrangler-action with Cloudflare credentials stored as repo secrets to ship straight to Cloudflare Workers.

Two steps stay outside that pipeline on purpose. A wrangler dev --local smoke pass for anything touching routes, D1, R2, or Access still has to be run by hand before merging, because Miniflare's local state isn't meaningfully reusable in a one-shot CI runner. And D1 schema migrations are never applied by the workflow: wrangler d1 migrations apply DB --remote is a manual step, run before merging code that depends on a new migration, so a schema change can never land silently on a routine push. Automating the repetitive checks was worth it; automating the two steps that could silently corrupt a live database wasn't.

Underneath all of it, a test suite carries most of the actual correctness guarantee. As of the most recently shipped feature — per-post cover images — the project sits at 105 passing tests, with 100% line coverage on that feature's new code, built test-first, red before green. What plain cargo test can't reach — anything touching the real worker::D1Database or Bucket bindings, or a route gated by a live Cloudflare Access JWT — gets verified through wrangler dev --local integration passes, and for the Access-gated admin flow specifically, a real authenticated browser session against the live blog.code9.guru/admin after deploying. There's no formal issue tracker either: this is a single-author project, so anything found mid-build gets added straight to that feature's testing checklist instead of a separate backlog somewhere else.

What I'd Still Change#

None of this gets presented as a finished, regret-free design — the project's own notes call out the trade-offs directly instead of smoothing them over. Askama's compile-time safety means a copy fix needs a developer and a redeploy, not just a saved textarea. The admin form's Markdown editor is a plain textarea, a real UX gap next to a rich editor, accepted because building one wasn't worth it for an audience of one author. Two posts published in the same second wouldn't sort predictably against each other in the prev/next navigation — a known simplification, not an oversight, at this authoring cadence. And KV caching, as mentioned, waits for traffic that would actually justify it.

Frequently Asked Questions#

Isn't building a blog engine from scratch reinventing the wheel?

For most people, yes — an existing CMS is the right call. This project's starting constraint ruled that out before the question of "wheel" versus "reinvented wheel" ever came up: free-tier-only, no paid hosting, no third-party CMS, which most existing platforms simply don't fit without either a subscription or the self-hosting infrastructure this project was built to avoid.

What does it actually cost to run?

Nothing, at the traffic this blog assumes — under 10,000 requests a month, comfortably inside the free-tier limits for Workers, D1, R2, and Access. There's no KV usage yet either, so there's no additional allowance to track.

Why Rust instead of, say, Node or Python on Workers?

The repo already had a Cloudflare Workers scaffold built on the worker crate and axum before this project started, so it was the fixed starting point, not a language evaluated from a blank slate. What Rust delivered in practice was a small enough Wasm bundle to skip a heavy server-side syntax-highlighting dependency, and compile-time template checking that catches a broken binding before it ever reaches a production request.

Looking Ahead#

KV caching is the one thing still explicitly on hold, written down as when needed, not never — the day traffic outgrows D1's free-tier read limits is the day it gets built, not before. That's the actual argument for building this engine instead of adopting one — every constraint in this post traces back to a real requirement, not a default inherited from someone else's platform. If you want to see how a specific piece works — the H2 heading-anchor pipeline, the defense-in-depth JWT check, the FTS5 search — the code is the documentation. Browse every topic this blog covers on the tags page, or subscribe via RSS to catch what ships next.