Building a Two-Node Upstream Kubernetes Cluster on Intel NUCs

Built on November 11, 2026.

What we built and why

I wanted a small Kubernetes cluster at home, no distro wrappers and no single-binary shortcuts. As lazy as I am, I wanted to go back to the roots of Kubernetes by just using upstream Kubernetes installed with kubeadm.

The cluster runs on two Intel NUCs for now so I can show more than one node and add as I go. k8s-cp1 is the control plane. k8s-worker1 is the first worker. Both sit on the same home LAN, and I all cluster traffic stays on that network.

Both NUCs are also on my Tailscale tailnet. I use that to run kubectl from my Mac wherever I am. The API server certificate includes the Tailscale IP and MagicDNS name, so remote access works without exposing anything to the internet.

One more wrinkle. k8s-cp1 already runs Docker with a monitoring stack and some automation tools. Those containers had to keep running through the whole build. That affected how I set up the container runtime so I figured I would show that part of the process as well.

The cluster is called labcluster for the docs here but I’d suggest that you choose a more fun name for yours 🙂

Hardware and network

Itemk8s-cp1k8s-worker1
RoleControl planeWorker
CPUIntel Core i5-6260U, 4 threadsIntel Core i5-5250U, 4 threads
RAM32 GB16 GB
OSUbuntu 24.04.4 LTSUbuntu 24.04.4 LTS
Kernel6.8.0-136-generic6.8.0-147-generic
LAN interfaceeno1eno1
LAN IP192.168.10.11192.168.10.21
Tailscale IP100.64.0.11100.64.0.21
Existing workloadsDocker (a monitoring stack and a few self-hosted apps)None
Container runtimecontainerd 2.2.4 (from Docker)containerd 2.4.2 (from Docker’s repo)

Network plan

RangeUse
192.168.10.0/24Home LAN and node IPs
10.244.0.0/16Pod network (Calico)
10.96.0.0/12Kubernetes services
172.17-19.0.0/16Existing Docker bridges on k8s-cp1 (left alone)
100.64.0.0/10Tailscale

The pod and service ranges avoid the LAN, the Docker bridges and Tailscale. Overlapping ranges cause routing problems that are painful to track down later…trust me on this as a life lesson to avoid.

Versions

ComponentVersion
Kubernetes (kubeadm, kubelet, kubectl)v1.37.2
containerd on k8s-cp12.2.4 (containerd.io from Docker)
containerd on k8s-worker12.4.2 (containerd.io from Docker’s apt repo)
Calicov3.33.0 (Tigera operator)
kubectl on my Macv1.36.1

Prerequisites

  • Two Ubuntu 24.04 machines on the same LAN
  • Root or sudo on both
  • Tailscale installed on both nodes and on the machine you will run kubectl from
  • At least 2 CPUs and 2 GB RAM per node (kubeadm checks this)
  • Unique hostnames, MAC addresses and product UUIDs on each node

Check the current stable release before you start.

curl -s https://dl.k8s.io/release/stable.txt
# v1.37.2 at build time

Step 1. Record what is already running

k8s-cp1 had Docker containers I could not afford to lose. I captured their state first so I had something to compare against.

docker ps --format '{{.Names}}\t{{.Status}}'

All nine containers showed Up. I checked this list again after every risky step.

Step 2. Turn off swap

The kubelet expects swap to be off by default. Run this on both nodes.

swapoff -a
sed -i.bak-k8s -E 's@^([^#].*\sswap\s.*)$@#\1@' /etc/fstab
grep swap /etc/fstab
swapon --show

The sed line comments out the swap entry so it stays off after a reboot. It also keeps a backup at /etc/fstab.bak-k8s.

Step 3. Kernel modules and sysctl

Kubernetes networking needs the overlay and br_netfilter modules. It also needs bridged traffic to pass through iptables and IP forwarding turned on. Run this on both nodes.

cat > /etc/modules-load.d/k8s.conf <<EOT
overlay
br_netfilter
EOT

