Migrating mauveRANT from Hashnode to Self-Hosted Ghost

Migrating mauveRANT from Hashnode to Self-Hosted Ghost
Photo by Kyle Larivee / Unsplash

Taking my blog back from someone else's platform

The introduction

Hashnode has been great. Let me say that upfront. It's a solid blogging platform for developers. Nice editor, decent markdown support, free custom domains, built-in newsletter. For many years, mauveRANT lived there without any complaints.

But "great" has an expiration date when you've spent the last month systematically pulling your data out of centralized platforms. I've moved my chat to Matrix, my photos to Pixelfed, my link aggregation to PieFed, my documents to CryptPad, and my git repos to Forgejo. The blog sitting on someone else's infrastructure was becoming another odd one out.

Hashnode could change their terms of service tomorrow. They could add AI training on my content. They could pivot to enterprise-only pricing. They could shut down entirely. History says platforms do these things, and you find out when it's already too late. I've watched it happen enough times to stop waiting for it to happen to me.

So the blog had to come home.

The "why Ghost"

I evaluated a few options. WordPress is the obvious choice but it's a maintenance treadmill of plugins, updates, and security hell. Hugo and other static site generators are elegant but I wanted a real editor and admin interface, not just markdown files and git commits. WriteFreely is minimal and federated but too minimal for what I wanted.

Ghost hit the sweet spot. It's open source. It runs in Docker. It has a great, modern editor. The Admin API is well-documented. It does markdown natively. No feature gates, no "upgrade to pro" nagging.

The stack is simple: Ghost 5 on Alpine, MySQL, both in Docker Compose. A reverse proxy handles TLS. That's it. No Redis, no Elasticsearch, no background workers. Ghost is refreshingly uncomplicated for what it does.

The architecture

                    Reverse Proxy (TLS)
                          |
                    rant.mvh.dev
                          |
              +-----------+-----------+
              |                       |
        ghost:2368              ghost-db:3306
        (Ghost Alpine)           (MySQL)
              |                       |
        ./content/              ./mysql/
        (themes, images,        (persistent
         settings, logs)         database)

Everything lives in one folder. The content directory mounts into the Ghost container and holds themes, uploaded images, and settings. MySQL data persists in a separate directory. Both are gitignored because they're state, not config.

The compose file is minimal:

services:
  ghost:
    image: ghost
    container_name: ghost
    restart: unless-stopped
    ports:
      - "${GHOST_PORT:-2368}:2368"
    environment:
      url: ${GHOST_URL}
      database__client: mysql
      database__connection__host: db
    volumes:
      - ./content:/var/lib/ghost/content
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql
    container_name: ghost-db
    restart: unless-stopped
    volumes:
      - ./mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 30s
      timeout: 5s
      retries: 5

Ghost waits for MySQL's healthcheck before starting. No race conditions, no restart loops. First docker compose up -d and you've got a running blog.

The migration

This is where it got interesting. I had 18 published posts and many drafts on Hashnode, all backed up as markdown files with YAML frontmatter in a git repository. The backup habit saved me here. If I'd been relying on Hashnode's export feature alone, I'd have been at the mercy of whatever format they decided to give me.

The migration needed to handle several things:

Parsing frontmatter. Most posts had standard YAML metadata: title, date, slug, cover image URL, tags. But one post had no frontmatter at all (just a markdown heading), one draft had empty metadata fields, and dates came in two different formats across posts.

