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
mainruns 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:
| Where | What it uses | What it skips, and why |
|---|---|---|
| Code highlighting | pulldown-cmark (server) + highlight.js (browser) | syntect — too heavy for the Wasm bundle size limit |
| Search | D1 FTS5 | An external search service — a paid dependency for no benefit at this traffic |
| Templates | Askama (compile-time checked) | minijinja — trades live-editing for catching errors before deploy |
| Media | Private R2 behind /media/* | A public bucket or second domain — one access-control point instead of two |
| List/tag/search/RSS pages | Direct D1 reads per request | A 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.