modprobe overlay
modprobe br_netfilter

cat > /etc/sysctl.d/99-kubernetes.conf <<EOT
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOT

sysctl --system
sysctl net.bridge.bridge-nf-call-iptables net.ipv4.ip_forward

Step 4. Container runtime

k8s-cp1: reuse Docker’s containerd

Docker already installs containerd as the containerd.io package, and Kubernetes can use that same containerd. There is one catch…Docker ships a config file that turns off the CRI plugin, and the kubelet needs CRI to talk to containerd.

This is the whole Docker-provided config.

disabled_plugins = ["cri"]

I backed it up and replaced it with the full default config. Then I switched the runc cgroup driver to systemd to match the kubelet.

cp -a /etc/containerd/config.toml /etc/containerd/config.toml.bak-pre-k8s
containerd config default > /tmp/ctd.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /tmp/ctd.toml
install -m 644 /tmp/ctd.toml /etc/containerd/config.toml

The only change from the stock default is one line.

-            SystemdCgroup = false
+            SystemdCgroup = true

That line lives under this section in the containerd 2.x config.

[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
  SystemdCgroup = true

Now the part that made me nervous. Restarting containerd on a box with live Docker workloads. Each running container has its own containerd shim process, and the shims keep running when containerd restarts. Once containerd is back up, Docker reconnects. The default config also keeps the same root at /var/lib/containerd, so Docker’s images and snapshots stay where Docker expects them.

systemctl restart containerd
sleep 8
systemctl is-active containerd docker
docker ps -a --format '{{.Names}}\t{{.Status}}'
ctr plugins ls | grep -i cri

Every container still showed its original uptime. None of them restarted. The CRI plugin showed ok. Woot!

io.containerd.cri.v1    images     -              ok
io.containerd.cri.v1    runtime    linux/amd64    ok
io.containerd.grpc.v1   cri        -              ok

If Docker has live-restore off, you can still expect this to work. Do it during a quiet window anyway, and have your docker compose files handy.

k8s-worker1: install containerd from Docker’s repo

k8s-worker1 had no runtime. I installed containerd.io from Docker’s apt repo so both nodes run containerd 2.x.

export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl gpg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  > /etc/apt/sources.list.d/docker.list

apt-get update
apt-get install -y containerd.io

mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl enable containerd
systemctl restart containerd
containerd --version

The resulting repo line looks like this.

deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable

This only installs containerd, not the Docker engine.

Step 5. Install kubeadm, kubelet and kubectl

Use the official pkgs.k8s.io repo. Each Kubernetes minor version has its own repo path, so set it to match the stable release. Run this on both nodes.

export DEBIAN_FRONTEND=noninteractive
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.37/deb/Release.key \
  | gpg --dearmor --yes -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /' \
  > /etc/apt/sources.list.d/kubernetes.list

apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y kubelet kubeadm kubectl cri-tools
apt-mark hold kubelet kubeadm kubectl
systemctl enable kubelet

The hold keeps routine apt upgrades from moving Kubernetes versions under you. Upgrades should go through kubeadm upgrade.

Point crictl at containerd so you can debug pods at the runtime level.

cat > /etc/crictl.yaml <<EOT
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
EOT

kubeadm version -o short
kubelet --version
crictl info > /dev/null && echo crictl-ok

Step 6. Initialize the control plane

I used a kubeadm config file instead of a long list of flags. It is easier to read and easy to keep in git.

Find the node’s MagicDNS name first.

tailscale status --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"])'

Here is the config I used on k8s-cp1, saved as /root/kubeadm/init.yaml. Replace with your own tailnet name.

apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: 192.168.10.11
  bindPort: 6443
nodeRegistration:
  name: k8s-cp1
  criSocket: unix:///run/containerd/containerd.sock
  kubeletExtraArgs:
  - name: node-ip
    value: 192.168.10.11
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.37.2
clusterName: labcluster
controlPlaneEndpoint: 192.168.10.11:6443
networking:
  podSubnet: 10.244.0.0/16
  serviceSubnet: 10.96.0.0/12
apiServer:
  certSANs:
  - 192.168.10.11
  - 100.64.0.11
  - k8s-cp1
  - k8s-cp1.<tailnet>.ts.net
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd

A few choices in this file are worth calling out.

  • advertiseAddress and node-ip pin the control plane to the LAN IP. Without them the kubelet may pick the Tailscale or Docker bridge address.
  • certSANs adds the Tailscale IP, the short hostname and the MagicDNS name to the API server certificate. That is what lets kubectl connect over Tailscale with TLS checks intact.
  • cgroupDriver systemd matches the containerd setting from Step 4.

Run the init and set up kubectl for root and my regular user.

kubeadm init --config /root/kubeadm/init.yaml

mkdir -p /root/.kube
cp /etc/kubernetes/admin.conf /root/.kube/config

install -d -o <user> -g <user> /home/<user>/.kube
install -m 600 -o <user> -g <user> /etc/kubernetes/admin.conf /home/<user>/.kube/config

kubectl get nodes

The node shows NotReady at this point. That is normal until a pod network is installed.

Step 7. Install Calico

I used the Tigera operator for Calico v3.33.0. From 3.30 on, the CRDs ship in their own manifest.

V=v3.33.0
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/$V/manifests/operator-crds.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/$V/manifests/tigera-operator.yaml
kubectl wait --for=condition=Established crd/installations.operator.tigera.io --timeout=60s

The stock custom-resources.yaml uses 192.168.0.0/16 for pods. I changed it to match the podSubnet in the kubeadm config. I also told Calico which network to use for node addresses.

Saved as /root/kubeadm/calico-cr.yaml.

apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  calicoNetwork:
    nodeAddressAutodetectionV4:
      cidrs:
      - 192.168.10.0/24
    ipPools:
    - name: default-ipv4-ippool
      blockSize: 26
      cidr: 10.244.0.0/16
      encapsulation: VXLANCrossSubnet
      natOutgoing: Enabled
      nodeSelector: all()
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
  name: default
spec: {}
---
apiVersion: operator.tigera.io/v1
kind: Goldmane
metadata:
  name: default
---
apiVersion: operator.tigera.io/v1
kind: Whisker
metadata:
  name: default
kubectl create -f /root/kubeadm/calico-cr.yaml

Why LAN IP autodetection

Calico picks a node address on its own by default, however each NUC has many interfaces. There is the LAN NIC, tailscale0 and on k8s-cp1 several Docker bridges. If Calico picks tailscale0, pod traffic between nodes rides the Tailscale tunnel. If it picks a Docker bridge, cross-node pod traffic breaks. Pinning detection to 192.168.10.0/24 makes Calico use the LAN every time.

Why VXLANCrossSubnet

Both nodes share one subnet, so Calico routes pod traffic directly with no encapsulation. If I add a node on another subnet later, Calico wraps that traffic in VXLAN automatically. VXLAN also avoids needing BGP on my home router.

The IPPool Calico created matches the plan.

{"allowedUses":["Workload","Tunnel"],"assignmentMode":"Automatic","blockSize":26,"cidr":"10.244.0.0/16","ipipMode":"Never","natOutgoing":true,"nodeSelector":"all()","vxlanMode":"CrossSubnet"}

Check that each node got its LAN address.

kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.metadata.annotations.projectcalico\.org/IPv4Address}{"\n"}{end}'
k8s-worker1 192.168.10.21/24
k8s-cp1 192.168.10.11/24

Give the operator a minute or two to settle. Some components report Degraded early on while the Calico API server starts. They clear on their own.

kubectl get tigerastatus

Step 8. Join the worker

Print a fresh join command on the control plane.

kubeadm token create --print-join-command

I used a join config file instead of the raw command so I could pin the node IP. Saved on k8s-worker1 as /root/kubeadm/join.yaml with mode 600.

apiVersion: kubeadm.k8s.io/v1beta4
kind: JoinConfiguration
discovery:
  bootstrapToken:
    apiServerEndpoint: 192.168.10.11:6443
    token: <token>
    caCertHashes:
    - sha256:<hash>
nodeRegistration:
  name: k8s-worker1
  criSocket: unix:///run/containerd/containerd.sock
  kubeletExtraArgs:
  - name: node-ip
    value: 192.168.10.21
kubeadm join --config /root/kubeadm/join.yaml
rm -f /root/kubeadm/join.yaml

Bootstrap tokens expire after 24 hours. Delete the file after the join anyway since it holds a credential.

Back on the control plane, label the worker and wait for both nodes.

kubectl label node k8s-worker1 node-role.kubernetes.io/worker= --overwrite
kubectl wait --for=condition=Ready node --all --timeout=300s

Step 9. Verify

Nodes

kubectl get nodes -o wide
NAME        STATUS   ROLES           AGE     VERSION   INTERNAL-IP   EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION              CONTAINER-RUNTIME
k8s-worker1   Ready    worker          8m31s   v1.37.2   192.168.10.21    <none>        Ubuntu 24.04.4 LTS   6.8.0-147-generic (amd64)   containerd://2.4.2
k8s-cp1       Ready    control-plane   9m12s   v1.37.2   192.168.10.11    <none>        Ubuntu 24.04.4 LTS   6.8.0-136-generic (amd64)   containerd://2.2.4

