Adding a Second Node to My Single-Node Talos Kubernetes Cluster
A step-by-step guide to expanding a single-node Talos Linux Kubernetes homelab to two nodes: architecture decisions, Cilium fixes, Longhorn replication, and the gotchas I expect along the way.
My homelab runs on a single Dell laptop: 15.6 GB of RAM, 512 GB NVMe, Talos Linux, fully managed by Flux GitOps. It's been solid for months, but it's starting to show its limits. Memory sits at 80%, Longhorn volumes run with a single replica (because there's only one node to put them on), and I've got an Intel NUC with 32 GB of RAM gathering dust on a shelf.
Time to fix that. This post is my step-by-step plan for adding the NUC as a second node: what I'll do, why, and what I expect to go wrong.
The Architecture Decision: Worker, Not Control-Plane
The first question is whether the NUC joins as a control-plane node or a worker. The answer is worker, and it's not close.
etcd requires an odd number of voting members for quorum. One node = quorum of one. Three nodes = quorum of two. But two nodes? Quorum is still two, meaning if either node goes down, the cluster loses quorum and the API server stops accepting writes. Two control-plane nodes is strictly worse than one for availability.
The right topology is 1 control-plane (Dell) + 1 worker (NUC). The Dell keeps running etcd, the API server, and the scheduler. The NUC just runs workloads and provides storage capacity. If the Dell dies, the cluster is down regardless. Adding a second control-plane node doesn't change that.
True HA means three control-plane nodes. That's a future project, not today's.
Prerequisites
Before the NUC boots Talos, a few things need to be in place:
- Same Talos version: Both nodes must run v1.12.1. Version mismatches cause subtle kubelet and API compatibility issues.
- iscsi-tools extension: Longhorn requires iSCSI for block device management. Without it, CSI volume attachments fail with cryptic errors. This extension must be baked into the Talos image.
- Wired network: The NUC needs a physical Ethernet connection to the same switch as the Dell. Same L2 subnet. WiFi is technically possible but Longhorn replication over WiFi is unreliable: packet loss causes replica rebuilds, which cause more traffic, which causes more packet loss.
- Static IP: I'll assign
192.168.50.11to the NUC (the Dell is at192.168.50.10).
Step 1: Fix the Cilium L2 Announcement Policy
This one needs to happen before the NUC joins the cluster.
My current Cilium L2 announcement policy hardcodes the Dell's physical NIC name:
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: l2-policy
spec:
interfaces:
- enp55s0u1
externalIPs: true
loadBalancerIPs: trueThe NUC's Ethernet adapter will have a different interface name (likely something like eno1 or enp1s0). With the current policy, Cilium on the NUC won't announce any L2 addresses because its interface doesn't match the filter. That means if a LoadBalancer service (like Traefik at 192.168.50.200) gets scheduled to the NUC, it becomes unreachable.
The fix is to remove the interfaces filter entirely:
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: l2-policy
spec:
externalIPs: true
loadBalancerIPs: trueWithout the filter, Cilium announces on all interfaces. Since both nodes only have one physical NIC each, this is fine. The change needs to be committed and reconciled by Flux before the NUC joins. Otherwise there's a window where L2 announcements silently break.
After Flux reconciles, verify that Traefik's VIP (192.168.50.200) still responds. If something goes wrong, the old single-interface config is a clean rollback.
Step 2: Build the Talos Worker Image
Talos images are built through the Image Factory. The key is to include the iscsi-tools system extension: this is what enables Longhorn's CSI driver to work.
If I already have the schematic ID from my Dell's image (which includes iscsi-tools), I can reuse it. Otherwise, create a new schematic with the extension and rebuild for both nodes to keep them identical.
The build process:
- Go to factory.talos.dev
- Select Talos v1.12.1
- Add the
siderolabs/iscsi-toolsextension - Download the ISO or raw image
- Flash it to a USB drive
- Boot the NUC from USB. It enters maintenance mode automatically
Step 3: Discover the NUC's NIC Name
Before generating the worker config, I need to know what the NUC's network interface is called. Talos exposes this via the API even in maintenance mode:
talosctl get addresses -n 192.168.50.11 --insecureThe --insecure flag is required because the node hasn't been configured yet and has no TLS certificate. The output will show the interface name (e.g., eno1) along with its DHCP-assigned address. Note this. It goes into the worker config.
Step 4: Generate and Apply Worker Config
Generate a worker-only config from the existing cluster's secrets:
talosctl gen config homelab https://192.168.50.10:6443 --output-types workerThis produces a worker.yaml that trusts the existing control-plane. Before applying, customize it:
- Network interface: Set the correct NIC name from Step 3
- Static IP: Assign
192.168.50.11/24with gateway and DNS - iscsi-tools: Ensure the extension is listed in the machine config
- Hostname: Something identifiable like
nuc
The network section in worker.yaml should look something like:
machine:
network:
hostname: nuc
interfaces:
- interface: eno1 # from Step 3
addresses:
- 192.168.50.11/24
routes:
- network: 0.0.0.0/0
gateway: 192.168.50.1
nameservers:
- 192.168.50.1
install:
disk: /dev/nvme0n1Apply the config to the NUC while it's still in maintenance mode:
talosctl apply-config --insecure -n 192.168.50.11 -f worker.yamlThe NUC will reboot, install Talos to its NVMe, and attempt to join the cluster.
Step 5: Verify the Join
After a few minutes, the NUC should appear as a Ready node:
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# dell Ready control-plane 180d v1.35.0
# nuc Ready <none> 2m v1.35.0A few things to check:
# Node health via Talos API
talosctl -n 192.168.50.11 health
# Cilium agent running on the NUC
kubectl get pods -n kube-system -l k8s-app=cilium -o wide
# All DaemonSets have pods on both nodes
kubectl get ds -AIf the NUC shows NotReady, check Cilium first: it's the CNI and nothing works without it. If Cilium is fine but the node is still not ready, check the kubelet logs via talosctl -n 192.168.50.11 logs kubelet.
Step 6: Longhorn Storage Expansion
This is where it gets interesting. Longhorn will automatically detect the new node and schedule its components (instance-manager, etc.) there. But existing volumes won't magically get a second replica. That requires deliberate action.
First, update the default replica count in the HelmRelease:
# infrastructure/longhorn/helmrelease.yaml
values:
defaultSettings:
defaultReplicaCount: 2 # was 1This only affects new volumes. Existing volumes keep their current replica count. To add replicas to existing volumes, there are two approaches:
- Longhorn UI: Navigate to each volume, click Update Replica Count, set to 2
- kubectl patch:
kubectl patch volume.longhorn.io <name> -n longhorn-system --type merge -p '{"spec":{"numberOfReplicas":2}}'
I'd do this one volume at a time, starting with low-risk workloads:
- Low risk first: ntfy, linkding, IT-Tools (small volumes, easy to recreate if something goes sideways)
- Medium risk: Miniflux, Navidrome, Actual Budget (stateful but recoverable)
- High value last: Prometheus, Authentik, Ghost, Vaultwarden. These hold data I care about
One important constraint: all my persistent volumes are RWO (ReadWriteOnce). The pod stays pinned to its current node until the volume has a replica on the other node. During the rebuild, Longhorn copies data across the network. For a 5 GB volume over gigabit Ethernet, that's under a minute. For Prometheus's volume (potentially much larger), it takes longer.
Step 7: Workload Migration
Kubernetes doesn't automatically rebalance running pods when a new node joins. Every pod stays where it is until something causes a reschedule. I'll do this deliberately, in tiers:
Tier 1 (Stateless, move freely):
- IT-Tools, Homepage, Cloudflared
- These can be deleted and rescheduled anywhere:
kubectl delete pod -n <ns> <pod> - The scheduler will pick the node with the most available resources
Tier 2 (Stateful, move after replica rebuild):
- Miniflux, Navidrome, Linkding, Actual Budget, Ghost
- Wait for Longhorn to finish replicating the volume to the NUC
- Then delete the pod. The scheduler may place it on either node since both have a replica
Tier 3 (Keep on Dell):
- Flux, cert-manager, Prometheus, Authentik
- These are infrastructure services that I'd rather keep on the control-plane node
- Can use node affinity later to enforce this permanently
At this point, I wouldn't set up node affinity rules or pod disruption budgets. That's optimization for later, once I see how the scheduler distributes workloads naturally.
Gotchas I'd Watch For
Based on what I know about this setup, here's what I expect to cause trouble:
- NFS access across subnets: My Unraid server at
192.168.1.133serves NFS volumes. If the homelab subnet (192.168.50.0/24) routes to the Unraid subnet, the NUC should reach it. But if there are firewall rules or missing routes, NFS mounts will hang silently. Test with a simple pod mount before migrating real workloads. - Missing iscsi-tools: If the Talos image was built without this extension, Longhorn CSI attach operations fail with errors that don't mention iSCSI at all. The error looks like a generic "failed to attach volume" with a timeout. Always verify with
talosctl get extensions -n <nuc-ip>first. - Talos version mismatch: Even a patch version difference between the Dell and NUC can cause problems: kubelet version skew policies, different default feature gates, subtle API incompatibilities. Pin both to the exact same version.
- WiFi temptation: The NUC has WiFi. Don't use it. Longhorn replication over WiFi is a recipe for degraded volumes and constant rebuilds. Wired only.
- Single control-plane tradeoff: If the Dell dies, everything is offline: kubectl, Flux, etcd, the API server. The NUC's workloads stop receiving updates and eventually the kubelet marks them as unknown. This is the accepted tradeoff of running one control-plane node. Backups matter more than ever.
- kubeconfig and talosctl endpoints: Both still point exclusively to
192.168.50.10(the Dell). The NUC is a worker and doesn't run the API server, so this doesn't change. But remember: "adding a node" doesn't add redundancy to the control plane.
What's Next
Once the NUC is joined and workloads are distributed:
- Monitor both nodes: Watch memory utilization on the Dell (should drop significantly) and the NUC (should have plenty of headroom at 32 GB).
- Consider a third node: That would enable 3 control-plane nodes with etcd quorum, pod disruption budgets that actually work, and true HA. But that's a different project.
- Pod disruption budgets: Once there are two nodes, PDBs become meaningful for graceful node maintenance. Not urgent, but worth setting up before the first Talos upgrade on a multi-node cluster.
The nice thing about doing this in a GitOps-managed cluster is that most of the changes are just YAML commits. The Cilium fix, Longhorn replica count, any node affinity rules: they all go through Git, get reviewed, and Flux applies them. The only imperative steps are the Talos image build and talosctl apply-config. Everything else is declarative.