Migrating Buttondown to self-hosted Ghost

I recently migrated my friends Buttodown newsletter to a new instance of Ghost that I host for them. Here’s what I learned on the process.

Setting up Email

For Ghost to work you must have an email server set up. This is used for transactional emails (signup emails, lost passwords, etc.). I decided to use Brevo as they had a fairly generous 300 emails/day as well as no limit on the number of domains you can set up.

I wanted Ghost to send newsletters, but Ghost only officially supports Mailgun, and their cheapest tier starts at 15€/month, which is way too much for this small site. Bummer! I did find this repo reimplementing Mailgun’s API, that might be an option for the future. In the meantime I might see if I can share a mailgun subscription with someone.

Buttondown export

Buttondown exports a zip that looks something like this:

/export
	/emails.json
	/emails
		/email1.md
		/email2.md
		/email3.md
		...

That’s not directly importable to Ghost. Using the universal importer, it expects a single JSON. So we need to convert the export into an appropriate format.

Convert Buttondown export to Ghost

Using a little LLM magic I got this script that surprisingly worked on the first try! I never thought I’d see the day. Put the buttondown export in a folder ‘buttondown_export’ next to this script and then run it with ‘python convert_buttondown_to_ghost.py’.

import json, os, time
from datetime import datetime

# Paths
EXPORT_DIR = "./buttondown_export"  # folder containing emails.json + /emails/*.md
OUTPUT_FILE = "ghost_import.json"

# Load Buttondown metadata
with open(os.path.join(EXPORT_DIR, "emails.json"), "r", encoding="utf-8") as f:
    emails = json.load(f)

posts = []
emails_dir = os.path.join(EXPORT_DIR, "emails")

for email in emails:
    slug = email.get("slug") or email["subject"].lower().replace(" ", "-")[:50]
    md_path = os.path.join(emails_dir, f"{slug}.md")

    # Fallback if filename doesn’t match slug perfectly
    if not os.path.exists(md_path):
        for f_name in os.listdir(emails_dir):
            if f_name.startswith(slug[:30]):  # fuzzy match
                md_path = os.path.join(emails_dir, f_name)
                break

    if not os.path.exists(md_path):
        print(f"⚠️ Missing markdown for {email['subject']}")
        continue

    with open(md_path, "r", encoding="utf-8") as f:
        md_content = f.read().strip()

    mobiledoc = {
        "version": "0.3.1",
        "atoms": [],
        "cards": [["markdown", {"markdown": md_content}]],
        "markups": [],
        "sections": [[10, 0]]
    }

    posts.append({
        "id": email.get("id") or slug,
        "title": email["subject"],
        "slug": slug,
        "mobiledoc": json.dumps(mobiledoc),
        "status": "published",
        "created_at": email.get("created_at") or datetime.now().isoformat(),
        "published_at": email.get("publish_date") or datetime.now().isoformat(),
    })

ghost_export = {
    "meta": {
        "exported_on": int(time.time() * 1000),
        "version": "5.0.0"
    },
    "data": {
        "posts": posts
    }
}

with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
    json.dump(ghost_export, f, ensure_ascii=False, indent=2)

print(f"✅ Created {OUTPUT_FILE} with {len(posts)} posts ready for Ghost import.")
https://toby.place/garden/migrating_buttondown_to_self-hosted_ghost/ https://sunbeam.city/@nice_tea https://web.brid.gy/r/:https://toby.place