Migrating 73 Posts From Jekyll to Ghost (And the Poetic Irony of the Theme)
The andrewcz.com blog started life on Jekyll, moved to Ghost, and then spent months in a weird in-between state where neither platform was the source of truth. This is the story of how we closed that gap, migrated 73 posts, discovered a pile of Ghost Handlebars footguns along the way, and ended up right back where we started… theme-wise, at least.
What Happened
The original plan was to stand up another Jekyll pipeline in the homelab. The site had been running on Ghost for a while, but the Ghost instance didn’t match the Jekyll source of truth… colors were off, taxonomy was missing, social links were wrong, and there was no search. The Jekyll repo on GitLab (gitlab.com/smacz/posts) had 73 published posts that weren’t in Ghost.
After looking at it, the direction changed. Ghost was already running and doing most of what we needed. The real gap was that Ghost hadn’t been dialed in to match the Jekyll theme, and it was missing a few things Jekyll couldn’t do anyway (search, proper taxonomy pages). So instead of rebuilding Jekyll, we committed to making Ghost match Jekyll’s source of truth and then backporting all the content.
Here’s the poetic irony: the Ghost theme we’re using (uno-zen-revised) originally shipped with Ghost. I ported it to Jekyll years ago when I moved off Ghost the first time. Now I’ve ported it back to Ghost. The theme made a round trip… Ghost to Jekyll and back to Ghost again. There’s something funny about spending hours dialing in CSS colors on a theme that was originally designed for the platform you’re dialing it in on.
The Theme Work
First thing was matching the Jekyll dark theme colors in the Ghost CSS. The key colors:
- Body/html background:
#202020 - Accent:
#bf3f34 - Text:
#b2a191 - Headings:
#e8d0c3 - Meta text:
#9fa590 - Code background:
#171717 - Borders:
#404040
I applied these via a Python script that does targeted replacements on specific CSS rules. That approach came from a hard lesson… early in the process, a sed operation nuked the 31KB CSS file down to 2.5KB. Just destroyed it. Had to restore from a local backup and re-apply everything. The Python approach is way safer for multi-line CSS changes; it targets specific rules rather than doing line-by-line substitutions that can cascade into disaster.
Beyond colors, the social links got updated to match Jekyll (Email, Arch, GitLab, Nextcloud, RSS… removed DDG and Reddit that were there before). Disqus comments got ripped out of post.hbs since Jekyll never had them and they were just a spam magnet with no real upside. The “Writings.” heading got removed from index.hbs. Sidebar elements all got styled as pills for visual consistency… nav links, social icons, description, search bar, search results, all matching the dark theme.
Search: Two Tiers Because Ghost Ships With Nothing
Jekyll had no search. Ghost also ships with no search. So we built a two-tier system:
- Inline sidebar search (
partials/search.hbs) – pill-styled input, uses the Content API, shows up to 5 results inline, always visible - Full search page (
page-search.hbs) – dedicated/search/page that shows all matching results with excerpts
Both hit Ghost’s Content API. The sidebar search submits to /search/ when you hit enter, so you get the full results view there.
Taxonomy: Ghost’s Handlebars Is a Footgun Minefield
Jekyll had a clean category structure. Ghost doesn’t have categories natively… it has tags. So we mapped Jekyll categories to a tag convention: category:HomeLab, program:Nix, process:Config (tag name category:HomeLab, slug category-homelab).
We created three taxonomy pages: /categories/, /programs/, /processes/. The original approach was to use Ghost’s NQL filtering in {{#get}} helpers with slug:~^category- to pull tags by prefix. That didn’t work. Neither did {{#match}} with pattern= or starts=. Neither did {{#has}} with wildcard slug="category-*".
Ghost 6’s Handlebars helpers don’t support prefix or regex matching on tag slugs. The documentation suggests they should, but they don’t. So the taxonomy pages were rewritten to use client-side JavaScript filtering… {{#get "tags" limit="100"}} fetches all tags into JS arrays, then JS filters by slug prefix and renders DOM elements. It works, but it’s a workaround for something Ghost should handle natively.
One more Handlebars gotcha: you can’t use {{count.posts}}} in a template. The triple }}} causes a parse error because Handlebars sees the closing }} and then a stray }. Had to separate JS object braces from HBS closing braces by using separate JS arrays per property instead of objects.
The Template Cache
Ghost’s template cache requires a full stop and start, not just a restart. On both ct0 and ct1:
sudo systemctl stop ghost-blog && sleep 3 && sudo systemctl start ghost-blogA simple restart doesn’t pick up template changes. Figured this out the hard way after making HBS changes that didn’t show up until we did the full stop/start cycle. This is the kind of thing that costs you 30 minutes of “why aren’t my changes showing up” before you realize the cache is lying to you.
The Admin API: Read-Only for Settings
Ghost’s Admin API returns 403 for settings and navigation endpoints, at least with the API key we have. Tried to set posts_per_page to 20 programmatically… 403. Tried the config endpoint… 404. Tried reaching the MariaDB container directly from the Ghost container… couldn’t get there.
The posts_per_page setting lives in the settings table in the Ghost database, but there’s no way to change it from outside the Admin UI with our current API key. If we need to do this programmatically later, we’ll need either an Admin API key with settings write permissions or direct MySQL access to the Ghost database.
The Migration: 73 Posts From GitLab to Ghost
The actual post migration was handled by a script at /tmp/ghost-migration/migrate.py. It:
- Fetches all posts from the GitLab repo (
gitlab.com/smacz/posts, project ID 9629834, branchmaster, posts in the root directory not_posts/) - Parses the Jekyll YAML front matter
- Converts markdown to HTML via
pandoc -f markdown -t html5 --no-highlight - Strips out image references (we’re not hosting Jekyll images in Ghost… that’s a separate decision)
- Rewrites internal Jekyll links to Ghost URL format
- Maps the
descriptionfront matter field to Ghost’scustom_excerpt, truncated to 300 chars - Drops the
repoandimagefront matter fields - Creates the post in Ghost with
?source=html(required for Ghost v5+ to accept HTML content) - Backdates
published_atto the original Jekyll publish date - Applies taxonomy tags based on the Jekyll front matter categories
The script is idempotent… running it multiple times won’t create duplicate posts. It also has --dry-run and --skip-fetch flags for testing.
A few normalization passes after the migration:
- The 7 existing Ghost retros got tagged with the same
category:/program:/process:format (old Homelab/Programs/Processes/Book Reviews tags removed) process:Configuregot normalized toprocess:Configacross 7 posts- Tag casing got cleaned up:
SysadmintoSysAdmin,ConfiguretoConfig, title-case for program tags - One duplicate post (“Subscribing to Nextcloud Public Calendar Link”) got deleted
Final state: 80 posts in Ghost (73 migrated + 7 original), all with taxonomy tags. Individual tag pages work at /tag/{slug}/.
What We Learned
- Don’t use
sedfor multi-line CSS changes. A Python script doing targeted replacements is safer, more reliable, and won’t destroy your 31KB CSS file. Back up the file first regardless. - Ghost’s Handlebars helpers are more limited than the docs suggest. NQL regex filters,
{{#match}}patterns, and{{#has}}wildcards all claim to work but don’t in Ghost 6. Client-side JS filtering is the workaround. - Ghost’s template cache requires a full stop/start, not a restart. This is undocumented behavior that will cost you time if you don’t know about it.
- The Ghost Admin API is read-only for settings with standard API keys. If you need to change settings programmatically, you need a key with settings write permissions or direct database access.
- Ghost v5+ requires
?source=htmlwhen creating posts with HTML content. Without it, thehtmlfield is silently ignored and you get an empty post. - Pandoc is the right tool for Jekyll-to-Ghost content conversion.
pandoc -f markdown -t html5 --no-highlightproduces clean HTML that Ghost accepts. - Idempotent migration scripts save you from yourself. Being able to re-run the migration without creating duplicates meant we could iterate on the script without cleaning up the database each time.
- Themes can make round trips. The
uno-zen-revisedtheme went from Ghost to Jekyll and back to Ghost. Sometimes the right answer was the one you started with.
What’s Left
posts_per_pagestill needs to be set to 20 in the Ghost Admin UI (manual, API can’t do it)- Internal links between migrated posts may need a second pass using the mapping file at
/tmp/ghost-migration/jekyll_to_ghost_mapping.json - Podcast RSS placeholder metadata needs fixing (NixOS
service.nixenv vars) - Custom 404 page
- Images are stripped from migrated posts… need to decide if/how to host them