Migrating a Ghost blog to EmDash on Cloudflare

By

Tagged: Cloudflare

Export Ghost, scaffold EmDash, keep every URL, move posts, tags and images with a script, publish them with their original dates, and shut the VPS down.

My Ghost blog runs in a Docker container on a VPS. Updating it is four commands, and I still don’t do it often enough. In 2026 Ghost fixed a stored XSS in the editor (6.67.0), a private IP filter bypass (6.65.0) and an XSS through Portal preview links (6.15.0), and each one meant a server I own was running vulnerable code until I got around to it.

EmDash takes the server away. The site runs on Cloudflare Workers with the database in D1 and images in R2, there’s nothing to patch, and the free tiers cover a blog. This guide is the migration, in the order I’m doing it. The import script was written against EmDash 1.4.0’s REST API and the Ghost import plugin’s source, and I checked the publish step against the EmDash playground. I’ll update this post with the numbers from my own run when it’s done.

What moves and what doesn’t

In Ghost In EmDash How
Posts and pages Posts and Pages collections Script, from the JSON export
Post HTML Portable Text The @plugdash/fromghost converter
Tags The tag taxonomy Script
Internal tags (#name) dropped Ghost bookkeeping, not content
Feature and inline images Media library in R2 Script, from a copy of content/images
Published dates publishedAt The publish call accepts a date in the past
Slugs Slugs Kept as they are
Authors Bylines By hand, one blog has one author
SEO title and description The SEO panel Script
Redirects (redirects.yaml) Redirects screen By hand or through the API
RSS at /rss/ /rss.xml One redirect
Members and newsletters nothing Export the CSV, move to a newsletter service
Comments Comments exist, but aren’t imported Lost, unless you script them
Theme Your Astro pages Rebuilt
Mobiledoc posts (Ghost 3 and older) empty body Convert in Ghost first

The last row matters if your blog is old. Ghost 5 moved posts to Lexical and keeps rendered HTML next to it, and that HTML is what the converter reads. A post that only has Mobiledoc imports empty. Open and save it in Ghost before you export.

1. Export from Ghost

In Ghost Admin go to Settings → Advanced → Import/Export, open the Export tab and choose All data. On a self-hosted site the zip downloads straight away. Inside it there’s a JSON file with posts, pages, tags, authors and settings, your members as a CSV, redirects.yaml and routes.yaml, and your theme.

Images aren’t in the export. On a self-hosted site they’re in the content folder, and with Docker you copy them out of the container:

docker cp ghost-blog:/var/lib/ghost/content/images ./ghost-images

Keep this folder next to the migration script. Uploading from a local copy is faster than downloading every image from the old site, and it works after the VPS is gone.

The JSON refers to images as __GHOST_URL__/content/images/2023/02/docker-pull.png. The converter replaces that placeholder with the site URL you give it, and the script turns the URL back into a path in ghost-images.

2. Create the EmDash site

Scaffold with the Cloudflare blog template:

npm create emdash@latest -- blog --template cloudflare:blog --yes
cd blog

Before deploying, change the URL pattern so posts live at the root, like Ghost’s /slug/. In seed/seed.json, the Posts collection has "urlPattern": "/posts/{slug}". Change it to "/{slug}", and move src/pages/posts/[slug].astro to src/pages/[slug].astro. Do the same for pages, which Ghost also serves at the root. Tag pages are already at /tag/[slug].astro, which matches Ghost.

Ghost forces trailing slashes, so every indexed URL ends with /. Leave Astro’s trailingSlash on ignore (the always setting breaks EmDash’s own routes) and add a middleware that sends the old form to the new one:

import { defineMiddleware } from 'astro:middleware'

export const onRequest = defineMiddleware(({ request, redirect }, next) => {
  const url = new URL(request.url)
  const skip = url.pathname.startsWith('/_emdash') || url.pathname.startsWith('/.well-known') || url.pathname === '/'
  if (!skip && url.pathname.endsWith('/')) {
    return redirect(url.pathname.slice(0, -1) + url.search, 301)
  }
  return next()
})

Don’t redirect /.well-known. OAuth and MCP discovery live there, and the CLI login stops working if you do.

Then deploy. The first deploy creates the D1 database, the R2 bucket and the KV namespace:

npm run build
npm run deploy

Open the workers.dev URL at /_emdash/admin/ and finish the setup wizard right away, because the first person through it becomes the admin. Passkeys are tied to the hostname, so if the final domain is ready, add it as a custom domain in wrangler.jsonc first and run the wizard there:

"routes": [{ "pattern": "blog.flaviocopes.com", "custom_domain": true }]

The site starts in staging mode, with noindex on every page. Leave it there until the end.

Under Settings → API Tokens create a personal access token with the admin scope. The script needs content:publish_any to backdate posts, and only an admin has it. Put it in the environment:

export EMDASH_URL=https://blog.flaviocopes.com
export EMDASH_TOKEN=ec_pat_...

3. The converter

There’s no Ghost importer in EmDash’s admin, which only lists WordPress sources. @plugdash/fromghost is a community plugin that reads a Ghost export and converts posts to Portable Text. It registers itself as an import source, but since the admin doesn’t show it, the README says to run it from a script. The useful part is that it exports its converter as a plain function, so the script doesn’t need the plugin installed on the site at all. Install it next to the script:

npm install @plugdash/fromghost

createGhostSource gives you two things: analyze, which counts posts, pages, tags and images and lists warnings, and fetchContent, an async generator that yields one normalized item per post. Each item has the title, slug, excerpt, dates, tag slugs, the feature image URL, SEO fields, and content already converted:

  • paragraphs and headings become blocks with a style
  • ul and ol become blocks with listItem and level
  • pre becomes a code block, with the language taken from class="language-bash"
  • img becomes an image block with asset: { _ref: url, url }
  • links and strong, em, code become marks

Posts with visibility set to members or paid are imported as drafts by default, so a paywalled post never goes public by accident.

4. The import script

The script reads the export, uploads each image once, creates the tags, creates every post as a draft, and publishes the ones that were published in Ghost with their original date. Save it as ghost-to-emdash.mjs:

import { readFile } from 'node:fs/promises'
import { createGhostSource } from '@plugdash/fromghost'

const GHOST_URL = 'https://blog.flaviocopes.com'
const IMAGES_DIR = './ghost-images'
const { EMDASH_URL, EMDASH_TOKEN } = process.env

const auth = { Authorization: `Bearer ${EMDASH_TOKEN}` }
const json = { ...auth, 'Content-Type': 'application/json' }

async function api(path, init = {}) {
  const res = await fetch(`${EMDASH_URL}/_emdash/api${path}`, init)
  const body = await res.json()
  if (!body.success) throw new Error(`${path}: ${body.error.code} ${body.error.message}`)
  return body.data
}

const uploads = new Map()
async function upload(url, alt) {
  if (uploads.has(url)) return uploads.get(url)
  const relative = url.replace(`${GHOST_URL}/content/images/`, '')
  const form = new FormData()
  form.append('file', new File([await readFile(`${IMAGES_DIR}/${relative}`)], relative.split('/').pop()))
  if (alt) form.append('alt', alt)
  const { item } = await api('/media', { method: 'POST', headers: auth, body: form })
  uploads.set(url, item)
  return item
}

const exportFile = new File([await readFile('./ghost-export.json')], 'ghost-export.json')
const source = createGhostSource({ siteUrl: GHOST_URL })

const analysis = await source.analyze({ type: 'file', file: exportFile }, {})
console.log(analysis.postTypes, analysis.attachments.count, 'images', analysis.tags, 'tags')
for (const warning of analysis.warnings) console.warn('warning:', warning)

const ghost = JSON.parse(await readFile('./ghost-export.json', 'utf8')).db[0].data
for (const tag of ghost.tags.filter(tag => tag.visibility !== 'internal')) {
  await api('/taxonomies/tag/terms', { method: 'POST', headers: json, body: JSON.stringify({ name: tag.name, slug: tag.slug }) })
    .catch(error => console.warn(tag.slug, error.message))
}

const items = source.fetchContent({ type: 'file', file: exportFile }, { postTypes: ['post', 'page'], includeDrafts: true })
for await (const post of items) {
  const collection = post.postType === 'page' ? 'pages' : 'posts'

  const content = []
  for (const block of post.content) {
    if (block._type !== 'image') { content.push(block); continue }
    const media = await upload(block.asset.url, block.alt)
    content.push({ ...block, asset: { _ref: media.url, url: media.url } })
  }

  const data = { title: post.title, excerpt: post.excerpt, content }
  if (post.featuredImage) data.featured_image = { id: (await upload(post.featuredImage)).id }

  const { item } = await api(`/content/${collection}`, {
    method: 'POST',
    headers: json,
    body: JSON.stringify({
      slug: post.slug,
      status: 'draft',
      data,
      taxonomies: post.tags ? { tag: post.tags } : undefined,
      seo: post.meta ? { title: post.meta.seoTitle, description: post.meta.seoDescription } : undefined,
    }),
  })

  if (post.status === 'publish') {
    await api(`/content/${collection}/${item.id}/publish`, {
      method: 'POST',
      headers: json,
      body: JSON.stringify({ publishedAt: post.date.toISOString() }),
    })
  }
  console.log(post.status, collection, post.slug)
}

Run it:

node ghost-to-emdash.mjs

A few notes on why it’s shaped this way.

The media endpoint deduplicates by content hash, so an image used in three posts is stored once even if the map misses it. Its response is the media item with its url, and that URL goes into the image block’s asset, both as _ref and as url, which is how the WordPress importer rewrites inline images too. A featured image field takes the media id instead.

Posts are created as drafts and published in a second call, because the publish endpoint is where publishedAt is honoured. I tested this part on the playground: a post created today and published with "publishedAt": "2021-03-15T09:30:00.000Z" came back with that date, sorted first in a list ordered by publish date, and its page returned 200. Without the date, publishing stamps the current time and your archive collapses into one day.

Tags are created before the posts, so the taxonomies field on each post can refer to them by slug. The catch is there because a second run hits existing terms.

The script is safe to rerun for new posts only if you delete the half-imported ones first, since EmDash doesn’t skip existing slugs on this path. The simplest way to retry from scratch is to delete the D1 database and deploy again.

5. Check every URL

Ghost publishes a sitemap, and the posts part of it is the list of URLs that must keep working. Fetch it from the old site and request each path on the new one:

const old = await fetch('https://blog.flaviocopes.com/sitemap-posts.xml').then(r => r.text())
const paths = [...old.matchAll(/<loc>https:\/\/blog\.flaviocopes\.com(\/[^<]*)<\/loc>/g)].map(m => m[1])

let failed = 0
for (const path of paths) {
  const res = await fetch(`https://blog.workers.dev${path}`, { redirect: 'follow' })
  if (res.status !== 200) { failed++; console.log(res.status, path) }
}
console.log(paths.length, 'checked,', failed, 'failed')

Run the same for sitemap-pages.xml and sitemap-tags.xml. Then check images: open a handful of posts and look for broken ones. Daniel Mitchell, who moved five years of Ghost posts this way, found 39 of his 64 inline images 404ing because the image blocks had a bare ID in asset and no URL, and his first check missed it because it only looked for URLs with a file extension. Check the rendered page, not the JSON.

Two more:

  • /rss/ is Ghost’s feed. EmDash’s blog template serves /rss.xml. Add a 301 under Redirects, and the same for /feed/ if you ever used it.
  • If redirects.yaml in your export has entries, recreate them under Redirects. The screen takes exact paths. Regex rules from Ghost need a middleware line each.

Turn on Settings → Backups while you’re in the admin, and take a D1 Time Travel bookmark once the import looks right:

npx wrangler d1 time-travel info blog

6. Members and the newsletter

EmDash has no members, subscriptions or email sending. If your Ghost site has any of those, move them before the cutover:

  • Export members from the same Import/Export screen as a CSV.
  • Import them into a newsletter service. Buttondown and Kit both take a Ghost CSV directly.
  • Put the service’s signup form in the Astro layout where Ghost’s Portal button was.
  • For paid members, tell them before you move. Stripe subscriptions created by Ghost keep charging until you cancel them, and nothing on the new site knows who paid.

My blog has no members and no newsletter, which is a large part of why the move is a day’s work and not a project.

7. Cut over

The order matters, because passkeys and EMDASH_SITE_URL are tied to the domain.

  1. Add the custom domain to wrangler.jsonc if you didn’t at the start, deploy, and confirm the admin login works there.
  2. In Settings → General, set the site URL to the real domain and switch the site from staging to live. The noindex tag goes away.
  3. Point DNS at the Worker. If the domain is already on Cloudflare the custom domain route does this for you.
  4. Run the URL check from step 5 again against the real domain.
  5. Leave the Ghost container running for a week with its public port closed, in case you need to look something up. Then delete the Droplet, and revoke the Ghost API keys and the Mailgun key it used.

The checklist

Print this part. Everything above explains why each line is there.

Before you start

  • Ghost is on 5.x or 6.x, so every post has HTML next to its Lexical body. Open and save any post that predates Ghost 5.
  • You know the current URL structure. The defaults are /slug/, /tag/slug/, /author/slug/ and /rss/. Check routes.yaml for anything custom.
  • You know whether the site has members, paid subscriptions or a newsletter. Each one is work that has to happen before the cutover.
  • You have a Cloudflare account, and the domain is either on Cloudflare DNS or you can change its nameservers.
  • Node 20 or later, and Wrangler logged in with npx wrangler login.

Export

  • Settings → Advanced → Import/Export → Export all data, and keep the zip.
  • docker cp the content/images folder out of the container, or rsync it from the server.
  • Members CSV from the same screen, if there are members.
  • A copy of redirects.yaml and routes.yaml, which are in the zip.
  • The Ghost sitemaps saved locally: sitemap-posts.xml, sitemap-pages.xml, sitemap-tags.xml. They are the test for step 5 and they disappear with the server.

New site

  • npm create emdash@latest -- blog --template cloudflare:blog --yes
  • urlPattern changed to /{slug} for posts and pages, and the Astro routes moved to match.
  • Trailing slash middleware in place, with /_emdash and /.well-known left alone.
  • Custom domain in wrangler.jsonc before the first deploy, so passkeys are registered on the real hostname.
  • Deployed once, setup wizard done, and you’re the admin.
  • Site left in staging mode.
  • Admin personal access token created, EMDASH_URL and EMDASH_TOKEN exported.

Import

  • npm install @plugdash/fromghost next to the script.
  • analyze run first. Read every warning before importing anything.
  • Script run once. Note the counts it prints and compare them with the Ghost post list.
  • Paywalled posts checked. They import as drafts, and you decide one by one.
  • Drafts in Ghost are still drafts in EmDash.

Check

  • Every URL from the three sitemaps returns 200 on the new site.
  • Published dates match. Open the archive page and look at the order.
  • Images load in ten posts picked at random, inline and feature.
  • Code blocks kept their language.
  • /rss/ redirects to /rss.xml.
  • Entries from redirects.yaml recreated under Redirects.
  • Backups turned on in Settings, and a Time Travel bookmark noted.

Members and email

  • Members CSV imported into the newsletter service.
  • Signup form in the layout.
  • Paid members told, and their Stripe subscriptions cancelled or moved.

Cut over

  • Site URL set to the real domain in Settings → General.
  • Staging mode off.
  • DNS pointed at the Worker.
  • URL check run again on the real domain.
  • Ghost container stopped, public port closed, server kept for a week.
  • Mailgun key and Ghost integrations revoked, then the server deleted.

What it costs after

Ghost on a VPS EmDash on Cloudflare
Server $6 a month, DigitalOcean $0, Workers free plan
Database MySQL or SQLite on the server D1, 5 GB free
Images on the server disk R2, 10 GB free, no egress fees
Email Mailgun for newsletters not needed for a blog without a newsletter
Updates you, with Docker npm update emdash and deploy, no server
Security patches you have to apply Ghost, Node, Docker, Ubuntu none

The last row is the reason to do it.

Want me to talk about your product? You can sponsor this site.

Related posts about cloudflare: