# Migrating a Ghost blog to EmDash on 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-11-07 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/ghost-to-emdash/

My Ghost blog runs in a Docker container on a VPS. Updating it is [four commands](https://flaviocopes.com/docker-update-container/), 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](https://flaviocopes.com/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:

```bash
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:

```bash
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:

```ts
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:

```bash
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:

```jsonc
"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:

```bash
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`](https://www.npmjs.com/package/@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:

```bash
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 `block`s with a `style`
- `ul` and `ol` become `block`s 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`:

```js
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:

```bash
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:

```js
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:

```bash
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.
