This site used to run on Ghost, self-hosted, on its own server. It’s the site you’re reading this on now, rebuilt on Astro and served as static output from Cloudflare Pages. The rebuild wasn’t about Ghost being bad software — it’s good software — it was about paying to keep a server up and patched for content that’s almost entirely static, on a site that needed to be portfolio-first, not blog-first.
Why leave Ghost
Two separate reasons, and it’s worth keeping them separate because they point at different fixes. The infrastructure reason: Ghost needs a running Node process, a database, and someone (me) keeping both patched, for a site whose actual content changes a few times a month. That’s a server I was operating for convenience I wasn’t using. The structural reason: Ghost is a blogging platform first, and this site needed to lead with case studies and a work page, with the blog as one section among several — fighting a CMS’s opinions about information architecture gets old fast.
Once both reasons pointed the same direction — static output, content collections instead of a CMS, no server to operate — the stack picked itself: Astro, Tailwind v4, Cloudflare Pages.
The phased build
I didn’t attempt a big-bang rewrite. Each phase shipped and got committed on its own, in order: scaffold the project, build the design system (typography, the light/dark tokens, the signature topology motif), build the content pages (case studies, services, about, uses, contact), then the blog itself (tabs for engineering/notes, Shiki syntax theming, the RSS feed, OG image generation), and only then the actual migration and launch.
Each box in that pipeline was a real commit, not a WIP branch — the site was in a demoable state at every stage, which matters more for a solo migration than it sounds like it should. There’s no code review forcing you to keep changes small when you’re the only one working on it, so the discipline has to be self-imposed.
Getting the content out of Ghost
Ghost exports as JSON, which is the easy part. The actual work was a conversion script (scripts/ghost-to-md.mjs) that walked that export and produced frontmatter-tagged Markdown matching the new content collection’s schema — mapping Ghost’s tags onto exactly two categories, engineering and notes, because the new site didn’t need Ghost’s more flexible tagging, it needed two clearly separated tabs.
Converted posts didn’t land directly in src/content/blog/. They landed in a migration-staging/ folder first, specifically so I’d have to make a deliberate decision about every post — some were years-old writing that no longer represented how I’d write about the topic today, and moving that straight into the new site on autopilot would have meant re-publishing content I no longer stood behind. The staging step turned “migrate everything” into “curate what’s actually worth keeping,” which was the point of doing a rebuild instead of a straight port.
Redirects: the part that’s easy to skip and shouldn’t be
Old Ghost URLs had inbound links from other sites and, more importantly, from search results that had been indexed for years. The redirect map does two things: it maps every old Ghost slug to the equivalent /blog/:slug/ path on the new site, and it keeps /rss.xml alive by redirecting from Ghost’s old /rss/ path rather than just letting existing RSS subscriptions silently break.
Skipping this is the single most common mistake I’ve seen in blog migrations generally — a technically successful rebuild that quietly zeroes out every inbound link and search ranking the old URLs had built up, because “we’ll deal with redirects later” turns into “we forgot redirects were a thing.”
What broke
Frontmatter validation caught more than I expected. Astro’s content collections validate every post against a Zod schema at build time — src/content.config.ts requires a category of either engineering or notes, a pubDate, tags as an array — and a handful of older Ghost posts had inconsistent or missing tags that the conversion script had to either fix or flag for me to fix by hand. That’s a good failure mode: a broken build beats a silently malformed post shipping to production, which is exactly the kind of validation a CMS with a looser schema wouldn’t have forced in the first place.
The Ghost decommission checklist
Once the new site was live and redirects were confirmed working (checked by hand against the actual old sitemap, not assumed), decommissioning Ghost meant: export a final backup of the database and uploads before touching anything, confirm DNS was fully pointed at Cloudflare Pages and had been for long enough that no traffic was still hitting the old server, then take the Ghost server down. Keeping the backup even after decommissioning wasn’t optional — a migration you can’t roll back from isn’t a migration, it’s a bet.
Was it worth it
For this site, yes — the content volume is low enough that the CMS’s real value (an editing UI for non-technical contributors) wasn’t worth the server it required, and Markdown-in-git as the content model fits how I already work. That calculus flips for a team publishing daily with non-engineers writing posts; a CMS’s editing experience is a real feature, not just a convenience, once the person writing isn’t comfortable with a pull request. The right architecture question isn’t “is a CMS or a static site better,” it’s “who’s actually going to be publishing, and what do they need to be able to do without me.”