Skip to content
Why Build a Kubernetes Homelab?
Why Build a Kubernetes Homelab?

Why Build a Kubernetes Homelab?

I knew Kubernetes from the cloud. Elastic Kubernetes ServiceAmazon's managed Kubernetes. Referenced here as the "someone else runs the control plane" end of the build-versus-buy comparison., Google Kubernetes EngineGoogle's managed Kubernetes, cited alongside EKS as the managed-control-plane comparison point. — they hand you a cluster with networking, storage, and GPU scheduling already wired. You push a manifest, it works, and you have no idea how. The abstraction is the point if your job is shipping features. But if your job is understanding infrastructure, the abstraction is the obstacle.

I wanted to know what happens between kubectl apply and a running pod. How does the Container Network InterfaceThe plugin contract Kubernetes uses to give pods networking. Cilium is Frank's CNI; swapping it changes how every packet in the cluster is handled. assign an IP? How does Longhorn replicate data without a SAN? How does an immutable OS manage disk mounts when there is no SSH and no shell? You can read about eBPF kube-proxy replacement or Dynamic Resource AllocationThe Kubernetes API for requesting specialised hardware — GPUs and accelerators — with far more expressiveness than the older device-plugin counter model.-based GPU sharing all day. But you can also break it, fix it, and actually learn it.

The hardware was already sitting around. An i9 desktop retired from daily use. A stack of Intel Next Unit of ComputingIntel's small-form-factor PC line. The shape of machine Frank's control-plane nodes are: desktop silicon, server duty.. Two Raspberry Pi 4s gathering dust. The cluster turns idle machines into a platform. The goal was never “run a production cluster at home.” It was to build one that could be production, so the skills transfer directly.

The Shape That Emerged

What started as “throw Kubernetes on some boxes” became a four-zone design, driven less by planning and more by what each machine forced us to confront.

    flowchart LR
  subgraph M[Zone A — Management]
    omni[raspi-omni<br/>Omni + Authentik + Traefik]
  end
  subgraph C[Zone B — Core HA]
    n1[mini-1<br/>CP + Worker]
    n2[mini-2<br/>CP + Worker]
    n3[mini-3<br/>CP + Worker]
  end
  subgraph G[Zone C — AI Compute]
    gpu[gpu-1<br/>RTX 5070 Ti, 128GB, 8TB SSD]
  end
  subgraph E[Zone D — Edge]
    pc[pc-1<br/>Legacy desktop]
    r1[raspi-1<br/>Pi 4]
    r2[raspi-2<br/>Pi 4]
  end

  omni -->|machine config| n1
  omni -->|machine config| n2
  omni -->|machine config| n3
  omni -->|machine config| gpu
  omni -->|machine config| pc
  omni -->|machine config| r1
  omni -->|machine config| r2
  

Zone A was a single Raspberry Pi 5 living outside the cluster. It carried three jobs: Sidero Omni (machine lifecycle), a Docker Traefik acting as the public edge for every *.frank.derio.net name, and the Let’s Encrypt minter for that zone. It did not run Authentik. Single Sign-OnOne login across many applications. On Frank, Authentik holds the session and Traefik asks it before forwarding a request. has always been an in-cluster ArgoCD app; the Pi only fronted its hostname. That distinction sounds pedantic until something dies, at which point “hosts the name” and “runs the service” fail in completely different ways.

Which is not hypothetical. On 2026-06-20 the Pi failed outright, taking all three jobs with it. The cluster carried on: control plane and workers boot independently of Omni, every workload stayed Ready, and the LAN service IPs kept answering. Nothing paged either, because the health monitoring is in-cluster and the cluster was genuinely fine. What went was kubectl and talosctl, because the stored kubeconfig points at Omni’s proxy and no break-glass credential for the real apiserver had ever been saved. Omni answers at its old hostname again, but the runbook’s durable plan (an Ansible-managed Proxmox host with HA and a UPS, explicitly “not another Pi”) is still open, and the *.frank names are being served by the in-cluster Traefik as a stopgap. Read the current arrangement as interim.

