Ghost to EmDash: 2,644 Posts, and Why I Stopped Trusting My Logs

I moved this blog off self-hosted Ghost onto EmDash, running on Cloudflare Workers. The data moved fine. It was the measuring that kept going wrong: a bounded index, a plugin that made every write take a minute, and a verification query that lied to me for hours.

I thought I was migrating 51 blog posts.

The sitemap said 2,644.

Every plan I made in the first hour was built on a number that was wrong by a factor of fifty. What follows is the stuff that only shows up once you're a thousand writes deep and your own logs have started lying to you.

The stack, before and after

This blog ran on Ghost 6.52, self-hosted, on the bespoke stack I wrote about in Self-Hosting Ghost 6. Docker on a Proxmox LXC, external MySQL, R2 for images, a Cloudflare Tunnel for ingress.

It now runs on EmDash, a CMS built on Astro, deployed to Cloudflare Workers with D1 for the database and R2 for media. No container, no MySQL, no tunnel. The whole thing is a Worker.

The move was worth doing. The interesting part is the handful of places where the obvious approach turned out to be quietly wrong.

Count your corpus before you plan anything

My author page said "51 posts". The homepage showed a dozen. I scoped the migration around that.

Then I pulled sitemap-posts.xml:

posts: 2644
  MC relay:  2584
  editorial:   60

2,584 of those were an automated Microsoft 365 Message Center archive – a pipeline that relays MC notices to the blog under a separate author account. I'd stopped thinking of them as posts at all. They were 98% of the corpus.

That changes everything downstream. Sixty editorial posts is an afternoon of careful work. Two and a half thousand machine-written notices is a schema decision, a pagination strategy, a throughput problem, and 2,584 redirects.

I also tried /llms.txt first, because Ghost 6 exposes one and it looked like a tidy index. It's bounded: it returns a recent subset, heavily skewed towards whatever was published last. It showed me 113 posts and I nearly believed it. The sitemap is the authoritative list.

Ghost's best migration feature is undocumented

Ghost 6 will hand you clean Markdown for any post or page. Append .md to the URL:

https://blog.tophhie.cloud/self-hosting-ghost-6-the-gotchas-nobody-documents.md

You get a metadata block (URL, published, updated, description, author, tags) and then the body as Markdown. No API key, no export file, no wrestling with mobiledoc or lexical JSON.

The alternatives are worse. The Content API needs a key and returns HTML. The admin export gives you a giant JSON blob in Ghost's internal format. The .md endpoint gives you the thing you actually want, per post, in a form a converter can eat.

One wrinkle: there's a three-line "Content Index" banner at the top of every response. Strip it.

Portable Text conversion has sharp edges

EmDash stores rich text as Portable Text, a structured JSON format that is neither HTML nor Markdown. So everything has to be converted, and this is where I lost the most time.

The REST API does not accept Markdown. EmDash has a markdownToPortableText, and it's wired into the CLI, the MCP server and the JS client. It is not wired into the REST content route, which validates the field as Portable Text before any conversion runs. Post Markdown to it and you get content: Invalid input. The docs' own example implies otherwise. I converted client-side in the end.

`*emphasis*` doesn't convert. Underscores do, asterisks don't. Ghost emits asterisks. That silently affected 23 of my editorial posts before I caught it, so the importer normalises *em* to _em_ first, carefully enough not to maul code fences, inline code, **bold**, or list bullets.

Tables get flattened. A Markdown table becomes one paragraph with the pipes still in it: | Feature | Status ||---|---|| Photos | Rolling out |. EmDash has a perfectly good table block type; the Markdown converter just doesn't produce it. Since 389 of my Message Center notices contain tables (Microsoft loves a table), I now pull tables out before conversion, leave a placeholder, and splice real table blocks back in, running the converter per cell so bold and links survive.

Backslash escapes are ignored. This one reached production before I noticed. Ghost's Markdown escapes punctuation, \[, \], \., \-, and EmDash's converter passes them through as literal text. So a heading rendered as:

\[What and Why:\]

40% of my corpus was affected. 1,074 entries.

The fix is to unescape *after* conversion, on the finished text. Doing it beforehand is actively dangerous: unescaping \- at the start of a line turns it into a list bullet, and \* becomes emphasis. Once the text is plain content, removing a backslash can't misfire.

