Infrastructure / Blog

How the blog works

Astro static site → Docker image → Kubernetes → Cloudflare Tunnel → whenkat.com.


The full pipeline

Everything is automated from a git push. No manual build steps, no SSH-ing into servers.

Write Markdown blog/src/content/blog/
Push GitHub proxmox-opentofu branch
Build Actions Astro → nginx image
Push ghcr.io private registry
Deploy ArgoCD syncs manifests
Serve Live whenkat.com

The GitHub Actions workflow watches blog/** and only triggers on changes there. A change to an unrelated manifest won't rebuild the blog image.


Technology layers

Astro Static site generator Compiles Markdown posts into pre-built HTML/CSS/JS at build time. No runtime rendering.
nginx File server (pod) Serves the built static files inside the blog pod. Lightweight — no Node, no app server.
NGF NGINX Gateway Fabric Routes whenkat.com traffic to the blog service via an HTTPRoute.
cloudflared Tunnel daemon Connects the cluster to Cloudflare's edge. No open ports on the home router.
Cloudflare Edge / CDN TLS termination, DDoS protection, caching. Your home IP is never exposed.

Writing a new post

Create a Markdown file in blog/src/content/blog/ with this frontmatter:

blog/src/content/blog/my-new-post.md
---
title: 'My Post Title'
description: 'One sentence shown in link previews.'
pubDate: 'Jan 15 2026'
series: 'AI Server Build'   # groups posts on the index page
seriesOrder: 3              # position within the series
---

Your content here in Markdown...

The series and seriesOrder fields control how the post appears on the landing page. Posts in the same series are grouped together and sorted by seriesOrder. Omit both fields for a standalone post — it lands in "Uncategorized".

To publish: git add the file, commit, and git push. GitHub Actions builds the image, pushes to ghcr.io, and ArgoCD rolls it out within a couple of minutes.

What the Docker image contains

The Dockerfile is a two-stage build. Node is used only at build time and is not present in the final image:

# Stage 1 — build (Node 22 Alpine)
npm ci && npm run build   # outputs to /app/dist

# Stage 2 — runtime (nginx Alpine)
# copies /app/dist → /usr/share/nginx/html
# ~25MB total image, no Node, no npm

The image is private on ghcr.io. The cluster uses a docker-registry secret named ghcr-pull-secret in the blog namespace to pull it. Recreate it with:

make blog-pull-secret   # reads secrets/ghcr-pull-token.txt

Kubernetes resources

All managed by ArgoCD from gitops/manifests/blog/:

Namespace:   blog
Deployment:  blog (2 replicas, ghcr.io/pycaster/proxmox-k8s-blog:latest)
Service:     blog (ClusterIP, port 80)
HTTPRoute:   blog (whenkat.com + www.whenkat.com → blog:80)

The HTTPRoute lives in gitops/manifests/gateway/blog-route.yaml and references the homelab Gateway in the default namespace. NGF applies it and begins routing traffic as soon as the blog pods are ready.