Zone B is three identical Intel NUCs (Ultra 5, 64GB, 1TB NVMe). They form the High AvailabilityRunning enough redundant instances that losing one does not take the service down. Frank's three control-plane nodes are its HA tier. control plane. Because Talos lets control planes run workloads, these also host Longhorn storage and most cluster services. Identical hardware means predictable capacity. No surprises.

Zone C is one machine: a custom desktop with an i9, 128GB RAM, an RTX 5070 Ti (16GB GDDR7), and two 4TB SATA SSDs. It is the single node that makes the cluster interesting — local Large Language ModelA model trained to predict text, served behind a chat or completion endpoint. On Frank these run locally on the GPU node rather than against a hosted provider. inference, diffusion models, agentic workloads. Everything GPU-related lands here.

Zone D is the rag-tag edge: a legacy desktop (pc-1) and two Raspberry Pi 4s. They run CI/CD pipelines, monitoring scrapers, DNS caches — workloads that need to be always-on but do not need a GPU or fast storage.

Frank's seven nodes marked in yellow: pc-1, gpu-1, three mini nodes, and two Raspberry Pis.
The rack contains several systems, but Frank's seven nodes are the equipment marked in yellow: pc-1 (1), gpu-1 (2), mini-1 through mini-3 (3–5), and raspi-1 and raspi-2 (6–7). The purple, turquoise, and blue markers identify neighboring Proxmox, NAS, and unrelated Raspberry Pi equipment rather than Frank nodes.

The Two-Layer Model That Makes It Work

The single most important design decision was separating machine config from workload config. With two declarative systems in play there are three arrangements available, and only one of them is comfortable. You can let both manage everything and rely on discipline to keep them apart. You can hand one of them everything and give up the other’s strengths. Or you can draw a boundary and pay to defend it. The first is the default, because it requires no decision, and it is the one where an OS extension installed by Omni is also a resource ArgoCD is trying to reconcile, with neither aware of the other.

Frank runs the third:

  • Layer 1 (Machine Config): Sidero Omni manages Talos Linux machine configurations: OS extensions, kernel modules, disk mounts, network settings. Applied via omnictl config patches, version-controlled in patches/.
  • Layer 2 (Workloads): ArgoCD manages everything running on Kubernetes: CNI, storage, GPU drivers, applications. GitOps via apps/ in the same repo.

Omni never touches workloads. ArgoCD never touches machine config. When a problem surfaces, you know which layer to debug.

The repo layout is asymmetric and it is worth knowing why before you go looking. A later multi-cluster restructure gave the Hop edge cluster its own clusters/hop/ subtree, but never moved Frank; Frank is the default cluster, so its two directories stayed at the repo root. There is no clusters/frank/.

Omni cluster dashboard showing all seven nodes, their roles, and resource usage

Verify the boundary on your own cluster

A boundary is easy to describe and easy to violate, so check it rather than believe it. Three checks, each of which answers less than it looks like it answers, which is why there are three. Output below captured 2026-07-29, except the third check, re-run 2026-08-01 — a rename landed in that tree between the two dates, which is the sort of thing that makes a pasted sample rot while the command stays true.

Start with what the cluster reports about itself:

$ kubectl get nodes -L zone,tier,accelerator
NAME      STATUS   ROLES           AGE    VERSION   ZONE         TIER        ACCELERATOR
gpu-1     Ready    <none>          148d   v1.35.3   ai-compute   standard    nvidia
mini-1    Ready    control-plane   148d   v1.35.3   core         standard    intel-igpu
mini-2    Ready    control-plane   148d   v1.35.3   core         standard    intel-igpu
mini-3    Ready    control-plane   148d   v1.35.3   core         standard    intel-igpu
pc-1      Ready    <none>          148d   v1.35.3   edge         standard
raspi-1   Ready    <none>          148d   v1.35.3   edge         low-power
raspi-2   Ready    <none>          148d   v1.35.3   edge         low-power

