I didn't replace Ghost because I was bored of a CMS. I replaced it because the blog I trusted as the public face of a solo stack was lying to me in two different directions at once: it recycled the same topics into slug variants until the catalog was garbage, and a "published" status in the admin still didn't guarantee a reader could fetch the post when Fly had a bad day.

The move was off Ghost-on-Fly onto Pyre — an owned content API, Postgres, Astro static build, atomic dist swap, Caddy on metal I control. What I got was not a prettier dashboard. What I got was a truth chain I can audit end to end. What I also got, hard, is the class of bug that only shows up after you think the hard part is over: schedule is not public.

Why Ghost stopped being the right shape

Ghost is fine software for a certain job. That job is "run a magazine-shaped site with a polished editor and a membership layer." My job became "publish measured posts from a content engine, on a cadence, without third-party runtime in the critical path, and without inventing a second copy of a topic because pagination lied."

The failure that forced the hand was not vibes. It was inventory. The pipeline that was supposed to avoid duplicates ran against an incomplete view of what was already live. Unpaginated dedupe left whole pages of published posts out of the comparison set. The result was not a few near-miss titles. It was a catalog that grew -2, -3, -4 slug tails for work that already existed. At one point the live site held on the order of a hundred near-identical recycled posts. That is not a content problem you fix with a better system prompt. That is a systems problem: the gate never saw the full published set, so the gate approved garbage.

Hosting on Fly compounded the trust problem. When the platform hiccuped, the blog 503'd. For a personal publication that is embarrassing. For a stack I was claiming as proof of independent infrastructure, it was disqualifying. The public site is not allowed to share fate with a PaaS control plane I do not operate.

So the requirements flipped:

  • Own the database and the API.
  • Compile the public site to static HTML.
  • Serve from a host I already run for other production work.
  • Make "already published" a query that can page through everything, not a partial first page that pretends to be the world.

Pyre is the shape that fits those constraints. Ghost is not.

What Pyre actually is in this setup

Pyre is not "a Ghost clone with a different logo." In the configuration that serves thesolostack.dev today, the pieces are deliberate:

  • Postgres holds content, status, schedule times, and publication settings.
  • A content API accepts create + schedule operations and later promotes scheduledpublished when the clock passes.
  • An Astro theme under sites/thesolostack-dev builds the reader-facing site.
  • pyre-build.sh stages a new dist, then atomically swaps it into the live path Caddy already serves.
  • Caddy serves the built dist/ from owned edge metal — not on the laptop, not on a Mac Mini that also has other jobs, not on Fly.

The control plane for writing stays on my Mac. Public traffic never targets the MacBook. That split is load-bearing. The blog's edge role lives with the other public services. The agent desk stays private.

From the content engine's point of view, the ship path is narrow on purpose:

draft → verify-post.py → pyre_publisher.py --execute
  → Postgres scheduled/published
  → rebuild (auto or manual) → dist swap
  → https://thesolostack.dev + /rss.xml

Everything before the first network write is local. The quality gate is a hard validator, not a suggestion. The publisher defaults to dry-run. --execute is explicit. That is the opposite of a LaunchAgent that freelances overnight.

The scar that matters: schedule ≠ public

Here is the thing nobody's CMS marketing page will tell you, because it only hurts after you own the full path.

Pyre can mark a post status=published in Postgres and the public site can still be yesterday's HTML. Promotion mutates the database. Readers do not read the database. Readers hit Caddy, which serves whatever is currently in dist/. If the rebuild that is supposed to fire on promotion fails, or stages a dist.new and never swaps, the API and the internet disagree.

I hit that class twice in the first week of real ops after the flip.

First failure mode: permissions. Auto-rebuild ran as a user that could not write shared node_modules under the sites root. Astro never finished a usable build. Postgres said published. RSS still showed the previous last post. Health scripts that only trusted an API would have declared victory. Health scripts that fetch https://thesolostack.dev/rss.xml correctly called the site dark.

Second failure mode: cwd and find. Even after ownership was fixed, a manual rebuild as the site's service user could complete Astro successfully, leave a full dist.new with good HTML, and still abort before the atomic swap — because a find in the script ran under set -e with an unreadable cwd (/root when invoked via the wrong sudo context). The build looked green. The live dist mtime did not move. Public truth stayed stale.

The fix was boring and specific: run as the service user, set the sites root and the content key, cd /tmp (or into the site dir before any find), require a minimum page count so a stub build cannot overwrite a real site, then swap. When that path prints the OK line and the live dist updates, RSS moves. Until then, you have a private success and a public failure.

So the operational rule is simple enough to put on a sticky note:

