· · 11 min read Intermediate

K3s Cluster Setup on Refurbished Mini-PCs

Build a production-ready K3s Kubernetes cluster on refurbished mini-PCs. Complete guide from hardware selection to HA setup with embedded etcd.

Three mini PCs on a plank shelf doing what the monster rack with a price tag behind them does

TL;DR Three refurbished mini-PCs, K3s with embedded etcd, kube-vip for one API address that survives a dead node, then MetalLB, Traefik and Longhorn on top. About €450 in hardware and an afternoon. Below are the commands, in the order I ran them.

Three mini-PCs sit on a shelf in my house. Between them they run my GitLab, my monitoring stack, home automation, file sync, a password manager, and a pile of other things I’d rather not hand to someone else’s computer. The whole setup cost less than one month of the managed Kubernetes bill for the same thing, and I know what every layer does because I put it there.

Note added 2026-09-23. The forge on that shelf is Forgejo now, not GitLab. Nothing about building the cluster changes, so substitute whichever forge you run wherever this post says GitLab.

You don’t need a cloud account or a rack for this. Three second-hand machines and an afternoon where nobody bothers you will do. This is the cluster I keep pointing at when I write about sovereign infrastructure, so here’s how it goes together, step by step.

One command first

Before any hardware talk, the part that surprises people. A working Kubernetes cluster is one command.

curl -sfL https://get.k3s.io | sh -

That’s a single-node K3s install. A real Kubernetes API, a real kubectl, workloads that run. If all you want is to poke at manifests on a spare laptop, you can stop reading here. You’ve already got something useful.

K3s is why this is so painless. It’s the full Kubernetes API in one binary of about 70MB, with the bits most homelabs never touch stripped out and sane defaults for the rest. It runs on a Raspberry Pi, and it runs very well on a mini-PC. People call it “Kubernetes lite” sometimes, which sells it short. What you deploy on it is identical to what you’d deploy on a big managed cluster. You skip the enterprise overhead you weren’t going to touch anyway.

The rest of this post turns that one command into something I’d trust with data I care about. Three nodes, embedded etcd, a virtual IP that survives a node dying, and the storage and ingress to host things on.

Why refurbished mini-PCs

You can build this on anything. I keep recommending the same thing because it keeps being right. Refurbished business mini-PCs are the best deal I know of for this, and for the money nothing else comes close.

WhatWhy
HP EliteDesk 800 G3Reliable, cheap, available everywhere
Lenovo ThinkCentre M720qGreat thermals, quiet
Dell OptiPlex 3060 MicroGood expansion options

Aim for roughly this per node:

  • CPU: Intel i5 6th-8th gen (4-6 cores)
  • RAM: 16-32GB (DDR4, upgradeable)
  • Storage: 256GB+ NVMe/SSD
  • Network: Gigabit Ethernet

Expect to pay around €100-200 per node, so €300-600 for three.

People always ask about a Raspberry Pi cluster instead, and Pis do work. I ran one for a while. But you’re fighting the platform the whole time. ARM images that don’t exist yet for the thing you want. SD cards that corrupt themselves when a node loses power. 8GB of RAM as a hard ceiling on a Pi 4. USB-attached storage that flakes out at the worst moment. A refurbished mini-PC gives you x86, an NVMe slot, proper RAM, and a machine that was built to run business workloads for years without complaint. I haven’t run a Pi as a node since.

My own cluster is three of the same box, which keeps things simple:

Node 1: HP EliteDesk 800 G3 Mini
        i5-7500T, 32GB RAM, 512GB NVMe
        Role: Control plane + Worker

Node 2: HP EliteDesk 800 G3 Mini
        i5-7500T, 32GB RAM, 512GB NVMe
        Role: Control plane + Worker

Node 3: HP EliteDesk 800 G3 Mini
        i5-7500T, 16GB RAM, 256GB NVMe
        Role: Control plane + Worker

That’s about €450 for 12 cores, 80GB of RAM and 1.2TB of storage.

Getting the nodes ready

Two things to sort out before K3s goes on. Addresses and the OS.

Predictable addresses

Every node needs an IP that doesn’t move. Set static IPs, or easier, hand out DHCP reservations from your router.

192.168.1.10  k3s-node-1
192.168.1.11  k3s-node-2
192.168.1.12  k3s-node-3
192.168.1.100 k3s-api      # VIP for API server

That .100 is a virtual IP we’ll set up later, so the API has one stable address even when a node dies. Give the nodes DNS names too, in your local resolver, or in /etc/hosts if you’re in a hurry.

192.168.1.10  k3s-node-1.home.lan
192.168.1.11  k3s-node-2.home.lan
192.168.1.12  k3s-node-3.home.lan
192.168.1.100 k3s.home.lan

The OS

To get going, Ubuntu Server 22.04 LTS minimal is the path of least resistance. Install it, update it, and turn swap off, because Kubernetes wants it off.

# After install, update
sudo apt update && sudo apt upgrade -y

# Install useful tools
sudo apt install -y curl wget vim htop

# Disable swap (required for Kubernetes)
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab

If you want the immutable, locked-down version of this, Talos Linux is where I’d send you next. It’s the better foundation and it’s harder to start on, so I’d keep it for round two. Ubuntu gets you a cluster today.

Three servers, one control plane

This is the step from “Kubernetes on a laptop” to “Kubernetes I’d trust.” The flag that matters is --cluster-init. It turns on embedded etcd, so the control plane survives a node failure.

On node 1:

# On node 1
curl -sfL https://get.k3s.io | sh -s - server \
  --cluster-init \
  --tls-san k3s.home.lan \
  --tls-san 192.168.1.100 \
  --disable traefik \
  --disable servicelb

What those flags do.

  • --cluster-init turns on embedded etcd, which is what makes the control plane HA.
  • --tls-san adds the DNS name and the VIP to the API certificate as SANs, so it’s valid for the address we’re about to use.
  • --disable traefik skips the bundled ingress. We’ll bring our own.
  • --disable servicelb skips the bundled load balancer. MetalLB does this better.

Grab the join token:

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

Then bring up nodes 2 and 3, pointed at node 1 and with the same flags:

# On node 2 and 3
curl -sfL https://get.k3s.io | sh -s - server \
  --server https://192.168.1.10:6443 \
  --token <TOKEN_FROM_NODE_1> \
  --tls-san k3s.home.lan \
  --tls-san 192.168.1.100 \
  --disable traefik \
  --disable servicelb

Pull the kubeconfig, point it at your workstation, and check that all three nodes joined:

# Get kubeconfig
sudo cat /etc/rancher/k3s/k3s.yaml

# Copy to your workstation, update server URL
export KUBECONFIG=~/.kube/k3s-config

# Check nodes
kubectl get nodes
NAME         STATUS   ROLES                       AGE   VERSION
k3s-node-1   Ready    control-plane,etcd,master   5m    v1.28.5+k3s1
k3s-node-2   Ready    control-plane,etcd,master   3m    v1.28.5+k3s1
k3s-node-3   Ready    control-plane,etcd,master   2m    v1.28.5+k3s1

Three control-plane nodes, each with an etcd member. Any one of them can fall over now and the cluster keeps running. That’s what the three boxes are for.

One address that survives a dead node

There’s a gap left. Your kubeconfig points at one node’s IP, and if that’s the node that dies, kubectl stops working even though the cluster is fine. You want one API address that floats to whichever control-plane node is still alive.

My pick is kube-vip. It runs as a DaemonSet on the control-plane nodes and answers for the VIP (192.168.1.100) over ARP, moving it to a healthy node by itself.

# On each control plane node
kubectl apply -f https://kube-vip.io/manifests/rbac.yaml

# Create kube-vip manifest
cat <<EOF | sudo tee /var/lib/rancher/k3s/server/manifests/kube-vip.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: kube-vip
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: kube-vip
  template:
    metadata:
      labels:
        app: kube-vip
    spec:
      hostNetwork: true
      tolerations:
        - key: node-role.kubernetes.io/control-plane
          operator: Exists
          effect: NoSchedule
      containers:
        - name: kube-vip
          image: ghcr.io/kube-vip/kube-vip:latest
          args:
            - manager
          env:
            - name: vip_interface
              value: eth0
            - name: vip_address
              value: 192.168.1.100
            - name: vip_arp
              value: "true"
            - name: lb_enable
              value: "true"
EOF

If you’d rather keep the VIP logic off the cluster, and your router runs HAProxy, you can balance the API there instead:

frontend k3s-api
    bind *:6443
    default_backend k3s-servers

backend k3s-servers
    balance roundrobin
    server node1 192.168.1.10:6443 check
    server node2 192.168.1.11:6443 check
    server node3 192.168.1.12:6443 check

Both work. I run kube-vip, because it keeps everything inside the cluster and that’s one box fewer in the failure path. HAProxy on the router is easier to reason about if you already trust your router. Pick whichever matches what you run.

Load balancing, ingress and storage

A bare cluster doesn’t host anything yet. Three pieces turn it into something you can deploy to.

LoadBalancer services with MetalLB

We turned the bundled servicelb off, so a LoadBalancer service has nothing answering it. MetalLB fills that gap by handing out IPs from a pool on your LAN.

kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.3/config/manifests/metallb-native.yaml

Give it a range to hand out, well clear of your node and VIP addresses:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.200-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default
  namespace: metallb-system

Ingress

We dropped the bundled Traefik so we could install it on our own terms. Here it is via Helm , taking a LoadBalancer IP from the MetalLB pool we just defined:

# Traefik (via Helm)
helm repo add traefik https://traefik.github.io/charts
helm install traefik traefik/traefik \
  --namespace traefik \
  --create-namespace \
  --set service.type=LoadBalancer

Storage with Longhorn

Pods that keep data need real persistent volumes. Longhorn gives you block storage replicated across the three nodes, so a volume survives a node going down:

helm repo add longhorn https://charts.longhorn.io
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --create-namespace

If you’re weighing storage options, I put Longhorn next to the heavier alternative in Longhorn vs Rook-Ceph.

Talking to the cluster from your desk

Last bit of setup. Get a kubeconfig onto your workstation, so you stop SSHing into node 1 for everything. The one K3s generates points at 127.0.0.1. Swap that for your VIP’s DNS name and the kubeconfig keeps working whichever node answers.

# On node 1
sudo cat /etc/rancher/k3s/k3s.yaml

# Save locally, edit server URL
# Change: server: https://127.0.0.1:6443
# To:     server: https://k3s.home.lan:6443

# Set permissions
chmod 600 ~/.kube/k3s-config
export KUBECONFIG=~/.kube/k3s-config

Keeping it alive

A homelab cluster you never maintain is a problem waiting for the day it breaks. A few habits keep it boring, and boring is what I want from infrastructure.

Upgrades

Upgrade one node at a time by rerunning the install with the same flags, or let the system-upgrade-controller do it for you:

# On each node, one at a time
curl -sfL https://get.k3s.io | sh -s - server \
  # same flags as initial install
kubectl apply -f https://github.com/rancher/system-upgrade-controller/releases/latest/download/system-upgrade-controller.yaml

Backups

etcd is where the whole cluster state lives. Snapshot it. K3s does this on a schedule if you ask, so ask:

# Manual snapshot
k3s etcd-snapshot save --name manual-backup

# Scheduled backups (add to k3s config)
# /etc/rancher/k3s/config.yaml
etcd-snapshot-schedule-cron: "0 */6 * * *"
etcd-snapshot-retention: 5

Pulling a node for maintenance

Need to open a box or reboot it? Drain it first, so workloads reschedule cleanly, then bring it back:

# Drain node before maintenance
kubectl drain k3s-node-2 --ignore-daemonsets --delete-emptydir-data

# Do maintenance...

# Uncordon when done
kubectl uncordon k3s-node-2

When something’s broken

A few failures come up often enough that I know them by heart. If kubectl can’t reach the server, check that K3s is running at all, that the firewall isn’t eating port 6443, and that your kubeconfig points where you think it does:

sudo systemctl status k3s
sudo ufw status

A node stuck on NotReady usually means the kubelet, the container runtime or the disk is unhappy:

sudo journalctl -u k3s -f
sudo crictl ps
df -h

And if etcd itself gets corrupted, you’ll be reaching for those snapshots. The --cluster-reset path exists, but treat it as a last resort, because it tears the cluster down to a single member:

# Check etcd health
sudo k3s etcd-snapshot list

# Force new cluster if etcd corrupted (DANGEROUS)
# sudo k3s server --cluster-reset

What it’s for

That one command at the top is now three nodes sharing an HA control plane, a VIP that survives a dead node, load balancing, ingress, replicated storage and a backup schedule. Enough to host things on.

Here’s what I run on mine:

All of it self-hosted, all of it in my house.

The money side is lopsided. The same thing in the cloud would cost me €150-300 a month. The shelf costs maybe €10-20 in electricity:

Cloud (3 nodes)Homelab (3 nodes)
Monthly€150-300€10-20 (electricity)
Annual€1800-3600€120-240
3 year€5400-10800€360-720 + €450 hardware

The hardware pays for itself in three to six months. After that it’s compute I own outright, for the price of the power.

But money was never the reason I did this. Running your own cluster teaches you how Kubernetes behaves. A managed one teaches you which command makes the dashboard go green. You find out what happens when a node dies, because one will, and you’ll be the one watching the VIP move. You learn capacity planning with your own RAM on the line. None of that comes with a managed cluster’s control panel.

And you get sovereignty over the whole stack. Your data stays yours, and so does the responsibility for it. The cloud is fine for work that belongs to someone else. The things that matter to me sit on a shelf in my house, where I can reach out and touch them, and now and then pull the plug on one to watch the other two carry on.