Back in 2020, in the thick of COVID quarantine, I started this blog. I wrote about it in my very first post, Hello World: two weeks stuck at home, rediscovering how much I love messing with technology, and finally deciding to write some of it down.
I needed somewhere to put the words. I picked Ghost.
It was, honestly, the path of least resistance. Ghost was the easy answer in 2020: spin up a VPS, install it, convert a theme into something I actually liked in an afternoon, and start typing.
And for years, it delivered. It got out of my way. I wrote, it published, the thing just worked.
But over the past few months, something shifted. A string of nasty exploits started landing for Ghost. Serious ones, the kind that let a stranger read your entire database or run their own code on your server.
And at some point, mid-update for the umpteenth time, I just stopped and asked myself the obvious question:
Why is my blog running on a CMS in this day and age?
I mean, really. It’s a stack of articles that never change after I hit publish. There’s no reason for it to be a living, internet-facing server that I rent, patch, and pray over. I could just… vibe-code a static site.
A whole class of these problems evaporates the second there’s no server to attack and no database to read. And going static would come with a pile of other benefits I’d been wanting anyway:
- No server to babysit. A blog is text. It should be a folder of files a CDN hands out: nothing to log into, nothing to exploit, nothing to patch at 11pm.
- Hosting I’m already using. I’ve got a bunch of projects running on Cloudflare already, and the experience has been fantastic: fast, painless, and costing me exactly nothing. Meanwhile I was paying for a VPS just to keep Ghost alive. It wasn’t a lot of money, but it had started to feel like a bill I had no business still paying.
- Total control. My markup, my styles, my routes, my rules. If I want the blog to do something weird, it should do something weird.
- Markdown for the robots. I wanted agents (crawlers, LLMs, whatever) to be able to ask for a post and get clean Markdown back instead of a soup of HTML. Ghost flat-out can’t do this, and it bugged me more than it probably should have.
- A modern stack. I wanted to build on the tools I actually enjoy using now (Astro, Tailwind, the modern web) instead of living inside Ghost’s theme world. (And sure, open-sourcing the result on GitHub was a nice bonus, but that was a side perk, not the point.)
I’d been circling this idea for a while without actually doing anything about it. What finally tipped me over wasn’t the blog at all. It was a side-quest.
Last week I built myself a new site to host and share my public-speaking stuff.
I had a fun design session with Claude, and once that was live, I thought - why not do the same for my homepage? So I went ahead and redesigned it as well.
And at that point, I was on a roll - and I thought: “well, now’s as good a time as any to finally do that blog migration I keep postponing.”
So I did.
Choosing the weapon
First, the boring-but-important question: what do you build a blog out of?
Three real contenders:
- Hugo. The old reliable. Fast, battle-tested, boring in the good way.
- A vibe-coded, self-made generator. Roll my own from scratch. Maximum control, maximum yak-shaving.
- Astro. Component-based, content collections built in, ships zero JS by default.
I went back and forth longer than I’d like to admit. What tipped it was this write-up on migrating from Ghost to Astro with Claude Code. It walked almost exactly the path I was about to take. Astro it was.
So now it was time to actually build the thing, and, frankly, it wasn’t much to write home about. I fired up Claude, wrote a careful spec, and set it running.
One choice that mattered: I asked it to operate as an orchestrator and lean heavily on sub-agents. And the single most important instruction I gave, the thing that kept the output high-quality, was that it should not port any of Ghost’s code.
It had to rebuild the behavior natively, as if the site had always been Astro. That turned out to be the challenging part: Ghost CSS styles and other cruft kept sneaking back in, and it took a long session with Claude before we’d scrubbed all of it out.
There were also some decisions around JavaScript. I built the site to have fully static output - by default, Astro ships no JavaScript to the client. But I did want a little interactivity: for example, the “load more” button on the main page.
Luckily, Astro has a feature called islands: tiny bits of JavaScript, shipped only where needed, which let the site eat the cake and have it too.
Beyond that it was a long tail of small details: fixing some styles, swapping out a few broken fonts, making sure none of my assets were too heavy, and (while I was at it) renaming them from Screenshot 2021… and WhatsApp Web into something readable.
Finally, I built a set of Astro MDX components to hold all the various elements my posts rely on: figures, videos, embedded tweets, and the like.
The result builds to a folder of static files that Cloudflare serves on every push. No server, no database, nothing to patch.
Plot twist: I was already haunted
Partway through the migration, while Claude was going through my old Ghost export to port the posts over, it flagged something: every single one of my 41 posts had the same obfuscated <script> tucked into its footer, not in the post bodies but in Ghost’s per-post “code injection” field.
It was base64-encoded. When I decoded it, it pointed at a domain I’d never seen, pulling a remote script onto my blog for anyone who visited. It was also built to be quiet (it fired only once per visitor), which is part of why I’d never caught it myself.
My blog, the one I was in the middle of leaving because Ghost sites were under attack, had already been compromised.
What are the odds, right?
But once the irony wore off, it stopped being funny. This wasn’t a defaced homepage or a cosmetic prank. Code I hadn’t written was running in my readers’ browsers.
So I sat down and did some research with Claude, and what I found made it worse, not better: this wasn’t personal. My blog hadn’t been singled out. It had been swept up in a broad, automated campaign that hit hundreds of Ghost sites, Oxford and Harvard among them. The injected script was a loader for a ClickFix attack: the kind that shows a visitor a fake “verify you’re human” box and tricks them into pasting a command that quietly installs malware on their own machine.
For some stretch of time, a real person could have landed on one of my posts and walked away infected, because of my blog. I genuinely, deeply hope nobody did. If you ever visited the site and saw a prompt telling you to press Win+R and paste something to “verify” yourself: don’t, and run a scan.
Even though I was only hours away from replacing the whole site with the clean static version, I knew that waiting it out was not an option. So I stopped everything, went into the live Ghost install, and stripped the malware out of all 41 posts by hand, right then.
If anything, finding this on the way out didn’t shake my decision to migrate. It validated it. The whole problem traces back to one thing: a server. Ghost is software, running on a machine, exposed to the internet, and software running on a server is something that can be attacked and compromised.
A static site has none of that. There’s no application to exploit and no database to read: just plain web pages sitting in storage, handed out exactly as they are. No server means no attack surface, and with no attack surface there’s nothing there to compromise in the first place.
A blog the robots can read
Remember the “Markdown for the robots” bullet from way back at the top? This is where it pays off, and it’s the part I’m most pleased with.
Quick context. There’s a growing push to make websites legible to AI agents: not just human browsers, but the crawlers and LLMs that increasingly read the web on our behalf.
Cloudflare actually wrote up a good framing of this they call “agent readiness”: the idea that a modern site should present itself cleanly to machines, not only people. There’s even a checker, isitagentready.com, that audits your site against a pile of emerging standards.
I ran my freshly-migrated blog through it and, naturally, set out to turn every red X green.
A few were easy, static wins:
- llms.txt is a plain-text index of the whole blog (every post, newest first, with excerpts), generated from the same content collection that powers the RSS feed, so it can never drift out of sync.
- Content signals in
robots.txtexplicitly tell crawlers what they may do: yes to search, yes to being cited in AI answers, no to training models on my writing. - Discovery headers: an RFC 8288
Linkheader and a.well-knownAPI catalog pointing agents at the endpoints that actually exist (/posts.json, the sitemap, the RSS feed) and nothing else.
The content signals are my favorite of the bunch, three lines that just say what I want out loud:
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=no
Allow: /
Search engines, welcome. Cite me in AI answers, sure. Train a model on my writing? No thanks. It’s advisory, not enforcement. But it’s nice to be able to state the preference at all.
That “nothing else” matters to me. A lot of the checklist is about advertising APIs, OAuth flows, and tool endpoints, none of which a static blog has. The honest move isn’t to publish that metadata anyway; pointing an agent at endpoints that don’t exist is worse than staying quiet. So I shipped what’s real and skipped what isn’t.
But the one I actually cared about was serving Markdown. The idea: when an agent asks for a post with an Accept: text/markdown header, hand it clean Markdown instead of rendered HTML. Browsers still get the pretty page; machines get the source. This is the exact thing Ghost couldn’t do, and the reason it’d been nagging at me for months.
Funny enough, Cloudflare does have a native “Markdown for Agents” feature that does precisely this: flip a switch, done. Except it’s gated behind their paid plans, and my little blog is very much on the free one. So the built-in path was out. Fine. I’d build it myself.
So how are we doing it? The first part is a generator that emits a .md version of every post straight from the source at build time (resolving image and video URLs to their real paths along the way). The second part is a tiny Cloudflare Worker (all of 2 KB) that sits in front of the static files, checks the Accept header, and serves the Markdown twin to anything that asks for it. Everyone else gets HTML, untouched. The whole interesting bit is about a dozen lines:
// Only HTML page routes reach this Worker. If the client prefers Markdown,
// serve the build-time .md sibling; everyone else gets HTML, untouched.
if (mdPath && prefersMarkdown(request.headers.get('Accept'))) {
const md = await env.ASSETS.fetch(new URL(mdPath, url));
if (md.ok) {
return new Response(await md.text(), {
headers: { 'Content-Type': 'text/markdown; charset=utf-8', Vary: 'Accept' },
});
}
// No Markdown variant for this page. Fall through to HTML.
}
return env.ASSETS.fetch(request);
The whole thing weighs nothing, costs nothing, and does something my old CMS fundamentally couldn’t. If you’re an agent (or just curious), you can watch it work:
curl -H "Accept: text/markdown" https://posts.oztamir.com/shell-in-the-ghost-migrating-to-astro/
You’ll get this very post back as clean Markdown. Open the same URL in a browser and you’ll get the page you’re reading now. One URL, two representations, picked by who’s asking.
That’s the kind of thing “total control” was supposed to buy me. Turns out it did.
So, was it worth it?
A weekend of work to replace something that already worked fine. No new features my readers will notice. Same posts, same URLs, same font (okay, for the first time, actually the right font).
But that was never the point.
What I came away with is a blog with nothing to patch and no server for anyone to break into, that costs me nothing to run, and that I have complete control over.
For years, the common wisdom has been that hand-rolling your own version of popular services is almost never the efficient call. There’s always something off-the-shelf that’ll get you most of the way for a fraction of the effort.
But with coding agents, that math changes. Building exactly what you want is suddenly cheap enough to be worth it. And what you get for the effort is the one thing no off-the-shelf product can hand you: a blog that does exactly what you want, because you’re the one who made it do that. That’s the part that makes it yours.
My blog used to be something I rented. Now it’s something I own, and I can get back to the only part I actually cared about in the first place: writing.
FIN