Back in 2024 I watched a NetworkChuck video about why you should start a blog. The idea was building a second brain — a place to document what you learn so you can find it again later and share it with others. That was enough to finally get me to do it.
My first setup was an $8/month VPS. It worked, but it also meant SSH-ing in for security updates, babysitting Nginx configs, and keeping an eye on the uptime of a single box in a single region. I never had a real outage, but the risk was always there in the back of my head — and most of that server sat idle while I paid for it every month.
So I rebuilt the whole thing. The new version is Hugo with a custom theme, deployed as a Cloudflare Worker. No servers to manage, global distribution, and a proper API layer for the features I wanted to add. Let me walk you through how it fits together.
Why Hugo
For a blog with no dynamic content, Hugo is hard to beat. It compiles everything to static HTML at build time, handles multiple languages natively, and the output runs anywhere. Build times stay under a minute even with dozens of posts.
I did consider a JS-based framework, but a personal blog has no reason to ship a hydration runtime to every reader. Hugo keeps the output clean and that was the whole point.
Why Workers and not Pages
Cloudflare Pages would have handled the static hosting just fine. The reason I reached for Workers instead is that I wanted an API layer — specifically two things:
- Reactions — readers can react to posts with emoji, and the counts live in Cloudflare KV.
- Newsletter — email sign-ups land in a Cloudflare D1 (SQLite) database.
With Workers the same deployment serves both the static site and these endpoints. No separate backend, no extra services to wire up. The whole router is just this:
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/api/reactions') return handleReactions(request, env);
if (url.pathname === '/api/subscribe') return handleSubscribe(request, env);
return env.ASSETS.fetch(request);
},
};
Everything else falls through to the static assets. Hugo builds them, the Worker serves them.
The build pipeline
Deploying is one line:
hugo --minify && npx pagefind --site public && npx wrangler deploy
Three steps behind it:
- Hugo compiles the content and the custom theme into
./public - Pagefind indexes that output for search — no external search service needed
- Wrangler uploads the assets and pushes the Worker to Cloudflare
Pagefind ended up being my favorite part. It runs at build time, ships a small index with the static output, and does all the searching in the browser. No extra services bolted on that slow down the build.
Reactions with KV
KV is Cloudflare’s key-value store, and it shows up as a binding inside the Worker. Each emoji reaction is just a key per post slug:
{slug}:{index} → count
A GET /api/reactions?slug=my-post reads all four counters in parallel, and a POST /api/reactions?slug=my-post&i=2 bumps one of them. The whole handler is about 25 lines.
KV is eventually consistent, which sounds scary until you remember we’re counting emoji reactions. Nobody needs to watch the number tick up in real time.
Newsletter with D1
D1 is Cloudflare’s managed SQLite. The subscribers table is nothing fancy:
CREATE TABLE subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
source TEXT,
country TEXT,
referrer TEXT,
language TEXT
);
One small trick: the Worker reads cf-ipcountry from the request headers, so I get the subscriber’s country without ever asking for it. Migrations run through a shell script that keeps track of which files it has already applied in a _migrations table.
Two languages from the start
I write everything in English and Spanish, so Hugo’s multi-language setup was a must. It uses separate content directories per language:
content/
english/posts/
spanish/posts/
Each post gets its own slug per language, so the URLs stay clean on both sides — no /en/ prefix cluttering the English version. The theme just switches the interface language based on which section you’re in.
What actually changed
Before, it was one server in one region, manual maintenance, and no API. After the rebuild it’s 275+ edge locations, zero servers for me to touch, reactions and a newsletter built in, full-text search, a dark/light theme, and a CLI terminal-style homepage.
And the $8/month is gone — Cloudflare’s free tier covers this entire setup. Not a bad trade.



