Setting Up The GitOps Pipeline
Post 0: The Automated Blog Setup
A frictionless GitOps pipeline: push to
main, GitHub builds alinux/arm64container, pushes it to GHCR, and webhooks a Raspberry Pi via a Cloudflare Tunnel to swap the live container in seconds.
The Problem This Solves
Writing and maintaining a blog involves a lot of small edits, deploys, and follow-up fixes. If publishing an update required manually SSH-ing into the Raspberry Pi, pulling images, and restarting containers every time a typo was fixed, the friction would slow publishing down. A set-and-forget GitOps pipeline keeps the focus on writing and documentation.
Architecture Overview
The blog itself is its own architectural implementation — a complete GitOps lifecycle in miniature.
- Source of Truth: The GitHub repository stores all Astro source code, Docker configuration, and architecture manifests.
- Continuous Integration: GitHub Actions triggers on every
pushto themainbranch. - Cross-Compilation: QEMU + Docker Buildx cross-compiles the Astro static site into a minimal
linux/arm64Nginx container image. - Registry: The built image is pushed to GitHub Container Registry (GHCR) under
ghcr.io/glitchyi/blog:latest. - Webhook Trigger: The Action fires a
POSTrequest to a Cloudflare Tunnel-secured endpoint, instantly waking Watchtower. - Continuous Deployment: Watchtower authenticates against GHCR, detects the new image digest, kills the old container, and starts the new one with
WATCHTOWER_STOP_TIMEOUT=0s.
What I’m Simulating
A lightweight, single-node GitOps lifecycle without the overhead of full ArgoCD or Flux. It trades sophistication for speed and a minimal memory footprint — pragmatic for a solo home-lab project running on a Raspberry Pi 5.
Components
Dockerfile— Multi-stage build: Node 22 Alpine builds the Astro site, Nginx Alpine serves the static output.docker-compose.yml— Definesastro-app(port3002) andwatchtower(port3003)..github/workflows/deploy.yml— GitHub Action: checkout → QEMU → Buildx → GHCR push → Watchtower webhook.- Cloudflare Tunnel — Exposes
localhost:3003(Watchtower’s HTTP API) publicly and securely without opening firewall ports. .env— StoresWATCHTOWER_TOKENandCR_PATfor authentication. Never committed.
How to Run It Yourself
# On the Raspberry Pi:
mkdir -p ~/blog && cd ~/blog
curl -sO https://raw.githubusercontent.com/Glitchyi/blog/main/docker-compose.yml
curl -s https://raw.githubusercontent.com/Glitchyi/blog/main/.env.example > .env
# Fill in WATCHTOWER_TOKEN and CR_PAT in .env, then:
echo $CR_PAT | docker login ghcr.io -u Glitchyi --password-stdin
docker compose up -d
What Actually Happened
Several real problems surfaced during setup:
Architecture mismatch. The first deploy crashed immediately with exec format error. A standard GitHub Actions runner builds amd64 images. The Raspberry Pi only runs arm64. Fixing this required adding docker/setup-qemu-action and docker/setup-buildx-action with platforms: linux/arm64 explicitly declared.
~/.docker/config.json was a directory. A previous botched docker login left config.json as a directory instead of a file. Watchtower silently mounted the broken directory, couldn’t parse credentials, and always returned scanned=1 updated=0. The fix was sudo rm -rf ~/.docker/config.json followed by a clean docker login.
Watchtower’s scope labels didn’t work. Several approaches to limiting Watchtower’s scope via WATCHTOWER_SCOPE and com.centurylinklabs.watchtower.enable=true labels failed silently with different versions. The working solution was to drop scoping entirely — on a single-purpose Pi running only the blog stack, monitoring all containers is the correct default.
Cloudflare cache served stale content. Even after Watchtower successfully swapped the container (confirmed via logs), the live site showed old content. The culprit was Cloudflare’s edge cache. A manual cache purge from the Cloudflare dashboard was required. Consider setting Cache Rules to Bypass for the blog tunnel hostname to prevent this.
The Frontend Evolution (Iterative AI Enhancements)
While the initial deployment focused on hardware automation, building an aesthetically rich and highly accessible UI required significant iteration. We evolved the initial layout into a custom, dark-themed, information-dense structural aesthetic.
Crucial custom implementations added post-launch include:
- Responsive Layout Architecture: Overhauled the
<main>container boundaries shrinkingpx-10paddings down topx-5on mobile interfaces, and implemented aggressiveflex-wrapand dynamic scaling on header typography (text-3xl sm:text-4xl) so massive architecture headlines do not abruptly wrap on older iPhones. - Dynamic Routing Slugs: Replaced the default Astro folder-routing logic (
[...id].astro) with a dynamic Zod schema loader, explicitly overriding slugs directly from the markdown frontmatter (e.g.slug: "monolithic") instead of inheriting clunky nested paths likeposts/01-monolithic/readme. - Advanced Markdown Injection: Stripped the standard
@tailwindcss/typographyformatting. We reconstructed the rawgithub-darkmarkdown theme by defining custom child selectors[&_pre_code]:bg-transparentacross code wrappers. This eliminated “double-layered highlighting” bugs and forced the underlying GitHub#0d1117<pre>rendering styling. - Dynamic Clipboard APIs: Injected raw client-side JavaScript via Astro components directly targeting all
<pre>bounds. This loops through code snippets, appends responsive SVGCopybuttons, morphs them to green checkboxes (#4ade80) on success, and interacts smoothly with the OS Clipboard without the necessity of bloated third-party Rehype React plugins.
Tradeoffs
| Strength | Weakness |
|---|---|
| Fully automated — zero manual deploys | No rollback: a bad build goes straight to production |
| Webhook-based: updates in seconds, not minutes | Watchtower requires Pi to have outbound GHCR access |
| Cloudflare Tunnel: no open firewall ports | Cloudflare cache can serve stale HTML after updates |
| Minimal RAM footprint on the Pi | Single point of failure — one node, no redundancy |
When to Use This (and When Not To)
This pattern is ideal for solo home-lab projects with a single compute target and no rollback requirements. It breaks down the moment you need staged rollouts, canary deployments, or multi-node synchronisation. At that point, a proper GitOps controller like ArgoCD or Flux becomes necessary.