search⌘K
search the log…
writing
$sysadmin$cybersecurity$devops$thoughts
homelabs
brasil homelabkubecraft homelab
site
about mecontact
devops · August 31, 2026 · 9 min read

The Dell.
a second node, and the four things Fedora needs first.

Adding a second machine to a one-node cluster. The join itself is one command. Fedora needs four things done first, and skipping any of them fails in a way that does not look like the cause.

k3skubernetesfedorahomelabfirewalld
// what we’re getting into
  1. Why a second node changes anything
  2. Prep one: kill swap, including the one you did not know about
  3. Prep two: firewalld and the two CIDRs
  4. Prep three: SELinux
  5. Prep four: stop the laptop sleeping
  6. The join, which is one command
  7. Labels by capability, not hardware
  8. Final checklist: confirm everything works

Why a second node changes anything

A one-node cluster is a real cluster in the same way a one-person team is a real team. Everything technically works, and none of the interesting problems have shown up yet.

The second node is where scheduling stops being theoretical. Pods start landing somewhere other than where you are typing. Networking has to cross a wire. Storage stops being a detail you can ignore, because a local volume on one machine is invisible to the other.

The machine going in is a Dell Latitude with 32 GB of RAM running Fedora, joining a server that has 16 GB. So the Dell is not just another node, it is the node with the memory, and things that were cramped on the server have somewhere to go.

note: This assumes a working k3s server already. If the control plane is not up yet, that is the post before this one.

Prep one: kill swap, including the one you did not know about

Kubernetes wants swap off. The kubelet makes memory decisions based on real memory, and swap makes those numbers lie.

On Fedora this is not the usual fstab edit. Fedora ships zram, a compressed block device in RAM, and it does not appear in fstab at all. You can comment out every swap line you find and still have swap running.

bash
# what is actually providing swap
swapon --show
#    -> /dev/zram0  partition  8G

sudo dnf remove -y zram-generator-defaults
sudo swapoff -a

# confirm nothing comes back after a reboot
sudo systemctl reboot
swapon --show
#    -> no output

If swapon —show also lists a real swapfile, deal with that separately in fstab. A broken fstab can stop a machine from booting, so verify it parses before you reboot.

bash
grep -i swap /etc/fstab
#    -> /var/swap/swapfile swap swap sw 0 0

sudo sed -i '/ swap / s/^/#/' /etc/fstab
sudo findmnt --verify
sudo systemctl daemon-reload

Prep two: firewalld and the two CIDRs

This is the one that fails in a way that does not look like the cause. Get it wrong and the node joins fine, shows Ready, and then pods on it cannot talk to pods anywhere else. It presents as a DNS problem. It is not a DNS problem.

Two networks need to be trusted, not two ports:

Then two ports on top: 10250/tcp for the kubelet, so the control plane can reach logs and exec, and 8472/udp for the flannel VXLAN tunnel that actually carries pod traffic between machines.

bash
# pod and service CIDRs, trusted wholesale
sudo firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16
sudo firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16

# kubelet and flannel vxlan
sudo firewall-cmd --permanent --add-port=10250/tcp
sudo firewall-cmd --permanent --add-port=8472/udp

sudo firewall-cmd --reload
sudo firewall-cmd --list-all

Miss 8472 specifically and you get the worst version of this: the node joins, pods schedule, everything looks healthy, and cross-node traffic silently goes nowhere.

Prep three: SELinux

Fedora runs SELinux in enforcing mode. k3s needs a policy that lets containerd do what it needs to do, and the install script handles this on its own.

You do not need to do anything here in advance. It is worth knowing it happened, because the installer will pull in k3s-selinux from the Rancher repo mid-install and ask to import a GPG key. That is expected, not a prompt to be suspicious of.

Do not turn SELinux off to make a problem go away. If something is denied, the denial is in the audit log and it will tell you what it wanted.

Prep four: stop the laptop sleeping

The node is a laptop. Laptops sleep when you close them, and a sleeping node is a node the cluster loses.

bash
sudo mkdir -p /etc/systemd/logind.conf.d
sudo vim /etc/systemd/logind.conf.d/99-lid.conf

Put this in it:

ini
[Login]
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore

Then apply it and mask the sleep targets as a backstop:

bash
sudo systemctl restart systemd-logind
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target

# see who is holding the lid switch
systemd-inhibit --list

Check that last command’s output. If a desktop environment appears in the list holding handle-lid-switch, it has taken the lid switch away from logind and your config file is being ignored. The desktop’s own power settings have to be changed too, and on a node the cleaner answer is to stop booting into a desktop at all.

The join, which is one command

Get the token and the version from the server:

bash
# on the server
sudo cat /var/lib/rancher/k3s/server/node-token
#    -> K10<long string>

k3s --version
#    -> k3s version v1.36.2+k3s1

Before installing anything, confirm the Dell can actually reach the API:

bash
# from the Dell, before installing anything
curl -k https://192.168.1.50:6443
#    -> {"kind":"Status", ... "reason":"Unauthorized","code":401}

A 401 Unauthorized is the good answer. It means the API server is there and talking. A timeout means the network path is wrong and nothing after this will work.

Then the join itself:

bash
curl -sfL https://get.k3s.io | \
  K3S_URL=https://192.168.1.50:6443 \
  K3S_TOKEN=K10<your-token> sh -

Setting K3S_URL is what makes this an agent rather than a second server. Leave it out and you get a second control plane, which is a different post and a different set of problems.

bash
sudo systemctl status k3s-agent --no-pager
#    -> Active: active (running)
#    -> k3s agent is up and running
note: kubectl does not work on the agent. Agents get no kubeconfig, so it tries localhost:8080 and gets connection refused. That is correct behaviour, not a broken install. Run kubectl on the server.
bash
# on the server, not the agent
kubectl get nodes -o wide

The agent may install a slightly newer patch version than the server. That is fine in this direction. Agents can be newer than the control plane, never older.

Labels by capability, not hardware

A label is a tag on a node. A nodeSelector on a pod says “only put me somewhere with this tag.”

The temptation is to label by machine name. Label by what the machine can do instead. If the Dell gets replaced next year, you move one label and every manifest still works. Label it dell and you are editing YAML for an afternoon.

bash
kubectl label node dell-node workload=heavy
kubectl label node k3s-server workload=light

kubectl get nodes -L workload

The ROLES column showing <none> for a worker is normal. That column reads a different label entirely and is purely cosmetic. Any node without a control-plane taint already runs workloads.

Then prove it actually works, because a selector that matches nothing leaves a pod Pending forever with no obvious complaint:

bash
kubectl run placement-test --image=busybox --restart=Never \
  --overrides='{"spec":{"nodeSelector":{"workload":"heavy"}}}' \
  -- sleep 60

kubectl get pod placement-test -o wide
#    -> NODE column should read dell-node

kubectl delete pod placement-test

If the pod lands on the Dell, scheduling is working, the label is right, and cross-node networking is up. That one test covers all three.

worth knowing before you move anything: local-path storage is node-local. A PVC binds to whichever node the pod first landed on and cannot move. Adding a nodeSelector to an existing stateful workload will leave it Pending forever, because it cannot reach its own volume. Moving it means deleting the PVC and letting it recreate, which means re-downloading whatever was in it.

Final checklist: confirm everything works

If every check below passes, this post’s work is done and verified.

bash
# 1. Swap is off and stays off across a reboot
swapon --show
#    -> no output

# 2. The firewall trusts the pod and service networks
sudo firewall-cmd --list-all | grep -A2 sources

# 3. The agent is running on the worker
sudo systemctl is-active k3s-agent
#    -> active

# 4. The server sees the node as Ready
kubectl get nodes
#    -> dell-node   Ready   <none>

# 5. The label is applied
kubectl get nodes -L workload

# 6. A pod with the nodeSelector actually lands there
kubectl run placement-test --image=busybox --restart=Never \
  --overrides='{"spec":{"nodeSelector":{"workload":"heavy"}}}' -- sleep 30
kubectl get pod placement-test -o wide
kubectl delete pod placement-test

# 7. Eyeball check: close the laptop lid, wait five minutes,
#    confirm the node is still Ready from the server

Two nodes, split by capability, with a test that proves placement rather than assuming it. The next thing that gets interesting is what happens when one of them goes away.

next post
K9s: navigating a cluster without living in kubectl