Adding a Worker Node to a Single-Node Talos Kubernetes Cluster
How I joined an Intel NUC as a worker node to a single-node Talos Linux cluster: secrets extraction, Cilium fixes, Longhorn HA, and the gotchas you won't find in the docs.
My homelab started as a single Dell laptop running Talos Linux, 15.6 GB of RAM, one node doing everything: control plane, workloads, storage. It worked, but at 77% memory overcommit and ~59% actual usage, I was one ambitious deployment away from OOM kills. Time to add a second node.
I picked up an Intel NUC with 32 GB RAM, roughly tripling my cluster's available memory. Here's how I joined it as a worker node to an existing single-node Talos cluster, entirely via the command line.
The Starting Point
- Existing cluster: Single Dell laptop, Talos Linux v1.12.1, Kubernetes v1.35.0
- GitOps: Flux reconciling from a GitHub repo every 10 minutes
- CNI: Cilium with kube-proxy replacement, VXLAN tunnel mode, L2 announcements
- Storage: Longhorn (single replica, because single node)
- New hardware: Intel NUC, 32 GB RAM, 512 GB NVMe
The goal: join the NUC as a worker, steer heavy workloads to it with soft affinity, enable Longhorn HA with 2 replicas across nodes, and keep the Dell as the sole control plane.
Phase 1: Extracting Cluster Secrets
Talos clusters are built from a secrets.yaml: the root of trust containing CA certificates and bootstrap tokens. If you still have yours from the initial cluster creation, great. I didn't, so I extracted it from the running control plane.
# Extract the machine config from the running control plane
talosctl -n 192.168.50.10 get machineconfig -o yaml \
| python3 -c "
import sys, yaml
docs = list(yaml.safe_load_all(sys.stdin))
with open('/tmp/running-controlplane.yaml', 'w') as f:
f.write(docs[0]['spec'])
"
# Generate a secrets bundle from the running config
talosctl gen secrets \
--from-controlplane-config /tmp/running-controlplane.yaml \
-o ~/.talos/secrets.yamlKeep secrets.yaml safe: this is your cluster's root of trust. Anyone with this file can generate valid node configs.
Phase 2: Building the Installer Image
Talos is immutable: you can't install packages after boot. Extensions like iscsi-tools (required for Longhorn) need to be baked into the installer image. The Talos Image Factory makes this easy.
# Define the extensions you need
cat > /tmp/worker-extensions.yaml <<'EOF'
customization:
systemExtensions:
officialExtensions:
- siderolabs/iscsi-tools
- siderolabs/util-linux-tools
EOF
# Submit to Image Factory — returns a schematic ID
curl -sS -X POST --data-binary @/tmp/worker-extensions.yaml \
https://factory.talos.dev/schematics
# {"id":"613e1592b2da41ae5e265e8789429f22e121aab91cb4deb6bc3c0b6262961245"}The schematic ID is a content-addressed hash of your extension set. Use it to download a boot ISO or reference the installer image directly in your machine config:
factory.talos.dev/installer/613e1592...61245:v1.12.1I flashed the ISO to a USB drive and booted the NUC from it. Talos enters maintenance mode: no OS installed yet, just an API endpoint waiting for configuration.
Phase 3: Identifying the Hardware
With the NUC in maintenance mode (DHCP IP: 192.168.1.215), I queried its hardware:
# Find the NIC name
talosctl get links --insecure --nodes 192.168.1.215
# Result: eno1 (up, ether, 1c:69:7a:08:f9:4d)
# Find the disk
talosctl get disks --insecure --nodes 192.168.1.215
# Result: /dev/nvme0n1 (512 GB, PCIe SSD)Two key values: NIC is eno1, disk is /dev/nvme0n1. These go into the machine config patch.
Phase 4: Generating the Worker Config
Talos machine configs are generated with talosctl gen config. I wrote a patch file with NUC-specific settings:
# patches/worker-nuc.yaml
machine:
network:
hostname: nuc-worker-01
interfaces:
- interface: eno1
dhcp: false
addresses:
- 192.168.50.11/24
routes:
- network: 0.0.0.0/0
gateway: 192.168.50.1
nameservers:
- 192.168.1.1
install:
disk: /dev/nvme0n1
image: factory.talos.dev/installer/613e1592b2da41ae5e265e8789429f22e121aab91cb4deb6bc3c0b6262961245:v1.12.1
kubelet:
extraMounts:
- destination: /var/lib/longhorn
type: bind
source: /var/lib/longhorn
options: [bind, rshared, rw]
kernel:
modules:
- name: nbd
- name: iscsi_tcp
nodeLabels:
node.kubernetes.io/instance-type: talos
homelab.naviauxlab.com/workload-tier: primaryKey decisions:
- Static IP on the servers VLAN (192.168.50.0/24), same subnet as the Dell
- Longhorn mount at
/var/lib/longhorn, required for Longhorn to manage disks - Kernel modules
nbdandiscsi_tcp: Longhorn's iSCSI backend - Node labels:
workload-tier: primaryfor steering heavy workloads here later
Then generated the full worker config:
talosctl gen config homelab https://192.168.50.10:6443 \
--with-secrets ~/.talos/secrets.yaml \
--talos-version v1.12.1 \
--kubernetes-version 1.35.0 \
--output-types worker \
--config-patch-worker @patches/worker-nuc.yaml \
--output nuc-worker-01.yamlThe HostnameConfig Gotcha
talosctl gen config appends a second YAML document: a HostnameConfig with auto: stable. This conflicts with machine.network.hostname in the main config, causing a validation error:
configuration validation failed: static hostname is already set in v1alpha1 configFix: Strip the second document before applying:
python3 -c "
with open('nuc-worker-01.yaml') as f:
content = f.read()
parts = content.split('\n---\n')
with open('nuc-worker-01.yaml', 'w') as f:
f.write(parts[0] + '\n')
"Phase 5: Joining the Cluster
One command:
talosctl apply-config --insecure \
--nodes 192.168.1.215 \
--file nuc-worker-01.yamlThe NUC partitions the NVMe, downloads the installer image from Image Factory, installs Talos, and reboots. After ~3 minutes, it comes up at 192.168.50.11 and kubelet registers with the control plane.
$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP ...
nuc-worker-01 Ready <none> 48s v1.35.0 192.168.50.11
talos-1ue-4vm Ready control-plane 47d v1.35.0 192.168.50.10Don't forget to update your talosctl config to include both endpoints:
talosctl config endpoint 192.168.50.10 192.168.50.11
talosctl config node 192.168.50.10 192.168.50.11Phase 6: Fixing Cilium
The NUC's Cilium pod immediately CrashLooped:
unable to determine direct routing device. Use --direct-routing-device to specify itThe issue: Cilium was deployed with devices: enp55s0u1 (the Dell's NIC name). The NUC has eno1. Cilium couldn't find the device and refused to start.
Since Cilium is Helm-managed in my cluster (installed during Talos bootstrap), the fix was a Helm upgrade:
helm upgrade cilium cilium/cilium -n kube-system \
--version 1.18.5 \
--reuse-values \
--set "devices=en+"en+ is a Cilium device wildcard that matches any interface starting with en, covering both enp55s0u1 (Dell) and eno1 (NUC). After deleting the crashed pod, the DaemonSet recreated it with the new config.
Phase 7: GitOps Changes
With the node joined, I updated the Git repo so Flux would reconfigure everything for multi-node operation.
Cilium L2 Announcement Policy
The L2 policy had a hardcoded interface name. Changed to a regex:
# Before
spec:
interfaces:
- enp55s0u1
# After
spec:
interfaces:
- ^enpLonghorn: HA Replicas
Bumped replica count from 1 to 2 for cross-node redundancy:
defaultReplicaCount: 2 # was 1
csi:
attacher:
replicas: 2 # was 1
provisioner:
replicas: 2
resizer:
replicas: 2
snapshotter:
replicas: 2
longhornUI:
replicas: 2Existing volumes stay at 1 replica until manually upgraded. Longhorn doesn't retroactively change them.
Workload Affinity
I added soft node affinity to all heavy workloads (>256Mi memory limit), preferring the NUC:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: homelab.naviauxlab.com/workload-tier
operator: In
values: ["primary"]Why soft (preferred) instead of hard (required)? If the NUC goes down, workloads fall back to the Dell. The cluster degrades gracefully rather than leaving pods unschedulable.
Workloads steered to the NUC: Prometheus (768Mi), Authentik server (768Mi), Authentik worker (512Mi), Grafana (512Mi), Ghost (384Mi), Loki (256Mi), Life Hub (256Mi), Audiobookshelf (256Mi).
The Result
$ kubectl top nodes
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
nuc-worker-01 2015m 25% 706Mi 2%
talos-1ue-4vm 1693m 21% 13953Mi 92%After Flux reconciled and I upgraded all 17 Longhorn volumes to 2 replicas (cross-node replication), then restarted the heavy workloads:
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
nuc-worker-01 706m 8% 2109Mi 6%
talos-1ue-4vm 680m 8% 9703Mi 64%Dell dropped from 92% → 64%. Authentik server + worker (1.3 Gi combined) migrated to the NUC. The NUC still has 30 GB free.
What Scheduled Automatically (DaemonSets)
- Cilium agent (CNI)
- Alloy (log collector)
- node-exporter (metrics)
- Longhorn manager + CSI plugin
No configuration needed: DaemonSets do their thing.
What Migrated to the NUC
- Authentik server (768Mi limit), no PVC of its own
- Authentik worker (512Mi limit), no PVC of its own
- Longhorn UI: stateless
What Stayed on the Dell (and Why)
Prometheus, Grafana, Ghost, Loki, Life Hub, and Audiobookshelf all stayed on the Dell despite having soft affinity for the NUC. The reason: Longhorn PV node affinity is immutable.
When Longhorn provisions a PV via CSI, it stamps a required node affinity matching the node where the volume was first created. Kubernetes won't schedule a pod to a node that doesn't satisfy the PV's required affinity. And since this field is immutable on bound PVs, you can't just patch it.
The data is replicated to the NUC (all 17 volumes now have 2 replicas), but the scheduler can't place pods there because the PVs say "Dell only."
Options to migrate PVC-bound workloads in the future:
- Longhorn backup + restore: back up the volume, create a new PVC (which provisions on the NUC), restore into it
- Delete and recreate PVCs: destructive, requires data backup first
- New workloads just work: any new PVCs will respect affinity and potentially land on the NUC
For a homelab, this is fine. The memory relief from moving Authentik alone freed 1.3 Gi on the Dell, and new workloads will naturally schedule to the NUC.
What Didn't Go Smoothly
talosctl gen configappends a conflictingHostnameConfigdocument: had to strip it manually before applying- Cilium
deviceswas hardcoded to the Dell's NIC name: the NUC has a different name (eno1vsenp55s0u1), causing CrashLoopBackOff until I changed it to a wildcard - Longhorn PV node affinity is immutable: even with data replicated across both nodes, existing PVCs keep pods pinned to the original node
All fixable, but they're the kind of thing you don't find in the docs until you hit them.
Phase 8: Updating the Dashboard
With two nodes, the single-node Grafana dashboard was useless: cluster-wide averages hide which node is under pressure. I rewrote the Homelab Overview dashboard to show per-node breakdowns.
The key PromQL pattern for per-node metrics:
METRIC * on(instance) group_left(nodename) node_uname_infonode_uname_info has value 1 for every node, with a nodename label containing the hostname. Multiplying any instance-keyed metric by it effectively joins in the friendly name. Use legendFormat: "{{nodename}}" and Grafana shows nuc-worker-01 instead of 192.168.50.11:9100.
Changes:
- CPU, Memory, Disk: replaced single gauges with per-node bar gauges showing both nodes side by side
- Network I/O, Disk I/O: split by node (
nuc-worker-01 RX/TX,talos-1ue-4vm RX/TX) - Load Average: per-node stat panel
- New stat panels: Node count, Total Pods, Longhorn attached volumes
- Tables: added Node column to PVC Usage and Pod Restarts
The dashboard is a ConfigMap labeled grafana_dashboard: "1". Grafana’s sidecar picks it up automatically. No manual import needed, and it stays in sync via GitOps.
Lessons Learned
- Save your
secrets.yamlfrom the initial cluster creation. Extracting it later works, but it's an unnecessary step. - Use device wildcards in Cilium from day one. Hardcoding NIC names works for single-node but breaks the moment you add hardware with different naming.
- Soft affinity > hard affinity for homelabs. You want workloads to prefer the beefier node, not require it. Hardware fails.
- Longhorn replica counts aren't retroactive. New volumes get the new default, but existing volumes need manual patching.
- Longhorn PV node affinity locks volumes to their original node. Data replication doesn't mean pod migration. Plan for this when adding nodes: you may need to backup/restore volumes to truly move workloads.
- Talos maintenance mode is your friend. Being able to query hardware (
get links,get disks) before committing to a config prevents guessing. - Stateless workloads migrate instantly. If your app doesn't have its own PVC (like Authentik server/worker), soft affinity works perfectly. Design for this: externalize state when possible.