Charlotte, NC
BlogJuly 25, 2026

A Boring Kubernetes Homelab: Talos, Flux, Longhorn, TrueNAS

Blake McCarn
A Boring Kubernetes Homelab: Talos, Flux, Longhorn, TrueNAS
I wanted a real Kubernetes homelab. I did not want every service in my house to become a Kubernetes migration project. That distinction shaped the whole build. It is easy to stand up a cluster, install an ingress controller, point a few Helm charts at it, and call the project done. The harder question is what happens six months later when a node fails, a storage upgrade goes wrong, or Git says one thing while the cluster says another. So I built the cluster to be boring: three control-plane nodes, four workers, immutable Talos Linux nodes, Flux reconciliation, explicit storage classes, and a very short list of workloads allowed to bring state with them. The restraint is the architecture. The seven Kubernetes nodes run as virtual machines on a small Proxmox cluster: The control-plane nodes have only their Talos operating-system disks. They do not schedule normal workloads, and they do not contribute disks to Longhorn. Each worker has a dedicated 400 GiB data disk that Longhorn can use. That separation costs some capacity. It also means an application filling a Longhorn disk cannot fill an etcd or control-plane disk at the same time. Capacity is cheaper than a confusing failure domain. Talos documents a supported Proxmox installation path, but the hypervisor is not the interesting part. The useful part is that the nodes are replaceable shells. The durable parts live in Git, the storage systems, and tested backups. I have managed enough Linux servers to know how configuration drift starts. Someone installs a troubleshooting package. A systemd unit gets edited directly. One node receives a kernel setting that never makes it into automation. Nothing breaks immediately, which is why the change survives long enough to become archaeology. Talos removes most of that temptation. It is API-managed, purpose-built for Kubernetes, and driven by declarative machine configuration. There is no normal SSH administration path and no package manager waiting for an emergency one-off fix. That changes the operating model. If I need to alter a node, I update its machine configuration or build the required capability into the image. If a node becomes suspect, replacing it is often cleaner than repairing it in place. Talos and Flux own different layers:
  • talosctl owns the node lifecycle, including machine configuration, Kubernetes versions, and Talos upgrades.
  • Flux owns Kubernetes resources after the cluster exists.
I intentionally did not make Flux responsible for Talos itself. A broken in-cluster controller should not be the only path to repairing the operating system that runs that controller. Longhorn needs host capabilities such as iSCSI support. On Talos, that means declaring the required system extension in the node image instead of installing a package after boot. The Talos storage guidance describes that extension model. It is a small detail, but it captures why I like Talos: even the exception becomes part of the reproducible image. Once the cluster is healthy, Flux takes over the application layer. The desired state lives in a private GitHub repository. Flux pulls from that repository, applies the declared resources, and keeps reconciling. If I make a quick kubectl edit change and forget about it, Flux eventually puts the cluster back to what Git says it should be. That can feel annoying during troubleshooting. It is also the feature. The repository is organized in layers so dependencies stay visible: Infrastructure controllers reconcile before applications that depend on them. Storage classes exist before PVCs. Gateways exist before HTTPRoutes. Observability comes online before I move meaningful workloads. This is the same pattern I use when designing client platforms at work, just scaled down. The difficult part is rarely installing the controller. It is deciding who owns each layer, how changes move between layers, and what still works when one of them is unavailable. GitOps gets awkward as soon as an application needs a password, token, or certificate. Storing plaintext secrets in Git is obviously wrong. Keeping every secret completely separate from the deployment definition creates a different problem: the cluster can need a value without Git describing where it comes from or how it becomes a Kubernetes Secret. My application and runtime secrets live in a dedicated 1Password vault. External Secrets Operator connects to that vault through the 1Password SDK and turns the references declared in Git into Kubernetes Secrets. The repository contains the item and field mappings, refresh policy, target Secret shape, and workload dependency. It does not contain the application values, even as ciphertext. There is one bootstrap problem: External Secrets cannot retrieve its own 1Password service-account credential before it can talk to 1Password. I keep that single bootstrap Secret encrypted in Git with SOPS and age. Flux's Kustomize controller decrypts it during reconciliation, External Secrets uses it to reach 1Password, and the rest of the runtime secrets flow from the vault. That makes SOPS the bridge into the secret manager, not the secret manager for every application. Git still owns the deployment contract, 1Password owns the sensitive values, and External Secrets owns the synchronization between them. Storage was the part I refused to leave implicit. Kubernetes lets a cluster have a default StorageClass. A PVC that omits storageClassName can then receive storage automatically. That is convenient until a chart silently puts a database on whichever backend happened to be marked default. My cluster started with no default StorageClass. Every stateful workload had to make its storage choice explicit, either through a named class or an explicitly bound static volume:
  • longhorn-ha for small, portable ReadWriteOnce volumes that benefit from replicas across worker disks
  • truenas-nfs for dynamically provisioned ReadWriteMany directories whose natural home is the NAS
  • local-ssd as a static-only option for scratch data or latency-sensitive workloads where binding the pod to one worker is an acceptable trade-off
  • A static NFS PV for an existing TrueNAS dataset whose lifecycle should remain independent from Kubernetes provisioning