Pods

kubectl get pods -A
NAMESPACE         NAME                                       READY   STATUS    RESTARTS   AGE
calico-system     calico-apiserver-69c6474754-pp4xx          1/1     Running   0          8m34s
calico-system     calico-apiserver-69c6474754-zgbmm          1/1     Running   0          8m34s
calico-system     calico-kube-controllers-7c9969d9c5-wbls4   1/1     Running   0          8m33s
calico-system     calico-node-6kdzm                          1/1     Running   0          8m33s
calico-system     calico-node-r722q                          1/1     Running   0          8m30s
calico-system     calico-typha-69789f988-h6lxg               1/1     Running   0          8m33s
calico-system     csi-node-driver-fnngm                      2/2     Running   0          8m33s
calico-system     csi-node-driver-lskjd                      2/2     Running   0          8m30s
calico-system     goldmane-7fb6b9c74b-794qb                  1/1     Running   0          8m34s
calico-system     whisker-578fb565d5-b6hsz                   2/2     Running   0          8m13s
kube-system       coredns-559f6c778d-gdkbh                   1/1     Running   0          9m2s
kube-system       coredns-559f6c778d-jbf64                   1/1     Running   0          9m2s
kube-system       etcd-k8s-cp1                             1/1     Running   0          9m8s
kube-system       kube-apiserver-k8s-cp1                   1/1     Running   0          9m8s
kube-system       kube-controller-manager-k8s-cp1          1/1     Running   0          9m9s
kube-system       kube-proxy-6p9ph                           1/1     Running   0          8m30s
kube-system       kube-proxy-fsnzz                           1/1     Running   0          9m2s
kube-system       kube-scheduler-k8s-cp1                   1/1     Running   0          9m9s
tigera-operator   tigera-operator-7c96ff8686-dfwx6           1/1     Running   0          8m45s
kubectl get tigerastatus
NAME        AVAILABLE   PROGRESSING   DEGRADED   SINCE   MESSAGE
apiserver   True        False         False      7m39s   All objects available
calico      True        False         False      7m49s   All objects available
goldmane    True        False         False      7m29s   All objects available
ippools     True        False         False      8m29s   All objects available
tiers       True        False         False      7m34s   All objects available
whisker     True        False         False      7m9s    All objects available

Workload test

This runs two nginx pods on the worker and hits them through a ClusterIP from the control plane. It proves scheduling, cross-node pod networking and kube-proxy all work.

