Field notes

Running Ghost privately, publishing static

Ghost in a container on my Mac, a folder of HTML on Cloudflare Pages, and Kit for the weekly email.

I write on Ghost. I don't run Ghost in public.

That sounds like a contradiction. It's the cleanest setup I've landed on for a site that gets updated a few times a month.

Ghost is an excellent editor and a mediocre thing to leave facing the open internet. It's a long-running Node process with a database behind it, it wants patching, and it's a known target. For a one-person practice, that's a maintenance surface I have no interest in owning.

So the actual architecture is: Ghost lives in a Docker container on my Mac, reachable only from my own devices. When I publish, a static copy of the site gets built and pushed to Cloudflare Pages. The thing the world sees is a folder of HTML and images on a CDN. Nothing to exploit, nothing to patch, nothing to wake me up.

The pieces

Ghost, containerized, on localhost. Docker Compose runs Ghost and its MySQL database, with the theme folder mounted straight into the container so a template edit is one restart away.

Ghost bakes its url setting into every canonical tag, feed URL and internal link it renders. That matters more than it looks like it should. I left it at the local address and have the export rewrite it on the way out, then check the result for any address that slipped through. Get this wrong and your static site is full of links to localhost.

Admin lives at /ghost/ on the same local address, and my phone reaches it over Tailscale, a private network between my own devices. Nothing is forwarded to the internet. No reverse proxy with a public cert. No fail2ban rules to tune. To anyone who isn't me, the CMS may as well not exist.

A static export step. Two reasonable options, and they suit different temperaments.

The lazy one is a crawler. ghost-static-site-generator walks the running site, dumps a mirror, and rewrites the domain as it goes. It's a thin wrapper around wget with Ghost-specific defaults, and it works. You inherit whatever theme you've already built, routes.yaml collections included, without touching anything.

The one with more ceiling is a static site generator pulling from Ghost's Content API at build time — Astro or Eleventy, depending on how much JS you want in the output. Ghost becomes a headless content store and you own the rendering. More upfront work, but you stop fighting the theme layer the moment you want a layout Handlebars doesn't feel like giving you.

I went with a crawler, a small one of my own. It starts from the sitemap and the routes in routes.yaml, saves every page, image and stylesheet, writes RSS as .xml files, and adds Cloudflare's 404.html, _redirects and _headers. Then it refuses to finish if any file still points at the local Ghost or links to something the export doesn't contain. That last check is the reason it's mine: a mirror that's quietly missing a page looks fine until someone clicks.

Cloudflare Pages via direct upload. This is the part that surprises people. Pages is usually pitched as Git-connected: you push to a repo, it builds. But it also just takes a finished directory.

npx wrangler pages deploy ./dist --project-name=foundrytabor

That's the whole deploy. No build environment to configure, no secrets sitting in a CI provider, no repo full of generated HTML cluttering my git history. The build happens on a machine I control, and only the artifact travels.

A publish trigger: me. Ghost fires webhooks on post.published, and a small local listener could run export-then-deploy. I haven't bothered. Publishing is one command, and I publish once a week. The catch is scheduled posts: Ghost will happily publish one at 9 a.m. Tuesday, but the static site doesn't know until the export runs.

What you give up

This is where most write-ups about static Ghost get quiet. You lose the dynamic half of Ghost, and you should decide that's fine before you build any of it.

Members, subscriptions, and paid tiers are gone. All of that needs a live Ghost API the browser can reach. If paid newsletters are your business model, this architecture is wrong for you. Pay for Ghost(Pro) and go on with your life. I still wanted a weekly email, so the signup forms are plain HTML forms that post to Kit, which is free at this size and sends the email. No script, no widget, and Ghost never sees an address.

Search breaks by default. Ghost's built-in search fetches from the Content API at runtime, and statically exported, there's no API to fetch from. Either drop search or generate an index at build time and wire up something client-side like Pagefind. I dropped it. The export strips Ghost's search script so it doesn't try.

Comments are gone, for the same reason.

Preview links don't work for anyone but me, which is mildly annoying when you want a second pair of eyes on a draft.

And if my Mac is off, I can't publish. The live site doesn't care. It's sitting on Cloudflare's edge, entirely independent. But authoring stops until the machine is back. For a personal site, an hour of not being able to write is not an outage. For anything with an editorial calendar, it is. Back up the database and the images somewhere a backup tool can see them, too: Docker keeps them in volumes that Time Machine skips.

Why it's worth it anyway

The cost of the whole thing is domain renewal. No VPS, no managed CMS subscription, no Pages bill at this traffic, and no email bill until the list passes ten thousand.

The money isn't really the point, though. The point is that the public artifact and the authoring tool are completely decoupled. The site can't go down because of something I did to Ghost. Ghost can't get popped because of something the internet did to it. I get a genuinely good editor with a real content model — tags, collections, routing — and the internet gets a folder of files.

For a practice where I'm the entire ops team, that trade isn't close.

One field note a week, by email.

Real engagements, written up and anonymized. Unsubscribe anytime.