Be precise about what that establishes. It establishes that seven nodes exist, that they carry zone labels, and that Zone A is not among them: management has no node because it lives outside the cluster it manages, and the day it shows up here is the day the separation quietly collapsed. It establishes nothing whatsoever about who set the labels. A kubectl label node typed by hand produces output identical to the above, character for character. If you decide the boundary is intact on the strength of this command, you have decided it on the strength of a screenshot.

So trace a label back to the file that declares it:

$ grep -rn 'zone: ai-compute' patches/ apps/
patches/phase01-node-config/03-labels-gpu-1.yaml:13:                zone: ai-compute

One hit, under patches/, nothing under apps/. That is worth exactly one string, one direction, one node. It says a machine fact has not leaked into the workload tree. It says nothing at all about traffic in the other direction, and the other direction is where Frank actually leaks. Ask it:

$ git ls-files 'patches/**/*.yaml' | xargs grep -L 'omni.sidero.dev'
patches/phase02-cilium/cilium-values.yaml
patches/phase03-longhorn/longhorn-gpu-local-sc.yaml
patches/phase03-longhorn/longhorn-values.yaml
patches/phase04-gpu/gpu-operator-values.yaml
patches/phase13-auth/authn-config.yaml

Every genuine Omni resource declares a type: ending in .omni.sidero.dev, so anything in the machine-config tree without that string is either a false positive or a leak. One is a false positive: authn-config.yaml is a bare Kubernetes AuthenticationConfiguration that the API server reads by path, with no Omni envelope to carry — Layer 1 doing its job. The other four are Cilium’s Helm values, Longhorn’s Helm values, a Longhorn StorageClass, and the GPU Operator’s Helm values. Four Layer 2 files, tracked, non-empty, sitting in the Layer 1 tree.

They are archaeology. That is how the cluster was installed before ArgoCD existed, patches/README.md says as much, and the live versions have lived at apps/cilium/values.yaml, apps/longhorn/values.yaml, apps/longhorn/manifests/gpu-local-sc.yaml and apps/gpu-operator/values.yaml for a long time. Nothing applies the old copies — though “nothing applies them” is a fact about the current state of the scripts, not a property of the files. They are also actively misleading. patches/phase03-longhorn/longhorn-values.yaml documents helm install --version 1.11.0 against a path (patches/phase3-longhorn/) that a directory rename retired, and the cluster left 1.11.0 in June over an instance-manager memory leak. Three separate facts in one dead file, all wrong, none of them announcing it.

The decision procedure, then. Run all three against your own repo and read them as a set:

  • Labels present, grep clean, reverse check clean. The boundary holds. Nothing to do.
  • Reverse check names files. Open each one. If it is live, you have a real leak and the fix is to move it. If it is dead, delete it or say in the README that it is dead, because the next person to grep this tree will not know which.
  • Forward grep finds a node label inside a Helm chart. This is the expensive one. The first question of every future debugging session (“which layer owns this?”) no longer has an answer, and no amount of the other two checks passing will give it back.

What the Series Covers

Each post in this series builds one layer on top of the last. The roadmap below shows the full sequence — the post you are reading sits at Layer 0, the motivation.

1 Hardware — 7 Nodes, 3 Zones
3x Intel NUC (Core zone) 1x GPU tower — RTX 5070 1x Legacy desktop 2x Raspberry Pi 4
x86_64 arm64 heterogeneous
2 OS & Bootstrap
Talos Linux (immutable) Sidero Omni (lifecycle) Declarative machine config Rolling upgrades
no SSH API-driven reproducible
3 Networking — Cilium CNI
eBPF kube-proxy replacement L2 LoadBalancer (ARP) Hubble observability Network policy
eBPF 192.168.55.200-254
4 Storage — Longhorn
Distributed 3-replica block storage GPU-local StorageClass 2x 4TB SSD on gpu-1 iSCSI via Talos extensions
strict-local best-effort all 7 nodes
5 GPU Compute
NVIDIA GPU Operator (RTX 5070) Intel DRA driver (Arc iGPU) Dynamic Resource Allocation CDI device injection
K8s 1.35 DRA ResourceClaim DeviceClass
6 GitOps — ArgoCD
App-of-Apps pattern Multi-source Applications Self-healing + drift detection Zero-downtime adoption
single repo annotation tracking
7 Fun Stuff
OpenRGB via USB HID DaemonSet + ConfigMap Custom container build (GitHub Actions) IT5701 firmware lock (in progress)
completely unnecessary fans still rainbow
8 Observability
VictoriaMetrics (metrics + alerts) VictoriaLogs (log aggregation) Grafana dashboards Fluent Bit log shipping Blackbox Exporter (endpoint probes) Pushgateway (heartbeat ingestion) Telegram alerting Health Bridge (GitHub lifecycle) Edge Observability (blog analytics, edge security, AI alerts) metrics-server (metrics.k8s.io — kubectl top + CPU/mem HPA)
VMSingle Feature Health metrics.k8s.io health-bridge GoatCounter CrowdSec Falco ai-alert-helper 192.168.55.203
9 Backup
Longhorn → Cloudflare R2 Daily + weekly recurring jobs SOPS-encrypted credentials NAS target (pending Longhorn 1.13)
S3-compatible 7-day RPO
10 Secrets Management
Infisical (self-hosted vault) External Secrets Operator ClusterSecretStore ExternalSecret → K8s Secret
audit trail Universal Auth 192.168.55.204
11 Local Inference
Ollama (gpu-1, RTX 5070) LiteLLM (unified gateway) OpenRouter (free cloud models) OpenAI-compatible API
ollama litellm 192.168.55.206
12 Agentic Control Plane
Sympozium (K8s-native agents) n8n (per-user workflow automation) VK Remote (self-hosted kanban API) ElectricSQL real-time sync
agent=Pod n8n vibekanban 192.168.55.207 192.168.55.216
13 Unified Auth
Authentik IdP (OIDC + proxy) SSO for ArgoCD, Grafana, Infisical Forward auth for Longhorn, Hubble, Sympozium OIDC-backed kubectl via apiserver
OIDC forward-auth 192.168.55.211
14 Multi-tenancy
vCluster (K8s-in-K8s) Disposable experiment clusters Resource quotas + network policies GitOps-provisioned via ArgoCD
vcluster multi-tenant SQLite
15 AI Agent Orchestrator
Paperclip (org-chart agents) Virtual companies + budgets Delegation chains + governance LiteLLM gateway integration
paperclip company model 192.168.55.212
16 Media Generation
ComfyUI (diffusion models) LTX-2.3 video, SDXL image, Stable Audio GPU Switcher dashboard (Go) Time-sharing via replica scaling
comfyui gpu-switcher 192.168.55.213
17 Public Edge — Hop
Hetzner CX23 (single-node Talos) Headscale mesh + Tailscale Caddy reverse proxy + TLS Split-DNS (MagicDNS)
edge WireGuard blog.derio.net
18 Persistent Agent
Kali Linux workstation Always-on Claude Code agent SSH remote access 50Gi persistent /root
kali claude --remote 192.168.55.215
19 Progressive Delivery
Argo Rollouts controller LiteLLM canary (Cilium traffic split) Sympozium blue-green VictoriaMetrics analysis gates
canary blue-green workloadRef
21 Secure Agent Pod
Hardened non-root Kali container Cilium egress allowlist VibeKanban agent orchestration VK Relay (WebSocket tunnel to browser)
security vibekanban relay 192.168.55.215
24 In-Cluster Ingress
Traefik v3 on raspi edge nodes Wildcard TLS (*.cluster.derio.net) Authentik forward-auth (12 services) Homepage dashboard
traefik acme 192.168.55.220
25 CI/CD Platform
Gitea (GitHub mirror forge) Tekton Pipelines + Triggers Zot OCI registry (cosign signed) Webhook-driven CI on pc-1
gitea tekton zot 192.168.55.209
26 Agent Images and the VK-Local Sidecar
agent-images repo (shared base + children) Matrix CI with cross-repo repository_dispatch VK-local sidecar (shared /home/claude PVC) Lockstep bumper PR in frank
docker github-actions sidecar
29 Ruflo — Swarm Orchestrator
claude-flow / ruvocal (chaotic swarm) Hybrid pod: ruvocal + shell sidecar SSH+Mosh shell on 192.168.55.222 Zero frontier-LLM keys (LiteLLM-only egress)
ruflo claude-flow swarm ruflo.cluster.derio.net
30 The Frank Papers — Research Series
Third blog series (Building / Operating / Papers) Dossier gate (vendors, sources, gaps, counter-args) Mermaid Frank theme + .paper-post CSS scope Render-time cross-series backlinks (zero retrofit) Paper 00 prologue published 2026-05-18
hugo hextra mermaid research
32 Infrastructure Automation
AWX Ansible controller (operator + CR) Reaches non-Talos / external home-lab devices Native OIDC login via Authentik awx.cluster.derio.net (Traefik)
awx ansible automation
33 Hermes Agent Shell
Nous Research hermes CLI (dedicated pod, gpu-1) BYOK → LiteLLM (provider pinned in config.yaml) profile.d shim vs. sshd env-scrub SSH+Mosh shell on 192.168.55.226
hermes byok litellm
Virtual Machines — upcoming
KubeVirt (VMs as pods) CDI disk image import KubeVirt Manager UI Longhorn-backed DataVolumes
KVM 192.168.55.205

