< the skull

GHOSTBUSTED
===========
2026-10-10

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=, t=

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.


< the skull