kubectl create deployment nginx-test --image=nginx:stable --replicas=2
kubectl patch deployment nginx-test \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"k8s-worker1"}}}}}'
kubectl expose deployment nginx-test --port 80
kubectl rollout status deployment nginx-test --timeout=180s
kubectl get pods -l app=nginx-test -o wide

IP=$(kubectl get svc nginx-test -o jsonpath='{.spec.clusterIP}')
curl -s -m 10 http://$IP | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>

DNS test

kubectl run dnstest --image=busybox:1.36 --restart=Never \
  --overrides='{"spec":{"nodeSelector":{"kubernetes.io/hostname":"k8s-worker1"}}}' \
  --command -- nslookup kubernetes.default.svc.cluster.local
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/dnstest --timeout=60s
kubectl logs dnstest
Name:   kubernetes.default.svc.cluster.local
Address: 10.96.0.1

Clean up.

kubectl delete pod dnstest
kubectl delete svc,deployment nginx-test

Docker workloads

Last check on k8s-cp1.

docker ps --format '{{.Names}}\t{{.Status}}'

All nine containers were still Up with their original uptime of weeks and months. The published ports still answered locally.

Step 10. kubectl from my Mac over Tailscale

The admin kubeconfig points at the LAN address 192.168.10.11. I copied it to my Mac and swapped the server for the Tailscale IP. The cert already covers that IP from Step 6.

I kept it in its own file so it would not touch any existing ~/.kube/config.

mkdir -p ~/.kube
umask 077
ssh root@k8s-cp1 'cat /etc/kubernetes/admin.conf' \
  | sed 's#server: https://192.168.10.11:6443#server: https://100.64.0.11:6443#' \
  > ~/.kube/labcluster.config
chmod 600 ~/.kube/labcluster.config

kubectl --kubeconfig ~/.kube/labcluster.config get nodes
NAME        STATUS   ROLES           AGE     VERSION
k8s-worker1   Ready    worker          2m44s   v1.37.2
k8s-cp1       Ready    control-plane   3m25s   v1.37.2

You can use the MagicDNS name instead of the IP. Both are in the certificate, thankfully.

This file is full cluster admin, so please treat it like a private key! For daily use I create a less privileged user and only elevate rights when needed.

Gotchas that Got Me

Docker’s containerd config disables CRI

The containerd.io package from Docker writes a one-line config that disables the CRI plugin. kubeadm fails its preflight checks until you fix that. Back up the file, generate the default config and set SystemdCgroup to true. Restarting containerd did not restart any Docker containers.

apt lock from unattended upgrades

The first package install on k8s-worker1 failed with “Could not get lock /var/lib/dpkg/lock-frontend”. An automatic upgrade run was holding it. Adding a lock timeout to apt fixed it without killing anything.

apt-get -o DPkg::Lock::Timeout=300 install -y kubelet kubeadm kubectl cri-tools

needrestart held back docker.service

After the package installs on k8s-cp1, Ubuntu’s needrestart listed docker.service as a deferred restart. I left it deferred on purpose. Restarting Docker would bounce every container on the box. Do that later in a maintenance window.

kubectl version skew

My Mac had kubectl v1.36.1 and the cluster runs v1.37.2. kubectl supports one minor version of skew in either direction, so it works. I will upgrade the Mac client to stay current.

Calico status looks broken at first

Right after applying the Calico resources, tigerastatus showed goldmane and whisker as Degraded with API errors. The Calico API server was still starting, and everything went green within about two minutes. Sometimes patience truly is a virtue.

Next in the Backlog…Digital Rebar

Part two of this project is getting Digital Rebar from RackN. The plan is to run the Digital Rebar endpoint on k8s-cp1 and enroll both NUCs as existing machines. No PXE reimaging to start.

Then I will rebuild this same kubeadm cluster as a Digital Rebar workflow and compare it with the hand-built version above. More on that soon!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Sign In

Register

Reset Password

Please enter your username or email address, you will receive a link to create a new password via email.