Skip to content
Go back

k3s Cluster on 3 Mini PCs From Zero

· Updated:
By SumGuy 14 min read
k3s Cluster on 3 Mini PCs From Zero
Contents

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:

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:

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:

Terminal window
sudo hostnamectl set-hostname node-1 # node-2 and node-3 on the others

Set 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:

Terminal window
ip -br link

Then write the netplan config:

/etc/netplan/01-netcfg.yaml
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.1

Netplan wants that file locked down, or it complains every time you touch it:

Terminal window
sudo chmod 600 /etc/netplan/01-netcfg.yaml
sudo netplan apply
ip addr show enp1s0

Open the ports

Skip this and your nodes will join, then act haunted an hour later. k3s needs these reachable between machines:

PortProtocolDirectionWhat breaks without it
6443TCPagents to serverNodes can’t join, kubectl can’t connect
8472UDPall nodesFlannel VXLAN, so pod-to-pod traffic across nodes
10250TCPall nodesKubelet metrics, so kubectl top and logs

If you keep ufw enabled, on every node:

Terminal window
sudo ufw allow 6443/tcp
sudo ufw allow 8472/udp
sudo ufw allow 10250/tcp
sudo ufw allow from 10.42.0.0/16
sudo ufw allow from 10.43.0.0/16

Keep 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:

Terminal window
sudo apt-get update && sudo apt-get upgrade -y
sudo apt-get install -y curl wget git htop

Install the k3s server:

Terminal window
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:

Terminal window
sudo k3s kubectl get nodes
NAME STATUS ROLES AGE VERSION
node-1 Ready control-plane,master 2m v1.36.4+k3s1

Now pull the kubeconfig down to your laptop so you can stop typing sudo k3s kubectl:

Terminal window
# on your laptop
mkdir -p ~/.kube
ssh [email protected] "sudo cat /etc/rancher/k3s/k3s.yaml" > ~/.kube/config
sed -i 's|127.0.0.1|192.168.1.100|' ~/.kube/config
chmod 600 ~/.kube/config

The 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:

Terminal window
kubectl get nodes

If 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:

Terminal window
sudo cat /var/lib/rancher/k3s/server/node-token

On node-2, install k3s as an agent instead of a server:

Terminal window
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:

Terminal window
kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP
node-1 Ready control-plane,master 10m v1.36.4+k3s1 192.168.1.100
node-2 Ready <none> 2m v1.36.4+k3s1 192.168.1.101
node-3 Ready <none> 1m v1.36.4+k3s1 192.168.1.102

Your 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:

Terminal window
kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 12m

That 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:

test-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-storage
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
Terminal window
kubectl apply -f test-pvc.yaml
kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
test-storage Pending local-path 8s

Pending here is correct, not broken. Nothing consumes the claim yet. Give it a consumer:

pvc-writer.yaml
apiVersion: v1
kind: Pod
metadata:
name: pvc-writer
spec:
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-storage
Terminal window
kubectl apply -f pvc-writer.yaml
kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
test-storage Bound pvc-9f3c1a02-4d17-4a3e-9b21-6f0c8d2e77aa 5Gi RWO local-path 54s

Read the file back to prove the mount works:

Terminal window
kubectl exec pvc-writer -- cat /data/proof.txt

The 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:

Terminal window
kubectl delete pod pvc-writer
kubectl delete pvc test-storage

Your 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.

nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-demo
spec:
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: v1
kind: Service
metadata:
name: nginx-demo
spec:
type: LoadBalancer
selector:
app: nginx-demo
ports:
- protocol: TCP
port: 8080 # 80 and 443 belong to Traefik
targetPort: 80

Deploy it and watch the pods land:

Terminal window
kubectl apply -f nginx-deployment.yaml
kubectl get pods -o wide -w
NAME READY STATUS RESTARTS AGE IP NODE
nginx-demo-6c4d8f5fbc-abc12 1/1 Running 0 45s 10.42.1.14 node-2
nginx-demo-6c4d8f5fbc-def34 1/1 Running 0 40s 10.42.0.22 node-1
nginx-demo-6c4d8f5fbc-ghi56 1/1 Running 0 35s 10.42.2.9 node-3

Those 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:

Terminal window
kubectl get svc nginx-demo
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx-demo LoadBalancer 10.43.12.34 192.168.1.100,192.168.1.101,192.168.1.102 8080:31234/TCP 90s

Every node running a svclb pod gets advertised, which is why all three IPs show up. Any of them answers:

Terminal window
curl -s http://192.168.1.101:8080 | head -4

You 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:

Terminal window
ssh -L 6443:localhost:6443 [email protected]

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:

Terminal window
kubectl top nodes
NAME 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:

Terminal window
kubectl top pods -A --sort-by=memory | head -10

Graphs 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:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Helm Without the YAML Soup
Next Post
DBeaver vs pgAdmin vs DataGrip

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts