Why we moved the Apify blog to GitHub (and kept Ghost)

Why Apify moved blog authoring from Ghost to GitHub: the reasoning, the setup, and the trade-offs of putting Git in front of a hosted CMS.

The Apify blog has been running on Ghost for years. Hundreds of posts, a steady editorial pipeline, a CMS the team knows. Nothing was dramatically broken. So why we just spent a few weeks moving the source of truth into a GitHub repository is a fair question.

The short version: Ghost is still the place readers see, but it's no longer the place writers, editors, or scripts touch first. Posts now live as MDX files in a Git repo, and a sync workflow pushes them to Ghost on every pull request.

Calling that a "migration from Ghost to GitHub" is a slight stretch. Ghost still renders the site. What changed is where editing happens, and the kinds of changes editing can now do.

The thing Ghost couldn't help with

Ghost is a good CMS for one post at a time. Open the editor, write, hit publish. For day-to-day publishing it's hard to fault.

The problem is what happens at the other end of the timeline. We have years of legacy posts. A lot of them still get traffic. A lot of them also need work: outdated screenshots, stale product names, broken examples, internal links that point at pages that no longer exist. Every audit ends with the same finding: "fix these 80 things across these 200 posts."

Inside Ghost, that work scales linearly with clicks. Open a post, edit, save, close. Repeat 200 times. There is no diff to review, no way to apply the same change to a batch, no history that explains why the edit happened, and no clean way to let an LLM read 200 posts and produce 200 patches you can actually review before they ship.

We tried. It didn't really work. Bulk edits in a hosted CMS turn into spreadsheets of "who is doing what to which post," and the bottleneck is always the editor, not the writing.

What Git solves that a CMS doesn't

Once posts live in a repo, the usual Git affordances kick in:

  • A PR shows the exact change in a diff.
  • Reviewers can comment on a paragraph the way they comment on code.
  • The whole history is recoverable. "When did this paragraph get rewritten, and by whom?" is one git log away.
  • A script (or an LLM) can rewrite hundreds of files at once, and the diff makes that change reviewable instead of magical.

That last point is the one that finally tipped the balance. An LLM that can read every post in a folder, propose changes, and write them back as a patch is genuinely useful when the work is broad and shallow ("update every mention of this product across the back catalog"). That same workflow inside Ghost is not possible without bolting a sync layer on top.

Bolting a sync layer on top is what we did.

How the setup actually works

New posts are written as MDX inside content/blog/{slug}/index.mdx. Frontmatter holds the title, tags, author, excerpt, publish date, and feature image. Images sit in the same folder and get referenced with relative paths.

When a PR gets the sync-to-ghost label, a GitHub Action runs a script that:

  1. Validates the frontmatter against a schema with Zod.
  2. Compresses any new images to WebP and uploads them to Ghost.
  3. Converts the MDX body to HTML and sends it to the Ghost Admin API.
  4. Posts a preview link back on the PR.

Old posts went through the same plumbing in the other direction. The full Ghost export lives in content/blog-legacy/ as HTML files. They are not re-imported on every sync (Ghost already has them), but any edit to one of those files is treated like an update and pushed back to the matching post.

Ghost stays in front of readers. Nothing about the public site changed. Subscriptions, themes, members, and analytics all work the same way.

The honest trade-offs

The setup is not free. A few things are worse than they were before.

Deletes are manual. Removing a file from the repo does not depublish the post in Ghost. The repo can create and update, but not delete.

The sync is one-way, for now. If someone edits a post directly in Ghost Admin (and people will), the repo doesn't know. Optimistic locking on the Ghost side catches the obvious collisions, but the long-term answer is a two-way sync, which doesn't exist yet.

Author validation is on the honor system. The author slug in the frontmatter isn't checked against Ghost. A typo silently picks the wrong author, or no author at all.

These are all real, and worth knowing before copying the pattern. The reason the project still ships net-positive is that the operations we actually do most often (write a new post, fix a typo across many posts, run an audit, regenerate stale screenshots) are the ones the new setup makes easy. The operations it makes harder (delete one post) are rare.

What this unlocked

The first interesting thing that happened after the sync went live wasn't a new feature. It was a batch edit. One PR, one diff, hundreds of posts touched, all reviewed in an afternoon. That had never been possible.

The second was a writing skill that lives in the repo itself. A /write-blog-post workflow that knows the frontmatter schema, the tag taxonomy, and how to run a dry-run sync before opening a PR. New posts now start from a guided template instead of a blank Ghost editor.

None of that required leaving Ghost. It just required moving the source of truth out of it.

On this page

Publish and earn on Apify Store

The largest marketplace of tools for AI

Start here