The Kubernetes StorageClass documentation makes the mechanism look simple. The important part is the naming. A storage class is not just a provisioner. It is a decision about performance, availability, reclaim behavior, and ownership. A claim should make that decision obvious: There is no hidden default in that manifest. A reviewer can see the storage choice before it reaches the cluster. Longhorn turns worker disks into replicated Kubernetes block storage. A volume can keep replicas on multiple nodes, rebuild after a replica is lost, and move with a workload when Kubernetes reschedules it. That is useful. It is not magic. Every replicated write consumes network and disk bandwidth. A bad application write is faithfully copied to every replica. A deleted volume is still deleted. Replication improves availability, but it does not replace backup. I kept Longhorn worker-only and non-default. The longhorn-ha class requests two replicas, and Longhorn's configured data path exists only on the bounded worker data disks. Control-plane disks are not eligible. Neither are random directories on the root filesystem. Longhorn's own best-practices guidance recommends deliberate replica counts, storage tags, and backups to secondary storage such as NFS or S3. That matches how I use it. Small application PVCs are a good fit. Large databases do not earn a migration merely because a Helm chart can create a claim. The NAS already knows how to be a storage system. I did not need Kubernetes to recreate it. TrueNAS provides two paths for shared file data and ReadWriteMany workloads. The truenas-nfs class dynamically creates subdirectories for new claims. Existing datasets with their own backup and lifecycle rules use explicitly bound static NFS volumes instead. Multiple pods can mount either kind of volume, and the data remains independent from the life of any Kubernetes worker VM. That is the right shape for shared documents, media, exports, and other file-oriented data. The dynamic path uses the Kubernetes SIGs NFS subdirectory external provisioner, while important pre-existing datasets stay statically bound. Both patterns keep TrueNAS authoritative for the files rather than asking Kubernetes to own their lifecycle. It also creates a clear boundary. Longhorn owns replicated block volumes inside the cluster. TrueNAS owns shared datasets outside it. I can troubleshoot each system without guessing which layer is authoritative. There is a trade-off. NFS makes the NAS and network part of the application path. Longhorn keeps storage close to the workers but adds replication traffic and cluster-level recovery work. Local SSD avoids the network storage path but intentionally gives up portability. That is why I keep multiple workload-facing storage policies instead of one default answer. There is no universally correct storage backend. The first version of my plan had too much in it. It was tempting to migrate every database, monitoring component, document service, and automation tool as soon as the cluster became healthy. That would have created a lot of activity and very little evidence that the platform was recoverable. Heavy databases and latency-sensitive state stayed on dedicated hosts or dedicated storage during phase one. Existing services did not move just to make the cluster diagram look complete. The Paperless stack shows the split. Its application components run in Kubernetes, Redis uses a small Longhorn volume, and the document archive is a statically bound TrueNAS NFS dataset. PostgreSQL and Neo4j remain on dedicated database VMs. Each part runs where its recovery and performance characteristics make sense. A workload earns its way into Kubernetes when I can answer four questions:
  1. Which layer owns its configuration?
  2. Which storage path matches its data?
  3. How is it backed up?
  4. What recovery path have I actually proven?
If the fourth answer is vague, the storage path is not ready for a meaningful workload. For Longhorn, I proved a full backup to a separate TrueNAS NFS target and restored it into a temporary volume. A daily canary separately verifies the CSI snapshot-to-new-PVC path. For TrueNAS-backed data, I care about the dataset recovery path outside Kubernetes and application-aware restores where they matter. A backup job that has never produced a working restore is only a recurring optimism task. The cluster now has three clear control paths:
  • Talos node health and lifecycle through talosctl
  • Kubernetes desired state and drift through Flux
  • Persistent data health through Longhorn and TrueNAS
Those paths feed the same central observability stack I wrote about in Building a Monitoring Stack That Actually Works. I watch node readiness, Flux reconciliation, PVC state, Longhorn volume health, storage capacity, and the telemetry pipeline itself. I keep the Kubernetes API on the private network. Browser-based administration follows a more deliberate split: selected interfaces, including Longhorn, can be reached through Cloudflare Access, while trusted LAN and Tailscale clients use split DNS to reach the internal gateway directly. That follows the same model I described in Tailscale First, Cloudflare Tunnel Still in the Stack: the control plane stays private, and browser routes get an authenticated edge when the convenience is worth it. The key insight here is that Kubernetes is not the platform by itself. The platform is Kubernetes plus ownership, reconciliation, storage policy, recovery, and a way to operate it when one layer is broken.
  • Proxmox for the virtual machine layer
  • Talos Linux for immutable, API-managed Kubernetes nodes
  • Kubernetes across 3 control-plane nodes and 4 workers
  • Flux for GitOps reconciliation from a private GitHub repository
  • 1Password + External Secrets Operator for application and runtime secrets
  • SOPS + age for the 1Password bootstrap Secret
  • Longhorn for replicated worker-local block storage
  • TrueNAS NFS for shared ReadWriteMany datasets
  • Local SSD storage reserved for explicitly node-bound scratch and latency-sensitive data
  • Grafana, Prometheus, Loki, and Alloy for cluster and storage observability
The result is not the biggest Kubernetes homelab I could build. It is one I can explain. Talos keeps the nodes replaceable. Flux keeps the application layer honest. Longhorn handles a bounded set of portable volumes. TrueNAS keeps shared data on a storage system built for it. The parts are deliberately boring, which is exactly what I want from infrastructure I plan to keep running.
Share this post: