Migrating from WordPress to Ghost
Documenting the process and decisions behind moving from self-hosted WordPress to cloud-hosted Ghost. Also comparing Ghost and Eleventy.
I've been struggling with my blogging solution using WordPress on a self-hosted EC2 instance in AWS. I get crawled constantly by AI bots, commented on by spammers, and forced to run through Ubuntu dist-upgrades to keep my database and PHP versions up to date.

I've been debating the static site generator approach with something like Eleventy, but that was more work than I wanted. I love to tinker, but I think I'd rather be writing than maintaining, mostly. When Veronica Explains posted about Ghost, that grabbed my attention. It seemed like it had everything I needed to host a site, manage my newsletter subscribers, and some bonus fediverse integration. An integration that I may or may not use.
I spun up a local instance on my laptop to check it out, and it seemed pretty slick. I had over 300 WordPress posts imported locally with no hassle at all. It even dealt with all my featured images that gave me so much trouble when trying Eleventy.
I started a free trial at ghost.org and with a little bit of exporting, I had my nearly complete WordPress site transferred over in just a few steps.
Eleventy Decision
Eleventy looked like a nice option and the base blog imported all of my WordPress content OK. It wasn't very nice looking, or, was just pretty basic. I would have had to add niceties myself.
Eleventy Pros
I've been thinking of moving to a static site generator like [[Migrating from WordPress to Eleventy]], and there is a draft post languishing that I've never finished because it's been too technically complex for my time available. Eleventy SHOULD have the benefits of:
- Speed
- Customization possibilities are endless
- Hosting on some free tier static site repo
- Publish pipeline could be automated through git from Obsidian somehow
- Security - since I'm not managing a running instance anymore
Eleventy Cons
However, with great power comes great responsibility.
- Customization is complex
- Have to build CSS and program thumbnails for posts myself
- Cover images were NOT displayed at the top of each post, so I'd have to use the template language to reliably locate where the thumbnail for each post resides and insert that into the post and the collection browser. I almost had this working after a few hours, but it didn't LOOK nice. Also, since I coded it myself, I wasn't sure how reliable it would be. First time ever using JavaScript. YOLO I guess?
- I could post my failed code snippets for doing this, but I'm sort of embarrassed that I couldn't make it work in less than a day.
- No comments without something like Disqus
- No subscription or email
- Have to maintain a customized publishing pipeline and troubleshoot it if it breaks
- This is less of a con than you would think - BUT - sometimes you just want to post something and not troubleshoot a pipeline job in the cloud.
- The private posts have to be handled manually somehow.
Ghost Decision
Ghost looked like it had everything I wanted and just worked immediately. I had both a local instance running beautifully on my laptop, and the free trial within just an hour or two.
Ghost Pros
Ghost on the other hands has pros of:
- Speed and auto-scale that they manage
- Publishing is simple
- Customization is possible but has simple defaults that are easy to use
- No need to crack open CSS or templates if I don't want to
- My cover images immediately worked and looked great on all the posts that have them. No custom templating needed.
- No maintenance
- Security - since they're hosting it and have it behind some WAF
- Comments AND ActivityPub
- Email newsletter!!!
- Private posts are converted to drafts.
Ghost Cons
And the Ghost cons are:
- I would rely on Ghost hosting for $15 per month that could go up
- But, I COULD self-host since it's open source
- Except I'm really done self-hosting Internet facing services
- Have to relinquish my ec2 instance so I can't use it for OTHER things,
- But honestly this is fine, could always spin one up on demand
- I don’t know why this is a con. This is why I wanted to run this project in the first place, removing that long-running instance. I guess I just have a sentimental attachment to this VM I’ve been shepherding through the years.
- Have to find another hosting source for my newsletter mp3s (S3?)
- This is probably better anyway - and was really fast to setup
- Have to figure out dated slugs
- This was easy right in the Ghost docs with a redirect.yaml
- Redirects let me do some other cool link shortening too.
- No image hosting for my gallery, which I'm not doing anyway and could have at another sub-domain that's hosted at my home behind a login.
- Sharing my whole camera roll with the Internet is so 2005.
- No easy comment migration
- Sad about this one, but I have all the comments in an XML file and database backup. I could integrate them somehow if I really chose to do so.
Ghost Migration Process
Here's a rough overview of the steps needed to test this out on my laptop.
- Export the blog content from WordPress to XML
- Make sure the WordPress site stays online
- For local laptop migration, use the Ghost migrate wp-xml tool
- It fetches all the images and blog content
- It creates a zip file with all the JSON and assets needed
- Load this zip file into the Ghost migrate tool universal migrator, and it pretty quickly creates all the posts
- Get the site looking and working how you want, then set it all up the same way on ghost.org.
With the 14-day trial, this was even easier. I just uploaded the WordPress XML file to my ghost.org site management console and it did the rest. All the blog posts, tags, and images were imported from my running site.
Then I took my validated redirects.yaml file from my local dev site and uploaded it to the prod site. That gave me the confidence to know all my redirects would work properly, and they DID, the first time without any troubleshooting in prod.
Ghost Migration Task List
The following is a dump of my notes on the migration. I hope this is useful to someone moving from WordPress to Ghost.
You can see my raw thought process in bullet form.
- [x] Figure out /blog/yyyy/mm/slug format for [[Ghost]]. Slug only? /blog/slug? Preserve or redirect. #blog ✅ 2026-02-12
- https://blog.ryansechrest.com/2022/12/include-year-and-month-in-ghosts-blog-post-permalinks/
- Something like the above to KEEP the year and date
- OR - migrate? but URLs shouldn't change. Redirect is ok. These are not high traffic assets and it avoids broken links.
- This is most relevant: https://ghost.org/tutorials/implementing-redirects/#common-redirects
Redirect the dated post slugs to the root/slug
301:
^\/[0-9]{4}\/[0-9]{2}\/(.*): /$1
Can I have this AFTER that? Does it need to be on a separate line? It stays in the 301 section. This redirects anything at blog/root like the pgp-keys page.
301:
^\/blog\/(\S+): /$1
It seems like this would be the way - oh - but what about /blog/ to / or /blog to /.
Tested the following and it works for what I need. The ghost tutorial doesn’t tell you, but place redirects.yaml in the following location for testing this out on your local site. I dug into that and the documentation does tell you where to place this file.
content/data/redirects.yaml
301:
^\/blog\/[0-9]{4}\/[0-9]{2}\/(.*): /$1
^\/blog\/wp-content\/uploads\/[0-9]{4}\/[0-9]{2}\/(\S+): https://bbbblog.s3.us-east-1.amazonaws.com/$1
^\/blog\/(\S+): /$1
^\/blog: /
^\/s3\/(\S+): https://bbbblog.s3.us-east-1.amazonaws.com/$1
Notice how I also got a cool redirect there too in the last line. I can pop files into s3 in a bucket I own and just write out
https://bbbburns.com/s3/<file-name>
Much shorter than the bucket URL.
- [x] Figure out slug overlap #blog ✅ 2026-02-12
- There isn't any slug overlap between all the dated urls and I verified this with the following. This means I can flatten all post to the root which is the ghost default. Default means less work!!
cat bbbblog.WordPress.2026-02-08.xml| grep "<link>" | grep -E '[0-9]{4}/[0-9]{2}' | sed -E 's|.*\/([a-zA-Z0-9-]+)/<\/link>|\1|' | sort > wp-slugs.txtuniq -d wp-slugs.txt- Thanks AI for coming up with a sed expression quickly
- [x] Figure out mp3 hosting #blog ✅ 2026-02-14
- Setup redirects for those mp3s in the redirect file, shown above.
- Yes. This works, like:
^\/cnn: https://cnn.com - Tracking that in [[Newsletter Links]] and [[S3 Bucket for File Hosting]]
- [x] Put pgp key files at root of theme or fix mail key discovery? #blog ✅ 2026-02-15
- Use public servers or skip this and give up on PGP.
- Key base identity or keyoxide?
- Or delete all keybase? Nah, don't NEED to.
- Setup keybase on iPad and used keybase DNS TXT record to verify my domain.
- Use my own wkd or setup keys.openpgp.org
- Used the CNAME openpgpkey.bbbburns.com
- Checked and WKD works:
╰─➤ gpg --locate-keys --auto-key-locate clear,nodefault,wkd burns@bbbburns.com
gpg: key 4B33CDC4903940DE: "Jason Burns <burns@bbbburns.com>" not changed
gpg: Total number processed: 1
gpg: unchanged: 1
pub rsa3744 2015-05-02 [SC] [expires: 2028-05-11]
44D9CD4EB5DE7C2567B6969C4B33CDC4903940DE
uid [ultimate] Jason Burns <burns@bbbburns.com>
sub rsa2048 2015-05-02 [S] [expires: 2028-05-11]
sub rsa2048 2015-05-02 [E] [expires: 2028-05-11]
sub rsa2048 2015-05-02 [A] [expires: 2028-05-11]
- [x] Figure out Brian server email sending #blog ✅ 2026-02-15
- Not an immediate web cutover priority?
- Should add the mail change settings. Check my porkbun DNS.
- Used jason@burns.team
- Used a generic postfix map for all accounts on brian to remap from address.
- Used sasl relay to porkbun SMTP server
- https://www.linode.com/docs/guides/configure-postfix-to-send-mail-using-gmail-and-google-workspace-on-debian-or-ubuntu/
- https://serverfault.com/questions/147921/forcing-the-from-address-when-postfix-relays-over-smtp
- https://www.postfix.org/ADDRESS_REWRITING_README.html#generic
- [x] Notify FWS crew? #blog ✅ 2026-02-14
- separate task for later: setup image gallery locally - gated behind login
- Immich still looks really nice!
- This could be a good way to hide newsletters from the home page https://blog.ryansechrest.com/2023/02/hide-specific-posts-from-homepage-in-ghost/
- https://docs.ghost.org/themes/routing?ref=ghost.org#filtering-collections
- Ghost collections and filtering COULD be helpful, but I'm just not sure what that would do for the main page? I guess blog would still be in the main collection
- I decided hiding the newsletter isn’t really what I want. I want it visible and showing a subscribe nudge.
- However, I COULD create a podcast rss in the future.
- [-] Figure out if i want to migrate comments https://forum.ghost.org/t/migrating-wordpress-comments-into-ghost-native-comments/35284/12
- [x] Edit porkbun DNS with flattening cname ✅ 2026-02-14
- [x] Pay for Ghost #blog 📅 2026-02-21 ✅ 2026-02-15
- [x] Migrate [[Newsletter Subscribers]] into Ghost #blog ✅ 2026-02-14
- [x] Notify subscribers in Subscriber Notification of the email change #blog ✅ 2026-02-15
- [x] Power off ec2 instance to stop paying for it. ✅ 2026-02-15
- There are still EBS snapshots AND the public IP address
- And I bought the reserved instance upfront, can I sell that? Not easily. Just eat the cost on the remaining time on the year.
- [ ] Release my public IP and EBS snapshots, terminate EC2 instance🪦 🛫 2026-03-15
- No going back after this! Unless I restore from backups.
