This blog runs on a self-hosted k3s cluster, served through a Cloudflare proxy, routed via the Kubernetes Gateway API, and deployed using a fully automated GitOps pipeline. This post walks through exactly how a git push to main makes it to your browser — from webhook to blue-green promotion.
Source: git.wagner.org.za/cw/blog
The Full Picture#
Before diving into each layer, here is the end-to-end flow:
The stack in brief:
| Layer | Technology |
|---|---|
| Static site generator | Hugo + Blowfish theme |
| Web server | Caddy 2 (Alpine) |
| Container runtime | k3s (Kubernetes) |
| Networking | Cilium CNI + Gateway API |
| TLS | cert-manager + Let’s Encrypt |
| DNS + proxy | Cloudflare |
| GitOps | ArgoCD |
| Deployments | Argo Rollouts (blue-green) |
| CI/CD | Gitea Actions → Argo Events → Argo Workflows |
Layer 1: Cloudflare#
The domain cw.wagner.org.za is managed in Cloudflare. The A record points to the cluster’s public IP (or the Gateway’s LoadBalancer IP exposed via the router), and the Cloudflare proxy is enabled — meaning all traffic passes through Cloudflare’s edge before reaching the cluster.
This gives free DDoS protection, caching at the edge, and hides the origin IP. Cert-manager uses Cloudflare’s DNS API for Let’s Encrypt DNS-01 challenges to issue the TLS certificate for *.apps.wagner.org.za and *.wagner.org.za.
Layer 2: Cilium Gateway API#
Inside the cluster, routing uses the Kubernetes Gateway API — a modern replacement for Ingress resources. The Gateway is provisioned by Cilium and gets a static LoadBalancer IP from Cilium’s LB-IPAM pool.
Three HTTPRoute resources hang off the same Gateway:
blog— production traffic tocw.wagner.org.zablog-preview— staging traffic tocw-preview.apps.wagner.org.za(used during deployments)blog-webhook— receives Gitea push events atblog-webhook.apps.wagner.org.za/push
All three share a single LoadBalancer IP; the Gateway routes by hostname.
Layer 3: Argo Rollouts — Blue-Green Deployment#
The blog does not use a standard Kubernetes Deployment. It uses an Argo Rollout with a blue-green strategy, which means two replica sets exist simultaneously during a release.
When a new image is deployed:
- Argo Rollouts creates the green replica set with the new image
blog-previewservice switches its selector to green podscw-preview.apps.wagner.org.zanow serves the new version- The CI pipeline runs E2E and load tests against the preview URL
- If tests pass, a manual promotion switches
blog-activeto green - Blue pods are kept for 60 seconds then scaled down
autoPromotionEnabled: false means the switch to production requires an explicit kubectl argo rollouts promote blog -n blog. Nothing ships to live traffic without a human (or a passing test suite) signing off.
Layer 4: The Web Server — Caddy#
Each pod runs a two-stage Docker image:
Stage 1 (build) Hugo extended → hugo --minify → public/
Stage 2 (serve) Caddy 2 Alpine → serves public/ on :8080
Caddy is configured with aggressive caching headers:
- Static assets (CSS, JS, fonts, images):
Cache-Control: max-age=31536000, immutable - HTML pages and RSS feeds:
Cache-Control: max-age=300 - Security headers:
X-Frame-Options,X-Content-Type-Options,Referrer-Policy - Prometheus metrics endpoint on
:2019
Layer 5: The CI/CD Pipeline#
This is where things get interesting. A git push to main kicks off a multi-stage pipeline that builds, tests, and deploys the new version — entirely inside the cluster.
Step 1: Gitea Actions fires the starter pistol#
A Gitea Actions workflow watches the main branch. When a push lands, it constructs a JSON payload with the commit SHA and POSTs it to https://blog-webhook.apps.wagner.org.za/push.
# .gitea/workflows/build.yaml (simplified)
on:
push:
branches: [main]
steps:
- name: Trigger Argo Events
run: |
PAYLOAD='{"ref":"${{ github.ref }}","sha":"${{ github.sha }}"}'
curl -f -X POST https://blog-webhook.apps.wagner.org.za/push \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
Step 2: Argo Events receives and validates#
An Argo Events EventSource runs an HTTP server inside the cluster on port 12000. It validates the X-Gitea-Signature HMAC header and puts valid events onto a NATS event bus.
An Argo Events Sensor listens on that bus and applies filters:
- Only triggers on
refs/heads/main - Ignores branch deletions
When the filter passes, it creates an Argo Workflow from the blog-build WorkflowTemplate, passing in the commit SHA.
Step 3: Argo Workflows — the actual build#
The blog-build WorkflowTemplate runs in three step groups. Steps within the same group run in parallel.
Step 1 — clone-and-build: Clones the repo with submodules onto a shared DirectPV PVC, runs hugo --minify, and writes public/ to the workspace.
Step 2 — parallel build + scan:
- A single BuildKit
step connects to a persistent
buildkitddaemon and buildslinux/amd64andlinux/arm64in one pass, pushing an OCI manifest list as:tag. No QEMU needed — the Dockerfile usesFROM --platform=$BUILDPLATFORMfor the Hugo build stage so it always runs on the host arch. - SonarQube scans the source code in parallel and blocks if the quality gate fails.
- Image tag format:
v<YYYY>.<MMDDhhmm>(e.g.v2026.06231024)
Step 3 — tag + security scan:
crane copyre-tags the versioned manifest list as:latest— no rebuild, just a manifest pointer update.- Trivy scans the
linux/amd64slice of the manifest for CVEs — fails the pipeline on any CRITICAL finding.
After the workflow completes, Kargo takes over promotion. The Warehouse detects the new tag in the registry, creates a Freight object, and auto-promotes it to the preview Stage. Kargo commits the updated kustomization.yaml to Git, ArgoCD syncs the change, and the Argo Rollout enters blue-green preview state. An AnalysisTemplate runs e2e smoke tests against cw-preview.apps.wagner.org.za. If tests pass, a human promotes in the Kargo UI (preview → production), then runs kubectl argo rollouts promote blog -n blog to cut live traffic to the new image.
If the e2e tests fail, the Rollout stays in preview state — live traffic on cw.wagner.org.za is completely unaffected.
Layer 6: ArgoCD GitOps#
ArgoCD watches the deployment/blog/ directory in this repository. Any change to a manifest — a new image tag, an updated HTTPRoute, a changed resource limit — is automatically synced to the cluster within minutes.
Source: git.wagner.org.za/cw/blog.git (path: deployment/blog)
Target: https://kubernetes.default.svc (namespace: blog)
Policy: automated, prune: true, selfHeal: true
This means infrastructure drift is automatically corrected, and the Git repository is the single source of truth for the cluster state.
Complete End-to-End Flow#
Putting it all together — from git push to your browser loading the new page:
Image Mirroring#
One subtle detail: every external image used in the pipeline is mirrored to the private Gitea registry. A scripts/mirror-images.sh script pulls from Docker Hub / GCR and pushes to git.wagner.org.za/homelab/:
alpine:3.21 → homelab/alpine:3.21
caddy:2-alpine → homelab/caddy:2-alpine
moby/buildkit → homelab/buildkit
crane → homelab/crane
sonar-scanner-cli → homelab/sonar-scanner-cli
aquasec/trivy → homelab/trivy
This means the pipeline has no external dependencies at runtime — everything is pulled from the local registry.
Summary#
The routing path for a request to this blog spans five distinct systems:
- Cloudflare — edge proxy, DDoS protection, TLS (DNS-01 via cert-manager)
- Cilium Gateway API — routes by hostname to the correct Service
- Argo Rollouts —
blog-activeService points to the live replica set - Caddy — serves pre-built Hugo static files with aggressive caching
- ArgoCD — keeps all of the above in sync with Git
And a git push triggers a fully automated pipeline: BuildKit builds a multi-arch image, SonarQube and Trivy run quality gates, Kargo promotes through preview and production stages with e2e verification, and Argo Rollouts performs the blue-green switch — all before live traffic is touched.
The whole thing runs on a four-node k3s cluster in the homelab — no managed Kubernetes, no cloud provider, no external build runners.
