↓Skip to main content

How This Blog Is Served — Kubernetes, Gateway API, and a Full GitOps Pipeline

·8 mins
Kubernetes Homelab DevOps GitOps Kubernetes Cilium Gateway-Api Argocd Argo-Rollouts Argo-Events Argo-Workflows Cloudflare Caddy Hugo Gitops Ci-Cd Blue-Green Buildkit Kargo
Chrisando Wagner
Author
Chrisando Wagner
25+ years across the full estate — Microsoft/VMware, OpenStack/Red Hat, cloud-native Kubernetes. These days that’s reference architectures, FinOps governance, and regulatory guardrails other teams build against. When I’m not debating gateway API controllers, I’m automating something in my homelab.
72 - This article is part of a series.

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:

flowchart TD Browser["🌐 Browser: cw.wagner.org.za"] CF["☁️ Cloudflare Edge Proxy"] GW["🔀 Cilium Gateway — TLS termination"] Active["⚙️ blog-active Service :80"] Pods["📦 Blog Pod — Caddy :8080"] Browser -->|"HTTPS"| CF CF -->|"Proxied HTTPS"| GW GW -->|"HTTPRoute: cw.wagner.org.za"| Active Active -->|"ClusterIP"| Pods

The stack in brief:

LayerTechnology
Static site generatorHugo + Blowfish theme
Web serverCaddy 2 (Alpine)
Container runtimek3s (Kubernetes)
NetworkingCilium CNI + Gateway API
TLScert-manager + Let’s Encrypt
DNS + proxyCloudflare
GitOpsArgoCD
DeploymentsArgo Rollouts (blue-green)
CI/CDGitea 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.

graph LR subgraph kube-system GW["🔀 Gateway: cilium-gateway — port 443 HTTPS"] end subgraph blog namespace R1["🟢 HTTPRoute: blog — cw.wagner.org.za → blog-active:80"] R2["👁️ HTTPRoute: blog-preview — cw-preview.apps.wagner.org.za → blog-preview:80"] end subgraph argo namespace R3["🔔 HTTPRoute: blog-webhook — blog-webhook.apps.wagner.org.za/push → eventsource:12000"] end GW --> R1 GW --> R2 GW --> R3

Three HTTPRoute resources hang off the same Gateway:

  • blog — production traffic to cw.wagner.org.za
  • blog-preview — staging traffic to cw-preview.apps.wagner.org.za (used during deployments)
  • blog-webhook — receives Gitea push events at blog-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.

graph TD subgraph blog namespace RS_Blue["🔵 ReplicaSet Blue — live version"] RS_Green["🟢 ReplicaSet Green — new version"] SvcActive["⚙️ Service: blog-active — selector: Blue"] SvcPreview["👁️ Service: blog-preview — selector: Green"] end subgraph Routing RouteActive["🌐 HTTPRoute: blog — cw.wagner.org.za"] RoutePreview["🔬 HTTPRoute: blog-preview — cw-preview.apps.wagner.org.za"] end RouteActive --> SvcActive --> RS_Blue RoutePreview --> SvcPreview --> RS_Green

When a new image is deployed:

  1. Argo Rollouts creates the green replica set with the new image
  2. blog-preview service switches its selector to green pods
  3. cw-preview.apps.wagner.org.za now serves the new version
  4. The CI pipeline runs E2E and load tests against the preview URL
  5. If tests pass, a manual promotion switches blog-active to green
  6. 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.

sequenceDiagram participant GA as 🤖 Gitea Actions participant WH as 🔔 EventSource HTTP :12000 participant NATS as 📨 NATS Bus participant Sensor as 👂 Events Sensor participant WF as ⚙️ Argo Workflow GA->>WH: POST /push (HMAC signed) WH->>WH: Validate X-Gitea-Signature WH->>NATS: Publish event NATS->>Sensor: Event received Sensor->>Sensor: Filter: refs/heads/main only Sensor->>WF: Submit workflow with 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.

flowchart TD A["📥 1 - clone-and-build: git clone + hugo --minify"] B["🐳 2a - buildkit-build: multi-arch build + push :tag"] C["🔍 2b - sonar-scan: SonarQube quality gate"] D["🏷️ 3a - tag-latest: crane copy :tag → :latest"] E["🛡️ 3b - trivy-scan: CVE scan, fail on CRITICAL"] A --> B & C B --> D & E

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 buildkitd daemon and builds linux/amd64 and linux/arm64 in one pass, pushing an OCI manifest list as :tag. No QEMU needed — the Dockerfile uses FROM --platform=$BUILDPLATFORM for 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 copy re-tags the versioned manifest list as :latest — no rebuild, just a manifest pointer update.
  • Trivy scans the linux/amd64 slice 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:

sequenceDiagram participant Dev as 👨‍💻 Developer participant Gitea as 🦊 Gitea participant AE as 🔔 Argo Events participant AWF as ⚙️ Argo Workflows participant Reg as 📦 Gitea Registry participant Kargo as 🚢 Kargo participant ARCD as 🔁 ArgoCD participant AR as 🔄 Argo Rollouts participant GW as 🔀 Cilium Gateway participant Browser as 🌐 Browser Dev->>Gitea: git push main Gitea->>AE: POST webhook (HMAC signed) AE->>AWF: Submit blog-build workflow AWF->>AWF: 1. Hugo build AWF->>Reg: 2. BuildKit: push multi-arch :tag AWF->>AWF: 3. SonarQube + Trivy checks AWF->>Reg: 4. crane: tag :latest Kargo->>Reg: Warehouse detects new :tag Kargo->>Gitea: Commit updated kustomization.yaml ARCD->>Gitea: Detects commit, syncs cluster ARCD->>AR: Apply updated Rollout image AR->>AR: Create green preview replica set Kargo->>AR: AnalysisTemplate: e2e smoke tests Note over Kargo,AR: Tests pass → human promotes in Kargo UI Dev->>AR: kubectl argo rollouts promote blog AR->>AR: Switch blog-active → green pods AR->>AR: Scale down blue pods (60s delay) Browser->>GW: HTTPS request cw.wagner.org.za GW->>AR: HTTPRoute → blog-active service AR->>Browser: Serve new Hugo site via Caddy

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:

  1. Cloudflare — edge proxy, DDoS protection, TLS (DNS-01 via cert-manager)
  2. Cilium Gateway API — routes by hostname to the correct Service
  3. Argo Rollouts — blog-active Service points to the live replica set
  4. Caddy — serves pre-built Hugo static files with aggressive caching
  5. 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.

72 - This article is part of a series.