Talos OS Setup: A 3-Node Homelab Kubernetes Cluster
Introduction
I was running a handful of services on a managed Kubernetes cluster on Hetzner. It worked fine, but the hardware pricing kept climbing and I was paying every month for compute that idled most of the time. I ended up with three small mini PCs (16-32GB RAM each) sitting on my desk instead, and decided to move those services onto a real cluster of my own: something that survives a node dying, that I don’t have to SSH into to patch, and that I don’t have to babysit. Talos Linux fit that better than a regular distro plus kubeadm - no shell, no package manager, the whole machine is configured from one YAML file and managed over an API. This post is the base cluster. Storage, a database, and exposing something to the internet come in later posts.
Prerequisites
- Three (or more) amd64 machines that can run Talos Linux. I used three mini PCs.
- Each machine booted into Talos maintenance mode (a Talos ISO on a USB stick works fine).
talosctlinstalled locally.- A LAN segment where you can hand out static IPs to the nodes.
Why All Three Nodes Are Control Plane
Three nodes is the minimum for an etcd quorum that survives one node going down. I didn’t have a fourth machine to dedicate as a worker, so instead of one control-plane node and two workers, all three run as control plane with scheduling enabled on them:
cluster:
allowSchedulingOnControlPlanes: trueThat gets me HA etcd and three schedulable nodes, at the cost of workloads sharing the box with the control plane components. For a homelab that tradeoff is fine.
Generating the Base Config
talosctl gen config homelab https://192.168.1.10:6443 --output-dir configsThis writes controlplane.yaml, worker.yaml, and talosconfig into
configs/. Since every node here is a control-plane node, I only need
controlplane.yaml - copied once per node and edited for hostname and IP.
Per-Node Network Config
Each node gets a static IP instead of DHCP, plus its hostname:
machine:
network:
interfaces:
- interface: eth0
dhcp: false
addresses:
- 192.168.1.10/24
routes:
- network: 0.0.0.0/0
gateway: 192.168.1.1
nameservers:
- 192.168.1.1
install:
disk: /dev/nvme0n1
wipe: falsenode2 and node3 are the same file with 192.168.1.11 / 192.168.1.12
and the matching hostname: at the bottom.
Laid out, the network looks like this - all three nodes and my laptop share
one flat 192.168.1.0/24 LAN, kubectl only ever talks to node1 directly,
and the nodes talk to each other over etcd to keep quorum:
CNI: Flannel, With NetworkPolicy Actually Enforced
Talos ships Flannel by default. As of Talos 1.13, Flannel can enforce
standard Kubernetes NetworkPolicy objects natively, which meant I didn’t
need to bolt on Calico or Cilium just to get policy enforcement:
cluster:
network:
cni:
name: flannel
flannel:
kubeNetworkPoliciesEnabled: trueWithout that flag, NetworkPolicy resources are accepted by the API but
silently not enforced - worth knowing before you assume a policy is doing
anything.
Why I Actually Need NetworkPolicy Here
These three nodes sit on the same flat LAN as the rest of my house - no
VLAN, no physical separation between “cluster” and “home network.” By
default a pod can reach my NAS or router just as easily as it can reach
another pod. NetworkPolicy is the only thing stopping that.
The pattern is default-deny egress per namespace, plus an explicit carve-out for anything that needs the internet:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-egress
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 192.168.1.0/24
ports:
- protocol: TCP
port: 443Allow HTTPS to anywhere, except the home LAN - a pod can reach S3, not my NAS.
Worth being honest about what this is: not real isolation. No VLAN, no firewall appliance, enforcement happens in Flannel on the same node the pod runs on - a kernel or CNI bug bypasses it entirely. It stops the boring case, a buggy app reaching somewhere on my LAN it shouldn’t, which is good enough for a homelab. Anywhere it actually mattered, physical or VLAN segmentation would be step one, not a “maybe later.”
Applying Config and Bootstrapping
While a node is still in maintenance mode it’s on a temporary DHCP address,
not the static one baked into its config yet, so the first apply targets
that temporary address with --insecure:
❯ talosctl apply-config --insecure -n 192.168.1.110 --file configs/node1.yamlRepeat for node2 and node3 against their own temporary addresses. Once all three have rebooted onto their real static IPs, bootstrap etcd on one of them:
❯ talosctl bootstrap -n 192.168.1.10Then pull a kubeconfig:
❯ talosctl kubeconfig ~/.kube/homelab -n 192.168.1.10
❯ export KUBECONFIG=~/.kube/homelab
❯ kubectl get nodes
NAME STATUS ROLES AGE VERSION
node1 Ready control-plane 3m v1.36.3
node2 Ready control-plane 2m v1.36.3
node3 Ready control-plane 1m v1.36.3No VIP Yet, Just Quorum
Worth being honest about a gap here: cluster.controlPlane.endpoint points
at node1’s static IP directly, not a floating VIP. What I have is etcd
quorum HA - the cluster tolerates one node going down - not endpoint HA. If
node1 itself is the one that goes down, kubectl pointed at
192.168.1.10:6443 stops working until I repoint it at node2 or node3
manually, since all three run the API server. That’s a real limitation I’m
leaving for later - a keepalived VIP or an external load balancer would fix
it, but for a cluster with one client (me) it hasn’t been worth the extra
moving part yet.
Conclusion
Three nodes, one YAML file each, no SSH, no OS to patch by hand. The cluster itself was the easy part - it doesn’t do anything yet. Next up: persistent storage, because a Postgres cluster is coming and it needs somewhere to put its data that survives a node reboot.