Mark Christian Klass
Case 03Public source

Homelab and GitOps

One Proxmox host, guests defined in Terraform, a two-node Kubernetes cluster that only changes through git, and monitoring that heals the boring failures before it pages me. Everything the other two cases run on.

Kubernetes
kubeadm, 2 nodes, Calico, MetalLB, Traefik, Longhorn, CloudNativePG
GitOps
Flux with image automation, sops-secrets-operator
CI
Self-hosted GitLab, Docker executor runners, container registry
Public access
Cloudflare Tunnel only; nothing port-forwarded
AI in this project
None at runtime. Built and operated with AI-assisted work; every change is reviewed and applied by me.

Architecture

Simplified homelab architecture A Proxmox host runs guests defined in Terraform: a two-node Kubernetes cluster, a self-hosted GitLab with CI and registry, a DNS resolver, a Cloudflare Tunnel connector and media containers. A NAS provides NFS storage and backup targets. Flux reconciles the cluster from git; CI pushes images to the registry; public HTTP enters only through the tunnel; alerts go to Telegram. PROXMOX HOST · guests defined in Terraform Kubernetes · 2 nodes Traefik · MetalLB · Longhorn · CNPG Prometheus · Grafana · Loki · Alertmanager n8n · pvewatch · Kopi Log · this site Flux reconciles from git GitLab · CI runners · registry git is the source of truth images tagged main-<ts>-<sha> DNS AdGuard Tunnel cloudflared Media, cameras, SSO, gateway LXCs and VMs, config in git, deployed by Ansible Always-on controller systemd watchdogs: detect, self-heal, export metrics Flow of a change push → CI gate → image → Flux picks newest tag → commits bump → rollout → probes and alerts confirm manual drift (kubectl scale, edit) is reverted within one reconcile NAS NFS for media and shared data backup targets · off-site copies to R2 Internet public HTTP only via the tunnel; admin via Tailscale Telegram Alertmanager and watchdog events, routed by n8n

Simplified on purpose: no hostnames, addresses or guest IDs. The real topology is in the repos as Terraform and manifests.

A change, end to end

  1. 1Edit. Change a manifest in the cluster repo. Run kubectl diff -k against the live cluster to see exactly what will move. Commit explicit paths, never git add -A, and push.
  2. 2Gate and build. The app's own CI runs its checks and builds an image tagged main-<timestamp>-<sha>. A bare SHA cannot be ordered; the timestamp is what "newest" means.
  3. 3Select and commit. Flux's image reflector scans the registry; the image policy selects the newest sortable tag; the automation controller writes it into the Deployment manifest and commits the bump to main under its own bot name. 80 such commits between 2026-06-21 and 2026-09-21, counted when this page was built.
  4. 4Roll out. Flux applies the new manifest. Helm-managed platform pieces are HelmRelease objects; app secrets are SopsSecret objects decrypted in-cluster, so the repo holds nothing in plain text.
  5. 5Confirm. Readiness probes gate the rollout. Alertmanager rules and app-level probes (the Kopi Log freshness probe, pvewatch's heartbeat) confirm the result and route anything wrong to Telegram. Out-of-band drift is reverted: a manual kubectl scale was undone within one reconcile, on purpose, to prove it.

The decision

A monitor must be simpler and more available than what it watches. NFS mounts that detach after a NAS reboot, container binds that go stale, a VPN container that wedges: these were first "monitored" by workflows inside the cluster, which shared fate with the thing they watched and failed silently for days. Detection and self-heal now run as systemd timers on an always-on, battery-backed controller with SSH to every host. They export a textfile metric per watchdog that Prometheus scrapes, and post an edge-triggered event to n8n, which owns notification, routing and the Telegram credential. Low-level detection never goes back into n8n.

Also decided: no CI kubectl set image steps, ever, because Flux owns the Deployments and reverts that drift (that exact fight once froze a site). Image policies live in the Flux namespace, because a cross-namespace policy marker is silently ignored. Terraform for every guest definition, so a rebuild after a power event is a plan and an apply, not a memory exercise.

Evidence

  • Public source
    homelab-k8s: Flux entry point, HelmReleases, SopsSecrets, image automation. homelab-infra: one Terraform file per guest.
  • Automation commits
    80 image-bump commits by the Flux bot, 2026-06-21 to 2026-09-21, in the cluster repo's history. Counted at build time on 2026-09-21.
Latest automated deploys · read from the cluster repo at build, 2026-09-21
  1. 2026-09-21markklass/demo-site:main-1789981904-8ab6557e
  2. 2026-09-21markklass/demo-site:main-1789983843-24de7740
  3. 2026-09-21markklass/demo-site:main-1789984518-a6e374e8
  4. 2026-09-21homelab/kopilog/web:main-1789985010-f9e46910
  5. 2026-09-18homelab/kopilog/web:main-1789725824-f0dee498
  6. 2026-09-16homelab/startup-page:main-p9-c8238b40
Each line is one commit made by the Flux bot, not by me: a newer image tag written into a Deployment manifest. Registry hostname removed.
How this page got here
  1. 1refresh-live-data: fetch the live figures; on scheduled runs, stop here if nothing changed.
  2. 2build-and-audit-site: type-check, build, WCAG 2 AA check on every page.
  3. 3push-site-image: tag main-<timestamp>-<sha>, push. Flux does the rest.
This build: a local build, 2026-11-04 19:00 UTC, started by local.