One broken plugin made every write take a minute

Partway through the bulk import, writes went bad. Not failing, just slow. Sixty seconds, then a timeout. I dropped concurrency, added retries, added backoff. It stayed slow. I convinced myself D1 had a write ceiling I was hitting.

It didn't. I had a marketplace plugin at v0.1.0 throwing on every content write:

Storage collection not declared: records
    at CloudflareSandboxedPlugin.withWallTimeLimit

It declared no records storage collection but tried to use one, threw inside its sandbox, and burned the wall-time limit before the write could return. Disabling it took a content write from 60 seconds to 0.92 seconds.

A misbehaving hook in the write path looks exactly like an infrastructure limit from the outside. Same symptoms, same graphs, entirely different cause. I spent hours tuning a client that was never the bottleneck.

The tell, in hindsight: writes to my small posts collection were fine at 1.7 seconds while writes to the big one timed out. That asymmetry points at per-write work rather than capacity, and I should have read it that way much sooner.

My logs were lying, and so was my verification

Two separate failures of measurement, and they're the part of this I'd most want someone else to avoid.

Writes that commit and then throw. Under load, EmDash's write path would land the row in D1 and then fail the response, either a Cloudflare 500 or a client timeout. My import script dutifully recorded these as failures. One pass reported published 0, failed 182. The database showed all 182 published.

I only caught it because a retry came back 409 CONFLICT on a stale revision token, which is only possible if the "failed" write had actually succeeded. After that I stopped retrying writes: single attempt, then re-run the whole pass and skip anything already correct. When the failure signal itself is unreliable, cheap idempotent passes are worth more than clever retry logic.

My verification query was wrong for hours. To count how many entries still had escape artefacts, I ran the obvious thing:

SELECT COUNT(*) FROM ec_message_center WHERE content LIKE '%\\%' ESCAPE '\'

Portable Text is stored as JSON. That query matches JSON's own \" and \n escapes, and every Windows path in every Microsoft notice. It reported 160 entries remaining and refused to budge no matter what I fixed, which sent me hunting for a cause that didn't exist.

When I finally parsed the blocks properly and checked only the span text, the true number was three. One of those three was a false positive as well: a notice that legitimately discusses the backslash character.

I'd stared at a stalled counter for a long time without once asking whether the counter was right.

2,659 redirects

Ghost serves posts at the root (/post-slug/); my EmDash routes are /posts/… and /message-center/…. Every existing inbound link needed a 301.

The obvious move was one rule for the 2,584 Message Center URLs, since they're all /mcNNNNNNN. Two things stopped that:

  • EmDash's redirect patterns use Astro route syntax ([param], [...splat]) and require a placeholder to be a whole segment. /mc[id] is rejected: "a placeholder must be the entire segment, not mixed with literal text."
  • Cloudflare's `regex_replace` needs a Business plan. I'm on Free.

So: individual redirect rows, created at concurrency 1 over about twenty minutes. Inelegant, but it used a mechanism I'd already verified seventy-five times, and that counted for more than elegance by that point in the day.

Watch the trailing slashes too. Ghost linked everything as /post-slug/, so both forms need to resolve.

What I'd do differently

Measure the corpus first. One sitemap-posts.xml fetch would have reframed the entire plan in the first ten minutes.

Validate the measurement before trusting it. If a number is going to steer your decisions, prove it counts what you think it counts. Mine didn't, and it cost more than any of the actual bugs.

Treat the database as the truth, not the API response and not the script's own success counter. Both lied. The database never did.

Rule out per-write work before blaming capacity. Disable the plugins and time one write. It takes five minutes and it would have saved me an evening.

Prefer idempotent passes over retries when writes are unreliable. Run it again, skip what's already right, repeat until a verification query reads clean.

Was it worth it?

Yes. The blog is a Cloudflare Worker now. No container to patch, no MySQL to fail over, no tunnel to babysit. Content lives in D1, media in R2, and the whole thing deploys with wrangler deploy.

The data moved fine, in the end. It was the measuring that kept going wrong, and that's the part I didn't expect to have to debug.

No comments yet