You’re About to Run Kubernetes at Home (Buckle Up)
Kubernetes feels like hiring a forklift to move a couch. Technically it works, but your neighbors will have questions. k3s is the smaller machine that still does the job. It’s Kubernetes that fits on hardware you can hold in one hand, and today we’re building a three-node cluster from bare metal.
This isn’t a “deploy to the cloud” tutorial. We’re taking three mini PCs, connecting them with Ethernet, installing k3s, wiring up persistent storage, and deploying a workload that answers on a real IP. No managed control plane. No hand-waving. Just you, three boxes, and the weight of responsibility that comes with running your own infrastructure.
Let’s go.
Why Three Nodes (And What They Don’t Give You)
A single-node cluster is real Kubernetes. One k3s server runs the datastore, the control plane, the kubelet, and the container runtime, and it happily hosts pods. Three nodes buy you two specific things:
- Workload redundancy: kill a worker and the scheduler restarts its pods on the survivors, roughly five minutes later with default tolerations
- Room to spread: three machines with 16GB each beats one machine with 16GB once you start stacking services
Now the part most three-node walkthroughs skip. This build gives you one server node and two agents. The control plane is not redundant. If node-1 dies, pods already running on the other two keep serving traffic, but nothing reschedules, kubectl stops answering, and no self-healing happens until you bring that box back.
Control plane high availability in k3s means three server nodes with embedded etcd, started with --cluster-init, talking to each other on TCP 2379 and 2380. That’s a heavier cluster and a different article. Build this one first, learn where the moving parts live, then promote it. If you want the recovery story before you need it, k3s Backups: etcd Snapshots + Velero covers snapshots.
Mini PCs (Beelink, Minisforum, an old ThinkCentre off eBay) fit this job because they’re cheap, around $200 to $400 each, quiet, and light on power. No fan noise at 2 AM, no power bill that makes you weep. And if one dies, you’ve lost $300 instead of $3,000.
Before You Start: Names, IPs, and Ports
Most people wing this part. Addresses “just happen”, DHCP moves something at the worst possible moment, and the cluster spends a week gaslighting you. Ten minutes of planning buys that week back.
Write this down:
- Cluster CIDR (pods):
10.42.0.0/16, the k3s default, keep it - Service CIDR (services):
10.43.0.0/16, also default, keep it - Host network:
192.168.1.0/24, your home LAN - Node IPs (static):
192.168.1.100: node-1, the server192.168.1.101: node-2, agent192.168.1.102: node-3, agent
Why static? Services get stable DNS names inside the cluster, but the nodes find each other by IP. Let those float and you’ll donate an evening to troubleshooting.
Set the hostname first
Kubernetes takes node names from the machine hostname, and renaming a node after it joins the cluster is a chore involving deletion and re-registration. Do it before you install anything:
sudo hostnamectl set-hostname node-1 # node-2 and node-3 on the othersSet static IPs
Ubuntu 26.04 LTS below, adjust for your distro. Check the interface name first, because on Ubuntu Server it’s almost never eth0:
ip -br linkThen write the netplan config:
network: version: 2 ethernets: enp1s0: # use the name from `ip -br link` dhcp4: no addresses: - 192.168.1.100/24 # .101 and .102 on the other nodes nameservers: addresses: [1.1.1.1, 9.9.9.9] routes: - to: default via: 192.168.1.1Netplan wants that file locked down, or it complains every time you touch it:
sudo chmod 600 /etc/netplan/01-netcfg.yamlsudo netplan applyip addr show enp1s0Open the ports
Skip this and your nodes will join, then act haunted an hour later. k3s needs these reachable between machines:
| Port | Protocol | Direction | What breaks without it |
|---|---|---|---|
| 6443 | TCP | agents to server | Nodes can’t join, kubectl can’t connect |
| 8472 | UDP | all nodes | Flannel VXLAN, so pod-to-pod traffic across nodes |
| 10250 | TCP | all nodes | Kubelet metrics, so kubectl top and logs |
If you keep ufw enabled, on every node:
sudo ufw allow 6443/tcpsudo ufw allow 8472/udpsudo ufw allow 10250/tcpsudo ufw allow from 10.42.0.0/16sudo ufw allow from 10.43.0.0/16Keep 8472 on your LAN only. Port-forward the VXLAN port to the internet and you’ve handed strangers a seat inside your cluster network.
Do all of the above on all three machines. Your 2 AM self will appreciate it.
Installing k3s: The Control Plane (node-1)
SSH into the box at 192.168.1.100. Update it and install the basics:
sudo apt-get update && sudo apt-get upgrade -ysudo apt-get install -y curl wget git htopInstall the k3s server:
curl -sfL https://get.k3s.io | sh -That’s it. Wait 30 to 60 seconds. k3s installs itself, bootstraps its datastore (SQLite by default on a single server), starts the control plane, and deploys its packaged components: CoreDNS, Traefik, ServiceLB, metrics-server, and the local-path storage provisioner. Remember that list. Two of those items decide how the rest of this article goes.
Check the node:
sudo k3s kubectl get nodesNAME STATUS ROLES AGE VERSIONnode-1 Ready control-plane,master 2m v1.36.4+k3s1Now pull the kubeconfig down to your laptop so you can stop typing sudo k3s kubectl:
# on your laptopmkdir -p ~/.kubesed -i 's|127.0.0.1|192.168.1.100|' ~/.kube/configchmod 600 ~/.kube/configThe k3s server certificate already lists the node’s own IP, so swapping 127.0.0.1 for 192.168.1.100 works with no extra flags. Any other name, like a DNS record or a reverse proxy address, needs --tls-san at install time.
Test it:
kubectl get nodesIf you see node-1 Ready, you’re golden. If it hangs, TCP 6443 isn’t reachable from your laptop.
Adding Workers (node-2 and node-3)
Grab the join token from node-1:
sudo cat /var/lib/rancher/k3s/server/node-tokenOn node-2, install k3s as an agent instead of a server:
curl -sfL https://get.k3s.io | K3S_URL=https://192.168.1.100:6443 K3S_TOKEN=<paste-token-here> sh -Wait 30 seconds. Repeat on node-3. Then check from your laptop:
kubectl get nodes -o wideNAME STATUS ROLES AGE VERSION INTERNAL-IPnode-1 Ready control-plane,master 10m v1.36.4+k3s1 192.168.1.100node-2 Ready <none> 2m v1.36.4+k3s1 192.168.1.101node-3 Ready <none> 1m v1.36.4+k3s1 192.168.1.102Your terminal shows four more columns after that (external IP, OS image, kernel, container runtime). I’ve trimmed them here, and I do the same to the wide output further down, so the interesting parts fit on the page.
The empty ROLES column on the agents is correct. They run workloads and nothing else. Congrats, you have a Kubernetes cluster. The install was the easy half.
Persistent Storage: Making Data Stick Around
Kubernetes throws your data away by default. Pods die, and poof, the container filesystem goes with them. You need a volume that outlives the pod.
k3s ships the local-path provisioner and makes it the default StorageClass, which is plenty for a home lab. It carves a directory on a node’s own disk. Look at it:
kubectl get storageclassNAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGElocal-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 12mThat WaitForFirstConsumer is the detail that trips up every first-time k3s user, so read it twice. A claim does nothing on its own. The provisioner waits until a pod is scheduled, because only then does it know which node’s disk to carve the directory on.
Create a claim:
apiVersion: v1kind: PersistentVolumeClaimmetadata: name: test-storagespec: accessModes: - ReadWriteOnce storageClassName: local-path resources: requests: storage: 5Gikubectl apply -f test-pvc.yamlkubectl get pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEtest-storage Pending local-path 8sPending here is correct, not broken. Nothing consumes the claim yet. Give it a consumer:
apiVersion: v1kind: Podmetadata: name: pvc-writerspec: containers: - name: writer image: busybox:1.37 command: ["sh", "-c", "echo written by $(hostname) > /data/proof.txt && sleep 3600"] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: test-storagekubectl apply -f pvc-writer.yamlkubectl get pvcNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEtest-storage Bound pvc-9f3c1a02-4d17-4a3e-9b21-6f0c8d2e77aa 5Gi RWO local-path 54sRead the file back to prove the mount works:
kubectl exec pvc-writer -- cat /data/proof.txtThe real directory sits under /var/lib/rancher/k3s/storage/ on whichever node the pod landed on. Which is also the catch: that volume is pinned to that one disk. Lose the node, lose the data. When you outgrow it, Longhorn vs OpenEBS for k3s Storage walks through replicated block storage.
Clean up before moving on:
kubectl delete pod pvc-writerkubectl delete pvc test-storageYour First Real Deployment (On a Port That’s Free)
Time to deploy something boring on purpose: an Nginx web server. If Nginx won’t serve, nothing else will.
First, the trap that eats the classic hello-world walkthrough. k3s installed Traefik for you, and Traefik’s own service is type LoadBalancer on ports 80 and 443. ServiceLB implements that by running a DaemonSet that binds those host ports on every node in the cluster. Point a second LoadBalancer service at port 80 and its svclb pods can never schedule, so the service sits forever with no external IP while curl on port 80 hands you Traefik’s 404.
You have two clean options: put the demo on a free port, or reinstall k3s with --disable=traefik and own port 80 yourself. We’ll take the free port, because Traefik is the ingress controller you’ll want later anyway.
apiVersion: apps/v1kind: Deploymentmetadata: name: nginx-demospec: replicas: 3 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:alpine ports: - containerPort: 80 resources: requests: memory: "64Mi" cpu: "100m" limits: memory: "128Mi" cpu: "200m"---apiVersion: v1kind: Servicemetadata: name: nginx-demospec: type: LoadBalancer selector: app: nginx-demo ports: - protocol: TCP port: 8080 # 80 and 443 belong to Traefik targetPort: 80Deploy it and watch the pods land:
kubectl apply -f nginx-deployment.yamlkubectl get pods -o wide -wNAME READY STATUS RESTARTS AGE IP NODEnginx-demo-6c4d8f5fbc-abc12 1/1 Running 0 45s 10.42.1.14 node-2nginx-demo-6c4d8f5fbc-def34 1/1 Running 0 40s 10.42.0.22 node-1nginx-demo-6c4d8f5fbc-ghi56 1/1 Running 0 35s 10.42.2.9 node-3Those pod IPs come out of the cluster CIDR you wrote down earlier, one /24 slice per node.
The scheduler spreads replicas of the same service across nodes when it can, so one pod per box is the usual result on an idle cluster. Now the service:
kubectl get svc nginx-demoNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEnginx-demo LoadBalancer 10.43.12.34 192.168.1.100,192.168.1.101,192.168.1.102 8080:31234/TCP 90sEvery node running a svclb pod gets advertised, which is why all three IPs show up. Any of them answers:
curl -s http://192.168.1.101:8080 | head -4You get the Nginx welcome page HTML. That’s a real application, on hardware you own, reachable from the couch.
Reaching the Cluster From Somewhere Else
Away from home, tunnel the API port over SSH:
Then use a second kubeconfig context pointing at https://127.0.0.1:6443. The server certificate covers 127.0.0.1 out of the box, so nothing else changes.
Better long term: put the whole home lab behind a VPN and use the LAN addresses from anywhere. Wireguard VPN Server in Docker gets that running in an afternoon.
Monitoring: Is My Cluster Healthy?
Good news, you already have metrics-server. k3s deploys it as a packaged component, so this works right now with nothing installed:
kubectl top nodesNAME CPU(cores) CPU% MEMORY(bytes) MEMORY%node-1 412m 10% 3218Mi 20%node-2 180m 4% 1704Mi 10%node-3 206m 5% 1902Mi 12%Do not apply the upstream metrics-server manifest on top of this. It collides with the packaged copy, k3s re-applies its own manifest on restart, and the upstream defaults reject the k3s kubelet’s self-signed certificate until you add --kubelet-insecure-tls. If you need a custom build, start the server with --disable=metrics-server and then deploy your own.
To find what’s actually eating the cluster:
kubectl top pods -A --sort-by=memory | head -10Graphs over time come later, with Prometheus and Grafana.
Common Gotchas
“Pods are stuck in ImagePullBackOff”: your nodes can’t reach the registry, or you hit Docker Hub’s anonymous pull limit. Run kubectl describe pod <name> and read the events for the real error, then authenticate to the registry or run a pull-through cache.
“My LoadBalancer service never gets an external IP”: something already owns that host port, and it’s usually Traefik on 80 and 443. Run kubectl -n kube-system get pods | grep svclb and look for pods stuck in Pending. Move your service to a free port.
“Nodes join, then go NotReady”: check UDP 8472 between the machines, then check the clocks. Run timedatectl on each node and confirm NTP sync, because certificate validation fails on a box that thinks it’s still 2019.
“My PersistentVolumes are all on one node”: yes, that’s local-path behaving as designed. It doesn’t replicate. Move to Longhorn or an NFS-backed StorageClass when the data matters.
“kubectl works on the server but not from my laptop”: TCP 6443 has to be reachable, and your kubeconfig has to name an address that’s in the server certificate. The node IP and 127.0.0.1 are covered by default. Anything else needs --tls-san at install time.
The Path Forward
You have a working three-node cluster. Sensible next moves:
- Ingress: route by hostname using the Traefik that’s already running. Ingress Choices in k3s: Traefik vs ingress-nginx vs HAProxy compares the alternatives.
- Replicated storage: Longhorn, so a dead node stops meaning dead data.
- Real control plane HA: rebuild with three servers and
--cluster-initonce a single reboot taking outkubectlstarts to bother you. - Monitoring: Prometheus and Grafana for history instead of snapshots.
- GitOps: ArgoCD reconciles the cluster against a Git repo, so your YAML stops living in a random directory.
- Secrets: Sealed Secrets or an external store, so your manifests stop carrying base64 passwords.
None of that is required today. You have a cluster. Deploy things on it. Break them. Read the logs.
Three boxes, network cables, and logs you can actually read. Your 2 AM self thanks you already.
Common Questions
Do three nodes give me high availability in k3s?
No. Three nodes with one server node give you workload redundancy only. Control plane high availability needs three server nodes started with --cluster-init and embedded etcd, reachable on TCP 2379 and 2380. One server node stays a single point of failure regardless of how many agents join it.
Why is my k3s PersistentVolumeClaim stuck in Pending?
Because the default local-path StorageClass uses WaitForFirstConsumer binding. The provisioner waits for a pod to be scheduled so it can create the directory on that pod’s node. Create a pod that mounts the claim and the claim binds within seconds. A Pending claim with no consumer is expected behavior.
Can I expose a LoadBalancer service on port 80 in k3s?
Not while Traefik is installed. The Traefik service claims host ports 80 and 443 on every node through ServiceLB, so a second LoadBalancer service on port 80 never gets its svclb pods scheduled. Pick a different port, or install k3s with --disable=traefik and run your own ingress.
How much RAM does a k3s node need?
The official minimums are 2GB and two cores for a server node, 512MB and one core for an agent. Those leave nothing for workloads. Budget 8GB per mini PC and you can stack a dozen small services before the scheduler starts rejecting pods for lack of memory.
Do I need to install metrics-server on k3s?
No. k3s deploys metrics-server as a packaged component, so kubectl top nodes works on a fresh install. Applying the upstream manifest creates a conflicting deployment that k3s overwrites on restart. Disable the packaged copy with --disable=metrics-server only if you need custom flags.