This took longer than writing would have
Why this blog has a Docker Swarm cluster behind it.
Most new blogs open with an apology. I’m skipping it.
I spent a weekend setting this up. What I ended up with: a GitHub repo, a Cloudflare tunnel, a Docker Swarm cluster, and a git-sync sidecar running on CephFS. For a blog with one post.
Why this stack
A dozen static site generators can do the job. I picked this combination because:
- Astro. Ships zero JavaScript by default, so the page you’re reading is mostly HTML and CSS. Where I want interactivity, I can drop in React (or Vue, or Svelte) and pay for the JS only on the pages that need it.
- MDX with React. Markdown for writing, JSX for when an idea doesn’t fit in prose. You’ll see a couple further down.
- GitHub Pages. Free. SSL is handled for me. The site is versioned alongside the writing. There’s no “platform fee” for a personal blog with one author.
Everything else follows from those three.
Themes I tried before this one
I went through four attempts at agreeing with myself on what “clean modern dev blog” meant:
- The original. Typography-focused, Vercel-adjacent. I thought it was too plain.
- An editorial theme I’d called Systems Journal. Warm cream paper, oxblood accent, Fraunces display, asterism dividers. Gorgeous. Wrong site.
- Linear-adjacent with indigo gradients. Closer, but the accent came out too purple. I know.
- This one. Geist Variable, a blue that’s actually blue, a soft aurora behind the hero, and a Pagefind search you can open with ⌘ K .
The blue:
I got to each iteration by staring at the previous one for thirty seconds and deciding it was plain. If you see a fifth theme next month, you’ll know what happened.
The bad parts
A few stretches of this build were funny to look back on:
- I ran
set -a && source credentials.envto load some config. One value had a literal!in it. Bash interpreted half of it as a shell command, printed the rest into my terminal, then gave up. - A GitHub Pages TLS cert sat in
authorization_createdfor 90 minutes. I cycled the custom domain three times, blamed Let’s Encrypt, blamed.toolsas a TLD, blamed a real GitHub outage that was happening at the same time, and fixed it by deleting the Pages config and starting over.
If a Let’s Encrypt validation gets stuck on you, leave it alone for an hour. Cycling repeatedly trips a rate limiter and makes the cert even slower to issue.
The moment I went too far
I wanted to preview drafts remotely. The right answer was “run astro dev on your laptop.” What I built instead:
- One private GitHub repo holds the source.
- A public GitHub repo holds the built HTML, pushed from CI with a fine-grained PAT.
- A Docker Swarm stack on my homelab runs
astro devagainst a CephFS bind mount. - A sidecar clones the private repo with a read-only SSH deploy key and runs
git fetch + resetevery three minutes. - An ingress rule on my Cloudflare tunnel points a preview subdomain at Traefik. A Cloudflare Access app sits in front of that, so only I can see the preview. I can’t see it either without signing in to my self-hosted SSO first.
Twelve minutes into that, I learned that Vite silently 403s any host not in server.allowedHosts. The error page asks you to read vite.config.js. I was not reading vite.config.js.
Switching which branch the preview tracks is now one environment variable:
environment: SYNC_BRANCH: "preview" SYNC_INTERVAL: "180"Canary deployment is not yet implemented.
What the site is
- Astro, deployed on push to
mainin the private repo. The public repo receives onedeploy <sha>commit per push, and nothing else. - Comments and reactions via Giscus, backed by GitHub Discussions. The “likes” are the 👍 / ❤️ / 🎉 row at the top of each thread. Click one if you feel like it.
- Search is Pagefind, built into the deploy pipeline, opened with ⌘ K .
- The theme follows your OS preference with a manual override that survives reloads. The Giscus iframe re-themes to match via postMessage.
Because MDX is JSX underneath, I can embed a real React component in any post. The thing below is one. It’s a button you can tap up to 50 times. Each tap spawns a small “+1” that floats away. There’s no server and no counter. It resets when you reload.
Same pattern for anything else: import at the top of the file, drop into the prose.
What’s next
More posts. Fewer posts about the blog.