Leaving Ghost for a Static Site
I write maybe two posts a month, and the writing itself was never the problem. Everything around it was.
My blog ran on Ghost , and Ghost is a real application. It needs a Node.js server, a MySQL database, and a few background services, all running in Docker on a small server I rented. Keeping it running took steady attention: database backups, version updates, and the occasional “why is the site slow today.” I was doing infrastructure work for a blog I posted to twice a month, and at some point I realized I was spending more time maintaining it than writing on it.
So I took it all down and rebuilt the blog as a plain static site. No database, no server to babysit, nothing running in the background. This is the story of why, and how I did it, including the migration tool I wrote and the way I publish posts now.
first, some credit#
I don’t want this to read as a Ghost hit piece. When I moved over from WordPress in 2023, I genuinely liked Ghost. The editor was clean, setup was simple, and it stayed out of the way when I wanted to write. After years of WordPress plugins, that felt much lighter. I even sent them a small donation early on, because I wanted the project to do well.
Ghost is a good product. It just turned out to be a good product for a problem I don’t have.
the problem it solves isn’t mine#
Ghost is built around paid newsletters and memberships. Subscriptions, paid tiers, email to your audience. That is where the product puts its energy, and it is genuinely good at it.
I never used any of that. No paid subscribers, no newsletter. I write posts and put them on the internet for free. So every feature built for the membership business was something I ran and maintained without ever touching.
And the stack kept growing. Over two years it got heavier instead of lighter: MySQL, the Node app, ActivityPub support, all wrapped in Docker. Every one of those makes sense if you run a newsletter business. None of it made sense for me. Ghost can also run on SQLite, which is a simple file-based database with no separate server, but officially that mode is meant for development, not production. So in practice I was running and backing up a full MySQL database for a personal blog.
running something for yourself vs for other people#
There is a real difference between self-hosting something only you use and self-hosting something other people read. Once a few people are reading, a database hiccup or a bad night for the host is no longer something you can ignore. It is your site that is down, so you end up keeping an eye on it.
That is how the ratio went backwards. I was spending more attention on MySQL, backups, and keeping containers happy than on the posts themselves. That is a poor trade for any blog. For a personal one, it makes no sense.
i couldn’t write where i wanted#
The other daily annoyance was writing on the move. Ghost had no app I trusted and no real way to draft from my phone when an idea showed up away from my desk. So I would write in Apple Notes or Notion wherever I was, then paste it into Ghost later when I was back at my machine. It worked, but it added a step to the one part that should be effortless.
the theming i never enjoyed#
A smaller thing also wore on me. I never liked theming Ghost. WordPress, for all its faults, lets you build a template and shape the site however you like. Ghost is code-only: you either take a theme as it is or edit it by hand, and I never found that time worthwhile. I ran the Journal theme and mostly left it alone. But I like owning how my site looks, and Ghost never made that easy.
choosing what came next#
For a while I thought about going back to WordPress. It is more polished than people give it credit for, and it is well optimized. But it is the same shape of problem with a different name: a database, a server, an admin panel, constant updates. That would not really solve anything.
So I made the real decision. No CMS at all. Not WordPress, not Ghost, not the next one. Just a static site: plain HTML files generated ahead of time and served as they are, with nothing running in the background.
The timing makes this easy. Hosting a static site in 2026 is basically free. Something that used to need a full-time person and a server room is now a setting you turn on. Cloudflare Pages, GitHub Pages , and others give you a global CDN, caching, and HTTPS at no cost for a site my size.
the stack#
Here is what I settled on:
- Hugo turns my markdown files into the finished HTML site. I looked at Eleventy (11ty) too, but I wanted a strong ready-made theme more than templating freedom, and Hugo had it. The look here is the Terminal theme by panr, with my own colour tweaks.
- Cloudflare Pages builds the site and serves it from servers close to whoever is reading.
- Cloudflare R2 stores the images. R2 is cheap, and more importantly it has no egress fees, which means you don’t get charged every time someone loads one of your images. That surprise cost is what S3-style storage is famous for, and R2 simply doesn’t have it.
- Pagefind handles search. It builds a search index when the site is built and runs the search entirely in the reader’s browser, so there is full-text search with nothing running on a server.
the migration tool#
The actual move took about three days. Day one was planning: what the content looked like coming out of Ghost, where each piece needed to land in Hugo, and how Cloudflare and R2 fit together. The other two days went into writing and testing a migration tool in Python, because I wanted this done properly and repeatably, not by hand.
I had around 150 posts and four years of writing, and two hard rules. Don’t lose any SEO, so every old URL had to keep working. And don’t break a single image. So I built the tool to be careful and to check its own work.
one post at a time, many posts at once#
The tool is built as a pipeline. Each post flows through a fixed sequence of stages, and every stage does one small job. Posts don’t depend on each other, so the stages inside a single post run in order, but many posts run through the pipeline at the same time across a pool of workers (eight by default). One worker takes one post and carries it all the way through. That is why the whole migration finishes in about the time the slowest post takes, not the sum of all of them.
the layers#
Here is the path each post takes, end to end:
flowchart TB
export[("Ghost JSON export<br/>~150 posts")]
subgraph worker["one worker, one post · eight running in parallel"]
direction TB
p1["Parse source"] --> p2["Metadata + slug"]
p2 --> p3["Body into blocks"]
p3 --> p4["Clean text + format"]
p4 --> p5["Re-host assets"]
p5 --> p6["Map cards to<br/>shortcodes"]
p6 --> p7["Assemble markdown"]
end
export --> p1
p7 --> gate{"Validate<br/>every stage"}
gate -->|pass| out["content/posts/<br/>slug/index.md"]
gate -->|fail| quar[["Quarantine<br/>+ reason"]]
p5 -.->|"sha256<br/>dedupe"| r2[("Cloudflare R2")]
out --> report["Report +<br/>resume state"]
quar --> report
report -.->|"re-run skips<br/>finished posts"| exportThere are eleven stages in total. The main ones, in plain terms:
- Parse the source. Ghost has changed editors over the years, so posts are stored in a few different internal formats. The first stage reads all of them and converts each post into one common structure the rest of the tool understands.
- Pull out the metadata. Title, slug, dates, tags, authors, cover image, social tags. The slug is the URL, so this stage makes sure it matches the old one exactly. That is the “zero SEO loss” rule in action.
- Break the body into blocks. Headings, paragraphs, lists, code, images, cards, each as its own typed piece, so later stages can handle them precisely.
- Clean up the text. Strip invisible characters, fix non-breaking spaces, and catch mojibake (garbled characters left over from bad encoding) so none of it leaks into the new files.
- Render the formatting. Bold, italics, links, properly nested lists, and code blocks preserved exactly, byte for byte. This is really a few stages doing one job: turning the structured blocks back into clean markdown.
- Re-host every asset. This is the important one for “no broken images.” Every image, file, and video is downloaded from the old site, hashed, uploaded to R2 under a filename based on its contents, and its URL rewritten to point at R2. Because the filename is the hash of the contents, an image used by five posts is uploaded once and reused. After this stage, no link to the old Ghost domain is left.
- Map Ghost’s special cards. Ghost has custom blocks (callouts, bookmarks, galleries, embeds, toggles, audio, video) that aren’t plain markdown. Each kind gets translated: some become plain markdown, and the interactive ones become Hugo shortcodes.
- Assemble and write. Combine the metadata and body, write the final
content/posts/<slug>/index.md, and confirm it is valid and Hugo can build it.
it checks its own work#
This is the part I cared about most. Every stage has a validator. If a stage produces something wrong, the post is quarantined: it gets set aside, and the tool records exactly which stage failed and why, instead of writing a broken post and moving on. At the end of a run it produces a report: how many posts were in the export, how many were written, how many were quarantined, and a pass rate for every single layer.
The point was simple. I didn’t want to discover broken posts weeks later. A run isn’t “done” until the report shows zero quarantined posts, every layer at 100%, Hugo builds with no warnings, and a search for any leftover old-domain URLs comes back empty.
It is also resumable. Every finished post is recorded, so if the network drops halfway or one post fails, I fix it and re-run, and it only processes what’s left. The R2 uploads are safe to repeat too, because the content-based filenames mean re-uploading the same image does nothing.
where it actually got hard#
The difficult parts were exactly where you’d guess. Ghost’s custom cards don’t map cleanly onto markdown, so each type needed its own translation rule. On top of that were the usual formatting edge cases and getting code blocks to survive exactly as written. Most of the testing time went into those odd cases, not the normal posts. The normal posts were never the problem.
I’ve open-sourced this tool: ghost2hugo . It started out tuned to my own content, but the shape of it, a layered pipeline that validates each step and re-hosts assets, carries over to anyone making the same move.
keeping every old url working#
The thing that scares people most about leaving a CMS is breaking their own links. Years of posts, search rankings, and inbound links all point at URLs that are about to change. If the new site serves those same paths, nobody notices the move. If it doesn’t, you get a lot of 404s and your search traffic drops off.
So this was a hard requirement, not a nice-to-have. Two things made it work:
- The post URLs didn’t change at all. Ghost served my posts at the top level, like
/my-post-slug/. I set Hugo up to do exactly the same, so every old post link resolves to the same place on the new site. No redirect needed, because nothing actually moved. - The one path that did change gets a redirect. Ghost puts tags at
/tag/<name>/; Hugo uses/tags/<name>/. That is the only structural difference, and a single 301 rule on Cloudflare Pages forwards the old shape to the new one.
That is the whole SEO story. Because the migration tool treats the slug as something to preserve exactly rather than regenerate, the new site is URL-for-URL compatible with the old one, and the move was invisible to anyone reading or linking to it.
how i publish now#
With the old blog gone, I needed a way to actually publish. The site is just markdown files in a Git repository, so in the simplest case publishing a post is one move: write the markdown, commit, push. I can do that from VS Code, from the terminal, from any editor, on any machine. No login, no admin panel.
Before I settled on that, I did look at whether some existing tool could smooth it out. There is a whole category of git-based CMSs now, editors that read and write your content straight in a GitHub repo, so you get a writing interface without giving up plain files. I tried a few of them.
None did exactly what I wanted, and each one came with something I hadn’t asked for: an account to sign into, a config schema to keep in sync, or yet another editing UI to learn. After two years of living inside Ghost’s admin panel, signing up for one more dashboard was the exact thing I was trying to get away from. So I dropped the idea of a CMS altogether and built my own small tool instead.
It is an Obsidian plugin I call Smithy, and I kept its job deliberately tiny. It does three things, and nothing else:
- Publish over git. It pushes the finished post to the blog’s GitHub repo for me. It talks to GitHub through its API rather than shelling out to Git, so it behaves the same on my phone as on my laptop.
- Auto-upload assets. Any image in the post is uploaded to my S3-compatible storage (Cloudflare R2) on publish, and the links are rewritten to point at it. The same bucket the migration tool filled.
- Keep the content in the repo. The markdown stays as plain files inside the GitHub repo. Nothing lives in a database or a hosted service I don’t own.
That is the entire plugin. I write a post as a normal Obsidian note, run one command, and those three things happen. It needs a GitHub token and my R2 keys entered once, and after that publishing is a single command. The remote-writing problem that pushed me off Ghost in the first place is simply gone: I can draft and publish from my phone if I feel like it.
Here is how the whole thing fits together once a post is ready to go out:
flowchart LR
subgraph author["write anywhere"]
direction TB
obs["Obsidian + Smithy"]
phone["iPhone + Smithy"]
code["VS Code / terminal"]
end
subgraph repo["GitHub repo"]
direction TB
md["Markdown + frontmatter"]
end
r2[("Cloudflare R2<br/>images & assets")]
subgraph cf["Cloudflare Pages"]
direction TB
build["Hugo build<br/>+ Pagefind index"] --> edge["CDN edge cache"]
end
reader(["reader"])
obs -->|"GitHub API"| md
phone -->|"GitHub API"| md
code -->|"git push"| md
obs -.->|"S3 API upload"| r2
phone -.->|"S3 API upload"| r2
md ==>|"on push, auto-build"| build
r2 --> edge
edge ==> readerIt’s here if you want to try it: Smithy .
Is any of this as instant as clicking “Publish” in a CMS? Not quite. There is a little friction. But for two or three posts a month it is nothing, and in return I get no servers, no database, no lock-in, and content that lives as plain files I will still be able to open years from now.
what changed, in practice#
The honest summary is that there is no infrastructure left to run. The site is static files on Cloudflare’s edge, so uptime is their job, not mine. R2 holds the images for almost nothing. There is no server to patch, no database to back up, no security hardening, and nothing exposed to attack. The operations side of running a blog basically disappeared.
It is also just faster. Plain files on a CDN beat a Node app querying a database on every request. The site loads quicker than it ever did on Ghost, from more places.
what i gave up#
A static site isn’t free of trade-offs, and it would be dishonest to pretend otherwise. Moving to one means giving up a few things a CMS hands you for free:
- No built-in comments. A static page can’t run a comment system on its own. If you want one, you bolt on a third-party service. I don’t, so for me this was a feature to delete, but it is a real loss if a comment section matters to you.
- No dynamic anything. No server means no forms, no member logins, no per-visitor pages, no newsletter or paid membership built in. Every reader gets the same files. For me that is the whole point; for a different blog it would be a dealbreaker.
- No writing in the browser. Ghost let me open a tab and write from anywhere. Now I write in an editor and publish through Smithy or a git commit. That is friction I chose on purpose, but it is still friction.
- Search ships with the page. Pagefind builds the search index at build time, so it grows with the site and the reader downloads part of it to search. At my size that is nothing. A site with tens of thousands of pages would have to think harder about it.
None of these were hard to give up for a personal blog. But they are exactly why “just go static” is the right call for me and the wrong one for plenty of other people.
the part i keep relearning#
This is the lesson I keep coming back to as someone who does infrastructure for a living. Keep it simple, and don’t take on complexity just because you can handle it. I could run the Ghost stack. I did, for two years. Being able to manage something is not a reason to. For a personal blog, the right amount of infrastructure turned out to be almost none.
One last honest note. Blogs aren’t what they were. As AI gets better at answering the questions people used to type into a search box, fewer of them end up on posts like this one, and I expect my traffic to keep drifting down. That is fine. I’m not writing for the traffic. I write this for myself, as a place to keep the things I work out so I don’t have to work them out again, and as a kind of memory. If you got here and something helped, even better.
If you want to see what else I’m building, my projects are on GitHub , and my DMs are open. Say hi.