charlietools
5 min read

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:

  1. The original. Typography-focused, Vercel-adjacent. I thought it was too plain.
  2. An editorial theme I’d called Systems Journal. Warm cream paper, oxblood accent, Fraunces display, asterism dividers. Gorgeous. Wrong site.
  3. Linear-adjacent with indigo gradients. Closer, but the accent came out too purple. I know.
  4. 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.env to 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_created for 90 minutes. I cycled the custom domain three times, blamed Let’s Encrypt, blamed .tools as 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 dev against a CephFS bind mount.
  • A sidecar clones the private repo with a read-only SSH deploy key and runs git fetch + reset every 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 main in the private repo. The public repo receives one deploy <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.

tap to applaud

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.

Comments & reactions