Transforming Hashnode-specific markdown. Hashnode has its own embed syntax: %[https://youtu.be/...] for videos. Standard markdown parsers don't know what to do with that. I also had image alignment attributes (align="left") baked into image syntax that needed stripping, and internal links pointing to /series/ URLs that Ghost doesn't use.

Re-hosting images. Every cover image and inline image pointed to Hashnode's CDN or an S3 bucket. If I left those URLs in place, my "self-hosted" blog would still be dependent on Hashnode serving my images. The migration script needed to download each image, upload it to Ghost via the Admin API, and rewrite every reference.

I wrote a Node.js script that handled all of this. It used gray-matter for frontmatter parsing, marked for markdown-to-HTML conversion, and Ghost's @tryghost/admin-api for creating posts and uploading images. The script checks for existing posts by slug before creating, so it's safe to re-run if something fails halfway through.

The special cases were the kind of thing that makes automated migration tedious:

  • One post with no frontmatter: hardcoded the title, slug, and date
  • YouTube embed syntax: converted to an iframe wrapped in Ghost's HTML card markers
  • Two different date formats (ISO-8601 and JavaScript Date strings): both handled by new Date()
  • Placeholder cover URLs on drafts: detected and skipped
  • A couple of S3-hosted images returned 403: had to track down the correct URLs and fix them after the initial run

Twenty posts, seventeen cover images, two inline images. The whole migration ran in about 30 seconds. Every post landed in Ghost with the correct title, slug, date, tags, and feature image. The script is disposable. It lives in a migration/ directory that gets deleted after verification.

The first wall: domain timing

I initially configured Ghost with the final domain (rant.mvh.dev) before the DNS was ready. This meant Ghost generated all URLs, including uploaded image paths, using a domain that didn't resolve yet. The logo and banner I uploaded through Ghost Admin showed as broken images because the browser couldn't reach rant.mvh.dev to load them.

The fix was obvious in hindsight: keep the working domain (ghost.mvh.dev) during setup and content migration, then switch to the final domain only when DNS is ready. Ghost stores the URL in .env and picks it up on restart. One line change, one docker compose down && up.

The second wall: Hashnode URLs

Hashnode uses /series/ for tag-based groupings. Ghost uses /tag/. Every internal link in my posts pointed to the wrong URL structure. The migration script rewrote these in the post content, but external links and bookmarks would still break.

Ghost supports a redirects.yaml file for server-side redirects:

301:
  ^/series/(.+): /tag/$1

One regex, permanent redirects, covers all current and future series-to-tag mappings. Dropped it into content/data/ and restarted.

The third wall: email

Ghost uses email for admin login verification. It sends a one-time code every time you sign in. No email transport configured means no login. I found this out after locking myself out of the admin panel.

Ghost's newsletter system only supports Mailgun for bulk sends. But transactional email (login codes, signup confirmations) works with any SMTP server. I already run a local Postfix relay that forwards through Brevo, so I pointed Ghost at it:

environment:
  mail__transport: SMTP
  mail__from: [email protected]
  mail__options__host: local-mta
  mail__options__port: 25
  mail__options__secure: "false"

First attempt failed because I set secure: "true", which tries implicit TLS. My MTA speaks STARTTLS on port 25, which means the connection starts plaintext and upgrades. Setting secure to false lets the SMTP library negotiate STARTTLS automatically.

The Ghost container also needed network access to the MTA container, which meant joining the new mutual Docker network and declaring it as external in the compose file.

The fourth wall: memberships without Mailgun

Ghost's built-in newsletter feature is tightly coupled to Mailgun. You can't use a generic SMTP server for it. For someone with a small amount of newsletter subscribers, setting up a whole Mailgun account with DNS records and API keys felt absurd.

The pragmatic answer: turn off memberships entirely and use RSS. The blog is the product. People who want updates can subscribe at /rss/ with whatever feed reader they prefer. No email infrastructure required, no subscriber management, no deliverability concerns. Ghost generates the feed automatically.

Memberships disabled. Subscribe buttons gone. I may come back to this later, as some people have voiced their approval of email notifications for my new posts. But, no ETA.

The fifth wall: theming

Ghost ships with the Source theme, which is clean and modern but defaults to light mode with a bright accent color. Hashnode's dark theme was a defining part of the blog's look. Ghost's admin has some design controls but they're limited: you can toggle a few layout options and set an accent color, but there's no "make everything dark" switch.

Ghost supports code injection, which means you can inject arbitrary CSS into the site header. So I wrote a comprehensive dark theme override: dark backgrounds, light text, purple accent color, dark code blocks, styled tag pills, the works. It's about 120 lines of CSS injected via the site header, overriding Source's defaults without forking the theme.

This approach has a major advantage over forking: when Ghost updates the Source theme, I don't have merge conflicts. The CSS overrides sit on top. If a Ghost update changes a class name, I fix one CSS rule instead of rebasing an entire theme fork.

What I learned

Back up your content in a format you control. Every post on Hashnode was already in a git repo as markdown. When it was time to migrate, I had everything I needed. If I'd been relying solely on the platform's export feature, the migration would have been harder and riskier.

Migrate images, don't just link to them. A self-hosted blog that loads images from someone else's CDN isn't really self-hosted. The extra work of downloading and re-uploading images means the blog is fully independent. If Hashnode disappeared tomorrow, nothing breaks.

Configure email before you need it. Ghost's login verification requires working email. Discovering this after deployment means you're locked out of your own blog until you fix it. Add SMTP config to your initial compose file.

Mailgun-only newsletters is a real limitation. Ghost's tight coupling to Mailgun for bulk email is frustrating for small-scale self-hosters. Know this going in and decide whether you need newsletters or whether RSS is sufficient.

CSS injection is underrated. Ghost's code injection feature means you can dramatically change the look of a theme without forking it. For someone who wants a dark mode without maintaining a custom theme, it's the right tool.

Don't overcomplicate the stack. Ghost with MySQL in Docker Compose is a two-service deployment. No Redis, no workers, no build steps. The simplicity is a feature. After deploying Matrix (five services), PieFed (five services), and CryptPad (its own special kind of pain), Ghost felt like a vacation.

The epilogue

Another piece of my digital life running on hardware I control. The blog is no longer subject to Hashnode's terms of service, pricing changes, or platform decisions. The content lives in a MySQL database on my server, backed by Docker volumes I can snapshot and restore. The images are served from my own domain. The theme is CSS I wrote. The whole thing is reproducible from a compose file and a backup.

Is this more work than leaving the blog on Hashnode? Obviously. Hashnode handles hosting, CDN, SSL, updates, and backups. I now handle all of that myself. But that's the trade. Every platform you depend on is a platform that can change the deal. Self-hosting means the deal is whatever I decide it is.

The blog is live at rant.mvh.dev. RSS feed at /rss/. No newsletters (yet), no memberships, no tracking, no analytics. Just rants.

Now if you'll excuse me, I have even more containers to deploy.


Have questions or want to discuss? Find me on the fediverse:

- Matrix: @mauvehed:takeonme.org
- Mastodon: @[email protected]
- Feddit: [email protected]
- Pixel: [email protected]