Omegion

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

  1. Three (or more) amd64 machines that can run Talos Linux. I used three mini PCs.
  2. Each machine booted into Talos maintenance mode (a Talos ISO on a USB stick works fine).
  3. talosctl installed locally.
  4. 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:

yaml
cluster:
  allowSchedulingOnControlPlanes: true

That 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

shell
talosctl gen config homelab https://192.168.1.10:6443 --output-dir configs

This 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:

yaml
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: false

node2 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:

yaml
cluster:
  network:
    cni:
      name: flannel
      flannel:
        kubeNetworkPoliciesEnabled: true

Without 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:

yaml
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: 443

Allow 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:

shell
❯ talosctl apply-config --insecure -n 192.168.1.110 --file configs/node1.yaml

Repeat 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:

shell
❯ talosctl bootstrap -n 192.168.1.10

Then pull a kubeconfig:

shell
❯ 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.3

No 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.