New blog stack: what worked, what didn't
I shut down my VPS. After years of paying to run WordPress on a server I managed myself, Partially Peaceful now runs as a static site on Cloudflare Pages. Here's what I built and what I'd do differently.
The stack
Ghost handles writing and publishing. It runs in a Docker container on a private machine – reachable only over Tailscale, my personal VPN mesh. No public Ghost URL exists. I post from my phone the same way I always did; the Ghost mobile interface is solid.
Cloudflare R2 holds all media. My old WordPress posts are mostly photos, so I needed storage independent of any server. R2 is Cloudflare's S3-compatible object storage. A Ghost storage adapter routes uploads there automatically; media serves from media.partiallypeaceful.com. Ghost URLs get rewritten at fetch time so the built site always points to the right domain.
Astro builds the static site. When I publish in Ghost, a webhook fires to a custom handler, which sends a repository_dispatch event to the GitHub repository. A GitHub Actions workflow connects to the Tailscale network, fetches content from Ghost's Content API using a key stored as a GitHub secret, runs the Astro build, and deploys to Cloudflare Pages via Wrangler.
The build covers: paginated post index, individual post pages, tag archives, /archive, a /now page from a Ghost page by slug, and main plus per-tag RSS feeds – all static.
Styling: Tailwind CSS v4, Nord-inspired palette, fluid typography with clamp(), JetBrains Mono, client-side dark/light toggle via localStorage.
The Ghost container never touches the public internet. Media lives in a bucket. The site is static. Hosting costs nothing.
What worked well
Getting off the VPS entirely. Cloudflare's edge is faster than a single VPS region for most of my traffic, and I no longer think about server upkeep.
Astro as the build layer. Static output, clean component model, straightforward integration with Ghost's Content API. Exactly what a photo-heavy microblog needs.
Tailscale for Ghost access. Keeping Ghost completely private while still letting GitHub Actions reach it during builds was the cleanest part of the whole architecture.
What didn't work well
R2 adapter setup. This took too many attempts. Available Ghost adapters for R2 are scattered and variably maintained; configuration options aren't documented in one place. It works well now, but it was the roughest part of the build and Claude was a big help.
The webhook chain. Ghost fires at a custom handler I wrote, which translates it into a repository_dispatch event for GitHub. That middleman is an extra moving piece I'd rather not maintain. Ghost's native GitHub integration should be able to trigger the Action directly – that's worth revisiting.
What's next
The aggregation layer I want eventually is posts from Mastodon, GitHub, and Goodreads all rolling into a single feed here. That's future work. For anyone considering a similar setup, the bones are solid – the pain is in the R2 adapter documentation and the webhook indirection. Both are solvable.