Goodbye Ghost: How I Migrated Two Blogs to a Platform I Built in About Two Days
By Mensur Duraković

I've been on Ghost for years and, honestly, it's a lovely product. The editor is clean, the themes are nice, and it just works. But there was one thing I couldn't get past. I don't run one blog, I run two. This one, and my fiancée's over at crueltyfreebiologist.com, where she writes about cruelty-free beauty and the science behind it.
Two blogs mean two of everything. And paying per-site SaaS pricing, every month, forever, for two hobby projects started to feel like renting a flat I was never going to own. I'd look at the invoices and think: I write software for a living. Surely I can host my own words.
So I did. I spent a couple of days building a small, self-hosted, multi-tenant blog platform that runs both sites from a single app on a single server, migrated everything off Ghost, pointed the domains at my own boxes, and cancelled the subscription. Both blogs are now served by code I wrote, on infrastructure I control, for a fraction of what I used to pay.
This is the story of how that went. The goals, the stack, the plan, the migration, the things that bit me, and the part I'm quietly proud of. It's a long one. Grab a coffee. ☕
What I actually wanted 🎯
Before writing a line of code, I wrote down what "done" looked like. Not features, but constraints. Those are more honest.
One app, one server, two domains. I didn't want to run two of everything. One codebase, one database, one deployment, serving both blogs.
A real web admin. My fiancée is not going to learn git to publish a post, and she shouldn't have to. She needed the same experience she had on Ghost: log in, write, hit publish. If the migration made her life worse, it failed, full stop.
Own the whole stack. Database, editor, theming, deploys, email, all mine, all understandable, nothing I couldn't fix at 11 pm.
Break nothing for readers. Every existing URL, every image, the RSS feed, all of it had to survive the move.
That last one turned out to be the hardest and the most important. More on it later.
The stack (and a few opinions I'll defend) 🛠️
Here's what I reached for, and why.
Next.js 15 with the App Router, React 19, and TypeScript in strict mode is the backbone. It gives me server-rendered pages for the public blogs (great for SEO and speed) and a comfortable place to build the admin. Strict TypeScript everywhere, no any.
Tailwind CSS v4 for styling, with a few shadcn/ui components in the admin so I wasn't hand-rolling buttons and dialogs.
PostgreSQL 16 as the database, with Drizzle as the ORM. Drizzle is SQL-first and plays beautifully with strict TypeScript. The schema is the source of truth, migrations are generated from it and checked into the repo, and the types flow all the way out to the queries.
Tiptap is the rich-text editor. It's built on ProseMirror and is about as close as you can get to Ghost's Koenig editor in feel. This mattered enormously, because it's the surface my fiancée actually touches every time she writes.
DigitalOcean hosts everything. A single Droplet runs the app, and Spaces (their S3-compatible object storage, with a built-in CDN) holds every uploaded image. Images do not live on the server; they live on the CDN, close to readers.
Caddy sits in front as the reverse proxy. The reason I love Caddy for a project like this is automatic HTTPS. It provisions and renews Let's Encrypt certificates for all domains automatically and handles www-to-apex redirects, one less thing to babysit.
Everything is wrapped in Docker Compose, so the app, the database, and Caddy come up together with one command.
GitHub Actions handles deploys: merge to the main branch, and it ships to the server.
Email, the newsletter, runs on Resend.
Passwords are hashed with bcryptjs (the pure-JS one, specifically, so I never have to fight native build dependencies inside a Docker image).
Two decisions I made deliberately, and would make again:
I dropped NextAuth in favour of a tiny custom session. There's no public sign-up here, just two accounts, full stop. A full auth framework was more machinery than the problem deserved. Instead, the login is a small signed cookie using the Web Crypto API. Fewer dependencies, less surface area, and I understand every line of it.
Every post is stored twice: as structured editor JSON and as rendered HTML. The JSON is the editing source of truth. The HTML is a pre-rendered cache the public pages serve directly. It costs a little storage and saves re-rendering the editor document on every single page view. Worth it.
The one idea the whole thing rests on: multi-tenancy by hostname 🔀
Here's the trick that lets one app serve two completely separate blogs.
Every incoming request carries the hostname it was meant for. The app looks at that hostname, figures out which blog ("tenant") it belongs to, and from then on every database query is scoped to that blog's ID.
My posts are tagged as mine, her posts are tagged as hers, and a request to one domain can only ever see one blog's content. The separation is the foundation on which everything else is built.
That one rule, resolve the tenant from the hostname and scope everything by it, is the single most important line of defence in the system. Get it wrong, and one blog could leak into the other. Get it right, and two blogs live happily in one database, blissfully unaware of each other.
On top of that sits a small theme system. My blog uses a clean, typography-first theme. Hers keeps the look and feel she used before, the round post images, the soft neutral panels, that gentle aesthetic.
How I kept a side project from eating itself 📐
The graveyard of personal projects is full of ambitious rewrites that sprawled until the author lost interest. I really didn't want this to be one of them, so I forced some discipline on myself.
I wrote a single planning document and treated it as the source of truth: the locked decisions, the architecture, the order of work. Then I built in phases, each with a clear definition of "done":
Phase 0, bootstrap: the skeleton. Project, database schema, Docker, and the deploy story.
Phase 1, the public site: rendering posts, home pages, tags, themes, RSS, sitemaps, SEO.
Phase 2, the admin panel: login, the post list, the Tiptap editor, image uploads, publishing, scheduling.
Phase 3, the migration: getting everything out of Ghost and into the new database, intact.
Phase 4, deploy and cutover: going live on the server and flipping the DNS for real.
Phase 5, the newsletter: the last big piece, added once the dust had settled.
Every change went through a feature branch and a pull request, even though I was working solo, and every PR had to pass the automated checks, type-checking, and linting before it could be merged.
The migration: the genuinely scary part 😬
Building a blog engine is fun. Moving years of someone's writing onto it without losing anything is where the real work hides.
Ghost lets you export your content as a single file. I wrote an importer that reads that export and, for every post, does a careful little dance.
It converts Ghost's HTML into the editor's format, finds every image in the post, downloads it, and re-uploads it to my own CDN, rewrites the image links to point at the new location, and recreates the tags.
Crucially, it preserves the original slugs, publish dates, and excerpts, so every old URL still resolves, the post dates don't reset to "today," and search engines see continuity instead of a brand-new site.
I also made the new RSS feed match the shape of Ghost's old one, and added a redirect from Ghost's old feed path to the new one, so anyone subscribed in a feed reader never even noticed the move.
All told, it brought across roughly 113 posts and around 340 images.
When I finally loaded each site on the new platform, and everything was just there, the right posts, the right images, the right dates, that was the moment it felt real.
Things that bite me (so they don't bite you) 🐛
No honest migration story is all green checkmarks. A few gremlins earned their place in this post.
The disappearing images. After I flipped the DNS, a chunk of images suddenly 404'd, even though I'd re-hosted them.
The culprit: the old image URLs pointed back at Ghost's domain, which, now that DNS had moved, redirected into my new app, which had no idea what they were.
The fix was to pull the images from Ghost's durable storage origin instead of the public site URL.
Lesson: When you cut over DNS, anything still pointing at the old host quietly turns into a trap.
The config that "reloaded" but didn't. At one point, I updated the proxy config, told it to reload, watched it report success, and it kept serving the old config.
Turns out the file was mounted into the container in a way that pinned it to the original version on disk, so the reload was reading a ghost of the old file.
The cure was a full restart, not a reload. I lost a good half hour to that one.
Stale tools running old code. A couple of times, I ran a maintenance task and got results that made no sense, because the containerised tool was a cached build running yesterday's code.
Now I rebuild before I run. Cheap habit, expensive lesson.
None of these were catastrophic. But they're the kind of thing nobody puts in the tutorial, and they're exactly where the hours actually go.
Owning it means I'm the security team now 🔒
There's a hidden line item in "self-hosted": the moment you fire the SaaS, you also fire the people who were quietly worrying about security on your behalf.
I took it seriously, partly because two people's writing lives are here. The reassuring truth is that most security is just sensible defaults, applied consistently, and a lot of it was baked in from the start.
The database isn't exposed to the internet at all; only the app, sitting on the same machine, can reach it. The app runs as an unprivileged user rather than as root.
HTTPS is everywhere, automatically. Passwords are properly hashed. Login attempts are rate-limited, so brute-forcing the door is a no-go. And there's no public sign-up, which means the entire "who can even log in" surface is two accounts and nothing else.
Then, once both sites were live, I did a deliberate hardening pass: the security equivalent of walking around the house, checking that the windows are actually locked.
A few of the things I tightened:
The tenant boundary. Since one app serves two blogs based on the hostname, I made sure a visitor can't trick it into mixing them up. The signal that decides which blog you're seeing is set by my own infrastructure and can't be forged by the browser. That boundary, as I said earlier, is the single most important one in the whole system.
The login cookie. I tightened the session cookie so another site can't quietly use it to act on your behalf, which shuts the door on a whole family of cross-site request forgery tricks.
A Content Security Policy. This is a browser-enforced allowlist of what a page is even allowed to load and run: fonts from here, images from my CDN, and nothing from anywhere else. If a malicious script ever did sneak into a post, the browser would simply refuse to run it. I rolled it out in a "report-only" mode first, where it watches and warns without blocking anything, until I was satisfied it wasn't going to trip over a legitimate page, and only then switched it on for real.
None of this makes the platform unhackable, but that was never the goal.
The last mile: a newsletter I actually own 📬
A blog without a way to email new posts to subscribers is only half a blog. So the final phase was a proper newsletter system.
Readers subscribe with a double opt-in (they confirm via email, so the list stays clean), there's one-click unsubscribe in every email, and from the admin, I can hit a "Send as newsletter" button on any published post.
Behind the scenes, sends go into a queue that's drained steadily in the background, with automatic retries, so a big send goes out smoothly instead of hammering anything.
My favourite part is a small, slightly cheeky cost optimisation. Sending from two different domains on one email provider usually means paying for a higher plan. Instead, each blog gets its own free account, each verifying its own domain.
Two blogs, two free tiers, zero added cost, and the app simply picks the right credentials based on which blog is sending. And of course, I imported the existing Ghost subscribers so nobody had to re-subscribe.
What it took 💸
The rough timeline, for the curious:
May 23: pulled the Ghost exports and built the skeleton.
June 3: my blog went live on the new platform.
June 4: my fiancée's blog went live too, the domains were fully cut over, and I cancelled the Ghost subscription.
June 5: a round of hardening and cleanup.
June 8: the newsletter was shipped, and the existing subscribers came across.
So yes, "about two weeks" on the calendar, but the actual hands-on time was closer to two full working days, give or take circa sixteen hours, almost all of it in evenings after the day job, with one longer push for the cutover.
The honest bit: I had an assistant 🤖
I should come clean about how a fortnight of evenings produced this much, because "two working days" raises an eyebrow for good reason. I didn't write it all by hand. I built it with an AI coding assistant working alongside me the whole way: Claude Code, Anthropic's command-line agent, running their Claude Opus 4.8 model.
It was a genuine force multiplier. It scaffolded the boilerplate I'd otherwise have typed a hundred times, helped me write the Ghost importer, talked through trade-offs when I was on the fence, helped chase down a few of the gremlins above, and ran the security review behind the hardening pass you just read about. The hours that are saved are a big part of why "two working days" is even plausible.
But here's the line I was careful not to cross. The entire point of this project was to own and understand my stack, and that promise would be hollow if I couldn't explain my own code. So I read every change, made the architecture calls myself, and pushed back whenever the AI suggested something I didn't actually want.
It accelerated the typing and the research, but the judgment stayed mine.
The part I'm quietly proud of 🏡
There's a specific kind of satisfaction in owning the thing your words live on. No feature I'm waiting for someone else to ship.
No pricing page deciding what my hobby is worth. No migration I can perform because the export button doesn't exist. If I want a new theme, a new field, a new little feature at midnight, I just go and build it.
Since this is still an engineering blog, let me close with the numbers yearly, because that's where it really lands.
Two Ghost Publisher plans came to about $58 a month, which is $696 a year for the pair. The self-hosted setup costs roughly $29 a month, about $348 a year, to run both blogs together.
That's a little over $348 saved every single year; it roughly halves the bill, and it hands me more control rather than less. Next year it saves the same again. The ownership, I get for free.
But the part that actually means the most is that I built it for two of us. My fiancée keeps writing exactly the way she always did (log in, write, publish) and never has to think about the machinery underneath. I get to tinker with the machinery to my heart's content.
This very post is running on that platform. If you'd like the next one in your inbox, the subscribe box is right there.
And if cruelty-free beauty backed by actual science is more your thing, go say hi to my fiancée over at crueltyfreebiologist.com, same engine, her words.
Thanks for reading. It's good to be back after a pause of almost 10 months.