What You Need to Follow This

  • Familiarity with kubectl and basic Kubernetes concepts (Pod, Service, Deployment)
  • A Talos-compatible machine (x86 or ARM64) to experiment on — even a single node is enough for most layers
  • About 30 minutes per layer post

The series assumes you are building alongside. Each post ends with a running cluster state you can verify.

Missteps

What HappenedWhy It Was WrongHow We Fixed ItEvidence
Management was a single un-redundant board — Omni, the public *.frank edge, and the zone’s cert minter all on one Raspberry Pi 5Putting management outside the cluster was right; putting three roles on one un-HA’d board was a separate decision that nobody made deliberately. When it died there was no second copy of any of the three*.frank names re-fronted onto the in-cluster Traefik; Omni’s durable rehoming onto an HA Proxmox host is still openfrank-gotchas/omni.md
No break-glass credential existed — the only kubeconfig and talosconfig routed through Omni’s proxyThe real apiserver on 192.168.55.21:6443 and the Talos API on :50000 both stayed up and reachable on the LAN throughout the outage. They were unusable purely for want of a credential, and Omni, which mints them, was the dead thingNot fixed. Recorded, so the next person does not discover it the same wayfrank-gotchas/omni.md
Helm values for Cilium, Longhorn and the GPU Operator were left under patches/ — the pre-ArgoCD install method, never deleted when ArgoCD took overFour Layer 2 files in the Layer 1 tree, still tracked, quietly contradicting the boundary this post is about. One of them documents an install command with a stale chart version and a stale directory pathNamed in the verify section above rather than swept uppatches/phase0{2,3,4}-*/, patches/README.md
Management was described as running Authentik — it never did; the Pi fronted auth.frank.derio.net, while the identity provider itself has been an in-cluster ArgoCD app since Layer 13“Hosts the name” and “runs the service” fail in different ways, so conflating them makes an outage harder to reason about at exactly the wrong momentZone A’s description corrected aboveapps/authentik/, commit 8e5da3f3

References

  • Talos Linux — Immutable, secure, minimal Kubernetes OS
  • Sidero Omni — SaaS-simple Kubernetes cluster management for Talos Linux
  • ArgoCD — Declarative GitOps continuous delivery for Kubernetes
  • Cilium — eBPF-based networking, observability, and security
  • Longhorn — Cloud-native distributed block storage for Kubernetes
  • NVIDIA GPU Operator — GPU management in Kubernetes