Public truth is RSS pubDate, HTTP 200 on the post URL, and dist mtime — never API status alone.

That rule is why the stall watchdog exists. It is read-only. It alerts when the feed has been silent longer than cadence. It does not care that someone clicked publish in an admin UI three days ago.

What I kept from the Ghost era

I did not throw away the writing system just because the runtime changed.

I kept the voice: conversational authority, cold open, verdict early, H2 sections, lived numbers, honest gotchas, forward-looking close. The content moat is still measurement, not recycled tool roundups.

I kept a quality gate in front of any write API. Structure, voice, duplicate detection against published content, no stub copy or lorem leaks. The gate's job is to be mean before readers are. After the dupe era, "ship faster" is not the missing skill.

I kept cadence as a human decision, not an unattended night job. There is a content-cadence LaunchAgent shape in the tree. It ships disabled and dry-run. Research briefs can land in an inbox daily. Publishing a research digest straight to the homepage is how you re-create the fabrication class that already burned another publication. Tier C stays inbox-only. Tier A lived flagship posts never auto-publish.

I kept the 14:00 UTC slot as the historical publish hour for this blog. The calendar habit is useful. The calendar is not allowed to outrank the gate.

What broke that I will not paper over

Inventory rot. A file of "existing titles" with 23 entries against 65 live posts is worse than no file. Dedup against a stale inventory is theater: it prints clean and ships duplicates. Before any generate path runs again, existing inventory has to be rebuilt from sitemap or a fully paginated API list. I pruned a 40-topic backlog that was 100% already live and reseeding only after that reconcile. If you skip the reconcile, you are choosing the -2 slug future.

Ops surface lag. Flipping DNS and serving static HTML is not the same as rewriting health checks, boot cards, and "how do I rebuild" notes. Ghost-shaped scripts kept failing after the site was already on Pyre. Alive on the new platform and operable by the next agent are different deliverables. I rewrote the health path to read public RSS. I wrote an ops reference that names the real build script path, the real dist path, and the real service identity that can complete a rebuild.

Subscribe honesty. The site does not get a newsletter CTA until confirm-mail works on the mail host. A subscribe form that cannot complete is a lie with a button. That stays deferred on purpose.

Excerpt encoding. CMS storage happily keeps ' and double-encoded entities in excerpt fields. Static templates that escape again produce garbage on the homepage. Decode before you re-escape. Fix once, check every excerpt source, not only html.

The rebuild command I actually trust

When promotion happens and public does not move, this is the path that has been proven on the production host — not as root improvisation, as the service identity:

# shape, not production identities — adapt the names to your host
ssh <edge-host>
# the content key comes from env or a secrets manager, never a SELECT pasted from a blog post
cd /tmp   # avoid an unreadable cwd for find(1)
sudo -u <site-user> env \
  PYRE_SITES_ROOT=<sites-root> \
  PYRE_API_URL=http://127.0.0.1:<port>/api/v1 \
  PYRE_CONTENT_KEY="$CK" \
  PYRE_MIN_PAGES=100 \
  bash <build-script> <publication-slug>

Requirements that are not optional: site directory owned by the service user, shared node_modules writable by that user, script cds into the site before find/swap, min-pages guard so a thin build cannot replace a thick one. If you see Astro "Complete!" and a fat dist.new while live dist is old, you did not finish. You staged.

I would rather run that command and watch RSS than "trust the scheduler" without a public check. Auto-rebuild is the goal. Observed public promotion is the acceptance test.

What I'd do again

I would still leave Ghost-on-Fly. Owning the API and the static edge is the correct shape for a solo builder who already runs metal and already treats public tunnels as a policy decision, not a convenience default.

I would still put a hard gate in front of every publish. The cost of a blocked draft is minutes. The cost of a duplicate era is weeks of cleanup and a credibility hole in the archive.

I would still insist that health checks read the public feed. Internal status is a claim. The URL is the product.

I would still refuse unattended daily auto-publish of research digests. The moat is lived measurement — thermal soaks, acceptance trials, rebuild scars with timestamps — not a content firehose that sounds like everyone else by Thursday.

What I would do earlier next time: prove the rebuild identity and the swap path before the first real schedule-on-promotion, and make "curl the slug twice + RSS item" a non-negotiable close for every ship. Database green is not a ship. Public green is a ship.

The Ghost era gave me a polished editor and a runtime I did not control. The Pyre era gives me a narrower editor path, a stricter gate, and a stack whose failure modes I can name. That trade is not close. The work now is not "migrate again." The work is to keep the truth chain honest every time a post crosses from draft to the open internet — verify, schedule, rebuild, then read the site like a stranger would.