Ghostbusted
A year ago the Ghost team asked me to take down Ghostbuster. Last week I finally moved this blog off Ghost and onto Statamic, keeping the posts, the feeds and pico, which still thinks it’s talking to Ghost.
I had a problem with Ghost once.
It's their code, and I always respected that. Whatever I changed stayed inside the theme. Even so, my install was a real Frankenstein, a pile of hacks (clever ones, if I'm allowed to say so) that got me things Ghost still doesn't do on its own.
And it worked. What bugged me was everything around it. There are a lot of themes, integrations and tools made for Ghost, and finding them is a pain. So I built a small platform called Ghostbuster. It crawled GitHub for Ghost resources, sorted them and let you compare one against another.
To be fair, Ghost is excellent at what it sets out to do, and it's very well built. I've never said otherwise.
About a year ago, a few months after Ghostbuster went live, I got an email from the Ghost team asking me to shut it down. Copyright, and so on.
All I wanted was to help people find more stuff for Ghost. I never meant to push anyone into hacking its code; I liked the way the thing was made too much for that. So I took the platform offline, archived the project and, later, deleted the repository.
That email never left my head, though.
I put it off. I thought about it, went back and forth, and let it sit for twelve months. Then, in the last two weeks, I finally moved this blog to Statamic. This is how it went.
What I had
Ghost 6, self-hosted on my own VPS, MySQL behind it, a custom theme on top. That part was simple. Everything hanging off it was not:
• pico-server, a little service of mine that takes every post and mirrors it to prose.sh, Gemini, Gopher, Mastodon, Bluesky, Nostr and Linkding. It reads posts through Ghost's Admin API and reacts to Ghost's webhooks.
• GoatCounter and Tinylytics for stats.
• A dozen feeds: RSS and Atom (summary and full text), JSON Feed, JF2, twtxt, sfeed, separate feeds for notes, links and bookmarks, a blogroll OPML and the sitemap. All of them announce a WebSub hub.
• ActivityPub, Zapier, a Transistor integration, members, a colophon page and a drawer full of custom templates.
What stayed and what went
I set one rule before touching anything. The posts had to survive, and so did four integrations: pico, GoatCounter, Tinylytics and the feeds. The theme didn't matter. Everything else could die.
So ActivityPub, Zapier, Transistor, members, the colophon and the custom templates didn't make the trip. I don't miss them yet.
Why Statamic
Flat files. No database. A post is a Markdown file with some YAML on top, sitting in a folder, and the whole site lives in a git repository. Underneath it's Laravel, which means that whatever Statamic doesn't do, I can write myself. And for a single-person site the core is free.
The plan
I didn't want to wing it. First I read through the old install until I knew every piece of it, then I wrote a plan, and only then did I start building.
Two decisions shaped the rest. No staging subdomain: the switch would happen straight on pablomurad.com. And everything would be built and tested on my own machine first, then shipped to the server with a deploy script. The server never gets edited by hand.
Locally it all runs in a PHP 8.4 Docker container. The first surprise came early: Composer kept segfaulting while writing vendor/ to my network drive. I moved vendor/ into a Docker volume and it never complained again.
Moving the posts
I wrote a one-shot import command that read Ghost's database directly and turned each post into a Statamic entry: title, slug, dates, tags, excerpt, cover image, and the body converted from HTML to Markdown.
A few things I was careful about:
• Ghost's post IDs were kept, so feed GUIDs stayed the same and feed readers wouldn't show every post as new.
• Ghost's internal tags (the ones starting with #, like #note) became regular tags with a hash- prefix, so hash-note, hash-link and so on.
• Images were copied keeping the exact same paths under /content/images/, so old links still work.
• Ghost serves resized images at URLs like /content/images/size/w600/... and old posts are full of them. A small route answers those with the original file instead of a 404.
The HTML to Markdown step had one sneaky bug. A paragraph that started with a non-breaking space followed by a # came out as a heading. Markdown sees the line start with #, and there you go. The fix was to escape those characters when they show up at the start of a paragraph.
Before switching anything, I compared every imported post against Ghost: content, publish dates, update dates. They matched. After that the import command was deleted, along with the database connection it used. It did its job once and had no reason to stay.
Old URLs
Ghost has its own URL habits and the internet remembers them. So the old paths redirect to the new ones with a 301, trailing slash included:
/rss/ -> /feed/
/atom.xml -> /atom/
/page/2/ -> /archive/
/author/... -> /
/sobre/ -> /about/
/iniciativas/ -> /initiatives/
In Laravel each one is a single line:
Route::get('rss', $to('/feed/'));
My first version dropped the trailing slash on the way out, which then caused a second redirect. Building the full absolute URL fixed that.
The feeds
Every feed was rebuilt on the same URL, with the same GUIDs and the same dates. If your reader is subscribed, you shouldn't have noticed anything.
Two small fights here. Blade got confused by the <?xml prolog at the top of the feed templates, because it looks like PHP to it, so the string had to be split in two. And the XSL stylesheets that make the feeds readable in a browser were being served as binary downloads until the web server learned their content type.
Pretending to be Ghost
This is my favorite part.
pico-server was written for Ghost. I could have rewritten it for Statamic. Instead, I taught Statamic to speak Ghost.
The site now answers three Ghost Admin API endpoints:
GET /ghost/api/admin/posts/
GET /ghost/api/admin/posts/slug/{slug}/
POST /ghost/api/admin/posts/?source=html
They accept the same kind of key Ghost uses (an id and a secret, signed as a short-lived JWT) and return posts shaped exactly like Ghost's.
The other direction is the webhook. When I publish, edit, unpublish or delete a post, Statamic sends pico the same JSON body Ghost would have sent, with the same signature header:
X-Ghost-Signature: sha256=
There's one thing Ghost knows that Statamic doesn't care about: whether a save is a first publish or an edit. A small ledger file remembers what was last sent for each post, and that's enough to tell the two apart.
I tested all of this against pico's own client code before going live. Then I wrote a real test post, watched it show up on every network, deleted it, and watched it disappear. pico didn't change a single line.
Stats
GoatCounter and Tinylytics are two script tags in the layout. The easiest part of the whole thing.
The switch
On October 2nd I pointed the site at Statamic and checked everything again, on the real domain this time.
Then I backed Ghost up (database, content folder, old web server config) and removed it: the install, its service, the Ghost CLI, the database and its user. One leftover stayed on purpose, a system account that other things on the server turned out to depend on. Deleting it would have broken stuff that has nothing to do with this blog.
After the move
Once it was stable, the small things came in:
• The site interface switched to English.
• My face is back as the logo and favicon.
• Error pages now use cats from http.cat. A 404 is a cat. A 500 is a cat.
• The footer links to the site's source, Keyoxide and Keybase.
• Statamic got its first update.
• Open Graph, Twitter Card and JSON-LD tags. Ghost generated these for free and I only noticed they were gone when link previews started coming out bare.
• Responsive WebP cover images.
• Notes and links as their own post types. They live in the same collection as posts, show up on the home page, have their own feeds, and pico mirrors them too.
• Static caching. Pages are stored as finished HTML, and saving a post clears exactly the pages it touches: the post, the home page, the archive, the tags.
The source is public at git.pablomurad.com/pablo/pablomurad.com, if you want to poke at it.
The license
For the table of contents on long posts, I grabbed an addon from the Statamic marketplace. It worked fine, apart from crashing on posts with nested headings, which I worked around.
Then a licensing warning started showing up in the control panel every time I opened it. The addon was commercial. I removed it and wrote my own table of contents in a few dozen lines of PHP.
That fixed the warning. It didn't fix the feeling. The first time a marketplace addon asked me for a license, I caught myself thinking about migrating again.
I'm not satisfied... yet.