Migrating from Jekyll to Ghost Blog

I decided to migrate my simple static Jekyll site to Ghost Blog: a powerful open-source blog and newsletter platform.

Migrating from Jekyll to Ghost Blog

Since 2022, I’ve been hosting my own blog for fun (and zero profit) from a Raspberry Pi 3 in my Homelab. When I first set it up,  I was just getting into writing technical content but, now that I write professionally, I found my blog wanting. I needed somewhere I could both publish write-ups of projects I’ve written in my own time but also keep an archive of my past work; including articles, talks, and demos so that they can follow me between jobs. I decided to migrate my simple static Jekyll site to Ghost Blog: a powerful open-source blog and newsletter platform.

This post is no substitute for the official docs for the Ghost Blog project which you can find here but is intended to share my experiences making the switch and share some of the things I used in case they might help other people with this migration.

Why Migrate?

My previous blog infrastructure was pretty simple: a static site rendered from Markdown in a GitHub repo using Jekyll with a GitHub action to copy new blogs merged to main over onto my Pi. I also needed some more actions for automatically reposting new blogs to my social pages (which turned out to be surprisingly difficult). This was fine back when I was just getting started and wanted somewhere to keep some of the blogs I’d written for my company at the time from Medium on my own site but wasn’t cutting it for me anymore. Writing blogs directly in Markdown is clunky and converting back and forth from Markdown always seems to require some manual correction.

I found myself craving the simplicity of a WYSIWYG (What You See Is What You Get) editor that I could easily copy and paste into from docs (if you aren’t drafting your blog as a doc you’re probably missing out on reviews from friends and peers by making it needlessly difficult to leave comments on your work). I also wanted the easy of using pre-built integrations for platforms like Mastodon, Bluesky, Medium, LinkedIn, and easy integration with email tools for newsletters to allow me to employ POSSE (a publishing strategy of posting first to your own site and then syndicating your posts across other platforms) without having to manually maintain integrations.

Deploy Ghost

I deployed Ghost to my homelab (a cluster of Raspberry Pi 4s) with Docker Compose using the example config from the Ghost documentation. I had to shuffle the newly created Ghost db onto a separate Pi to the one hosting my main nginx server as the read and writes to the db were more than the SD card could handle. This could easily be avoided by picking a USB drive with decent read and write speeds and mounting the docker compose volume there instead but I’m cheap and lazy.

This creates a Postgres service Ghost uses to store posts and other data from your site. I also configured the newsletter feature in Ghost to use my personal email by default but later signed up for Mailgun to support emailing more users. 

Migrate Content

Convert posts

Migrating content from Jekyll to Ghost is a bit of a pain, a few people have put together tools and made them available for others to use but I couldn’t find anything that worked for my posts consistently so I wrote my own script to convert posts, you can find it here. You can pass the script the path to your Jekyll posts and it will return a JSON file of Ghost posts you can easily import.

create_ghost_zip.py ~/blog/_posts ghost-import.json

The script handled all 175 of my posts in one go, automatically creating the tag relationships and preserving all my metadata. I found Ghost's official migration tools helpful for reference, but needed this custom script since my Jekyll setup had some frontmatter fields that required special handling.

Copy images to Ghost

My script automatically updated my image paths to the correct format for Ghost, so migrating images over was as simple as copying the images directory from my Jekyll repo over into the correct location in my Ghost volume with the following:

cp -r ./assets/images/* /path/to/ghost-data/images/

Setting Up Ghost ActivityPub

As I mentioned previously, better BlueSky and Mastodon integration was half the reason I was migrating in the first place so it was unfortunate I had so much trouble with this step. ActivityPub is the decentralized protocol that Mastodon is built on top of so to automatically repost my blogs there I needed to get the Ghost ActivityPub feature working. My struggles here were entirely my own fault for not reading the docs closely enough (as you can see in my support ticket) but the Ghost team and community were extremely helpful and helped me fix my config issues—for which I’m extremely grateful.

The Ghost ActivityPub feature proxies certain requests to Ghost's hosted service at ap.ghost.org. I made a rookie error and passed the wrong value in for the host header which broke my Fediverse integrations entirely leaving the Network tab in Ghost blank. If you run into a similar issue my fix (as provided by Cathy Sarisky) was to correct this and restart both nginx and Ghost which resolved my issue.

# ActivityPub - proxy to Ghost's hosted service
location ~ ^/.ghost/activitypub/ {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $http_host; 
    proxy_ssl_server_name on;
    proxy_pass https://ap.ghost.org;
}
location ~ ^/.well-known/(webfinger|nodeinfo) {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $http_host;
    proxy_ssl_server_name on;
    proxy_pass https://ap.ghost.org;
}

Restart nginx and Ghost:

docker restart nginx
docker restart ghost

Setting Up Bluesky Bridge via Bridgy Fed

Getting posts to bridge to Bluesky via Bridgy Fed, also initially stumped me. BridgyFed is a service that automatically mirrors your posts over from ActivityPub to ATProto the decentralised protocol that BlueSky is built on to save you the trouble of manually reposting things.After following @[email protected] from my Ghost blog's ActivityPub account, the bridged account was created but posts weren't flowing through.

After some troubleshooting and filing a GitHub issue, I discovered the fix: unfollow @[email protected], wait a few minutes, then follow it again. This, in combination with Ryan Barre updating DNS on the Bridgy side of things, resolved what turned out to be a duplicate DID issue where posts were being sent to the wrong Bluesky account.

Final Thoughts

Migrating from Jekyll to Ghost was more painful than I had hoped. Setting up the ActivityPub integration was particularly cumbersome, though this could have been avoided with paying better attention to the docs and ultimately the great community around Ghost helped tremendously.

Some of the integrations I was hoping for, like Ghost to Medium, just aren’t available natively yet but the brilliant editor in Ghost makes it extremely easy to copy paste an entire blog into Medium where with Jekyll I’d be wrangling with Markdown for a while every time to get things looking right. I’ve yet to make much headway with the newsletter feature and would like to have more options for email senders (mailgun seems to mostly end up in spam despite my best efforts) but ultimately this feature was easy enough to set up and seems future proof enough for me to grow into it.

I kept my previous Jekyll site configuration safe in case I wanted to migrate back but for now I’m very happy with my Ghost blog and have found it makes it much easier to stage, publish, and share content. If you’re still on Jekyll and have the patience (or the money to make the move, Ghost is widely available as a managed service) I’d thoroughly recommend it.