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.")