K3s on Proxmox: Running a Production-Grade Kubernetes Cluster in Your Home Lab
I've been running Kubernetes in production for years, but I always hesitated to bring it into my home lab. Full K8s felt like overkill for a few self-hosted services, and the maintenance overhead on a cluster I'd touch once a week seemed like a bad trade-off. Then I started using K3s on Proxmox VMs β and that calculation flipped completely.
K3s ships as a single 70MB binary. It passes all Kubernetes conformance tests. It runs comfortably on nodes with 1 CPU and 512MB RAM (though you'll want more for real workloads). It's maintained by Rancher/SUSE and actively developed. And when you pair it with Proxmox as your hypervisor, you get VM-level isolation, snapshots before upgrades, and easy node recreation β all on commodity hardware.
This guide walks through standing up a real multi-node K3s cluster on Proxmox: three VMs, persistent storage with Longhorn, ingress with Traefik, and remote access via Tailscale. No toy setup β this is how I actually run it.
---
Why K3s Over Full Kubernetes (and Why Not Just Docker Compose)
If you're running home lab services, Docker Compose works great right up until it doesn't. The breaking points I hit: no rolling updates, no built-in health-based rescheduling, manual SSL certificate management per service, no resource limits enforcement, and zero portability of workload definitions between machines.
K3s solves all of these. And unlike full Kubernetes, it:
k3s-upgrade-controller β a single CRD applicationThe tradeoff vs. k8s: K3s makes some opinionated choices (Flannel CNI by default, Traefik ingress). You can swap these out, but if you need Calico/Cilium networking or you're chasing the absolute cutting edge of sig-network, full k8s may fit better. For a home lab running real workloads? K3s is the right call.
---
Hardware Requirements and My Setup
You don't need much. My cluster runs on a Beelink EQ12 Mini PC as the host, with three Proxmox VMs:
| Node | Role | vCPU | RAM | Disk | |------|------|------|-----|------| | k3s-server-01 | Server (control plane) | 2 | 4GB | 32GB | | k3s-agent-01 | Agent (worker) | 2 | 4GB | 64GB | | k3s-agent-02 | Agent (worker) | 2 | 4GB | 64GB |
The 64GB disks on the agents are what Longhorn will use for distributed storage. I'm using a Samsung 970 EVO Plus 500GB NVMe as the Proxmox host's primary drive, which gives enough IOPS for this to feel snappy.
If you want to run this on dedicated hardware, the Minisforum MS-01 is an excellent 3-node-in-a-box option β dual 2.5GbE, an SFP+ port, and room for two NVMe drives per unit. The Beelink GTi14 Ultra is another solid choice if you want a single-host setup running multiple VMs.
For RAM, if your mini PC supports it, upgrade to at least 32GB DDR5 so you have headroom for both VMs and the host OS.
---
Step 1: Create the Proxmox VMs
I use Ubuntu 24.04 LTS cloud images for K3s VMs. Download it once to Proxmox and create a template:
# On the Proxmox host
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.imgCreate VM template
qm create 9000 --name ubuntu-2404-template --memory 2048 --net0 virtio,bridge=vmbr0
qm importdisk 9000 noble-server-cloudimg-amd64.img local-lvm
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-0
qm set 9000 --boot c --bootdisk scsi0
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000
Then clone it three times:
for i in 101 102 103; do
qm clone 9000 $i --name k3s-node-$i --full
qm set $i --memory 4096 --cores 2
qm resize $i scsi0 +30G # agents get +60G instead
done
Set cloud-init for each VM (SSH key, static IP, DNS):
qm set 101 --ciuser ubuntu --sshkeys ~/.ssh/authorized_keys --ipconfig0 ip=192.168.1.101/24,gw=192.168.1.1 --nameserver 192.168.1.1
Start them up and make sure you can SSH in before proceeding.
---
Step 2: Install K3s β Server Node First
On k3s-server-01 (192.168.1.101):
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --cluster-init --tls-san 192.168.1.101 --disable traefik --disable servicelb" sh -
I disable the built-in Traefik and ServiceLB here because I'll install Traefik via Helm for more control, and I'll use MetalLB for LoadBalancer services on my LAN.
Grab the node token for the agents:
sudo cat /var/lib/rancher/k3s/server/node-token
Verify the server is up:
sudo k3s kubectl get nodesNAME STATUS ROLES AGE VERSION
k3s-server-01 Ready control-plane,etcd,master 30s v1.32.x+k3s1
---
Step 3: Join the Agent Nodes
On each agent VM (replace and server IP):
curl -sfL https://get.k3s.io | K3S_URL=https://192.168.1.101:6443 K3S_TOKEN=<TOKEN> sh -
Back on the server, confirm both agents joined:
sudo k3s kubectl get nodesNAME STATUS ROLES AGE VERSION
k3s-server-01 Ready control-plane,etcd,master 5m v1.32.x+k3s1
k3s-agent-01 Ready <none> 2m v1.32.x+k3s1
k3s-agent-02 Ready <none> 1m v1.32.x+k3s1
Copy the kubeconfig to your local machine:
# On the Proxmox host or your workstation
scp ubuntu@192.168.1.101:/etc/rancher/k3s/k3s.yaml ~/.kube/config-k3s
Edit the server URL: change 127.0.0.1 to 192.168.1.101
export KUBECONFIG=~/.kube/config-k3s
kubectl get nodes # works from your laptop now
---
Step 4: Persistent Storage with Longhorn
The built-in local-path provisioner is fine for stateless workloads, but for anything with a database or file storage, you want Longhorn. It provides replicated block storage across your nodes β if an agent VM goes down, your data survives.
Install via Helm:
helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn --namespace longhorn-system --create-namespace --set defaultSettings.defaultReplicaCount=2
Wait for all pods to be Running:
kubectl -n longhorn-system get pods --watch
Set Longhorn as the default StorageClass:
kubectl patch storageclass local-path -p '{"metadata": {"annotations": {"storageclass.kubernetes.io/is-default-class": "false"}}}'
kubectl patch storageclass longhorn -p '{"metadata": {"annotations": {"storageclass.kubernetes.io/is-default-class": "true"}}}'
For the storage to work well, make sure each agent node has that extra disk volume I mentioned. Longhorn auto-discovers /var/lib/longhorn by default. On my setup, I mount the extra Proxmox disk to that path:
# On each agent, after formatting the extra disk as ext4
sudo mkfs.ext4 /dev/sdb
echo '/dev/sdb /var/lib/longhorn ext4 defaults 0 2' | sudo tee -a /etc/fstab
sudo mount -a
The Western Digital Blue 1TB SN580 NVMe is a cost-effective choice for Longhorn storage volumes β solid sequential throughput and competitive pricing. The Samsung 870 EVO 1TB SATA SSD works well too if your Proxmox setup is using SATA rather than NVMe passthrough.
---
Step 5: Ingress with Traefik and cert-manager
Install Traefik via Helm with a values file that suits a home lab:
helm repo add traefik https://traefik.github.io/charts helm repo updatecat << 'EOF' > traefik-values.yaml deployment: replicas: 1 service: type: LoadBalancer annotations: metallb.universe.tf/loadBalancerIPs: "192.168.1.200" additionalArguments: - "--certificatesresolvers.letsencrypt.acme.email=contact@gilricardo.com" - "--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json" - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" EOF
helm install traefik traefik/traefik --namespace traefik --create-namespace -f traefik-values.yaml
For MetalLB (so LoadBalancer type services get real LAN IPs):
helm repo add metallb https://metallb.github.io/metallb
helm install metallb metallb/metallb -n metallb-system --create-namespaceConfigure IP pool
kubectl apply -f - <<EOF
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: homelab-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.200-192.168.1.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: homelab-l2
namespace: metallb-system
EOF
Now any LoadBalancer service gets a real IP in your 192.168.1.200-220 range β accessible from anywhere on your LAN.
---
Step 6: Remote Access with Tailscale Operator
The Tailscale Kubernetes Operator is a clean way to expose cluster services over Tailscale without a VPN client on every device. Install it:
helm repo add tailscale https://pkgs.tailscale.com/helmcharts
helm repo update
helm upgrade --install tailscale-operator tailscale/tailscale-operator --namespace=tailscale --create-namespace --set-string oauth.clientId=<TAILSCALE_CLIENT_ID> --set-string oauth.clientSecret=<TAILSCALE_CLIENT_SECRET> --wait
Then annotate any service you want exposed over your Tailnet:
apiVersion: v1
kind: Service
metadata:
name: my-app
annotations:
tailscale.com/expose: "true"
tailscale.com/hostname: "my-app-k3s"
spec:
type: LoadBalancer
...
The service gets a my-app-k3s hostname on your Tailnet β accessible from your phone, laptop, or any Tailscale-connected device. No port forwarding, no dynamic DNS, no certificate headaches for internal services.
---
Gotchas I Hit (Save Yourself the Debugging)
etcd disk I/O matters more than you'd think. If your control plane VM's disk is slow, etcd will be flaky. Use NVMe-backed storage for the server VM. I learned this the hard way when kubectl commands started hanging intermittently β the culprit was the server VM sitting on a slow SATA pool.
Longhorn needs consistent node naming. If you recreate a VM and the hostname changes, Longhorn gets confused about which node owns which volume replicas. Set your hostnames in cloud-init and don't change them. I use k3s-agent-01 and k3s-agent-02 as static hostnames, not the default ubuntu cloud-init hostname.
firewalld/ufw will break node communication. K3s nodes communicate on several ports (6443, 8472 UDP for Flannel VXLAN, 10250, etc.). Either configure firewall rules properly or (for a home lab) just disable ufw on the VMs: sudo ufw disable.
Proxmox snapshots before any K3s upgrade. Run qm snapshot before upgrading. K3s upgrades are usually smooth, but having a one-command rollback path costs you 30 seconds and has saved me twice.
For a proper A/C-powered network switch to connect your mini PCs, the TP-Link TL-SG108E 8-Port is a cheap managed switch that handles VLAN segmentation if you want to isolate cluster traffic. The Netgear GS308E is another solid pick in the same tier.
---
Deploying Your First Real Workload
Here's a complete example deploying Gitea (self-hosted Git) on the cluster with a Longhorn PVC:
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
containers:
- name: gitea
image: gitea/gitea:latest
ports:
- containerPort: 3000
- containerPort: 22
volumeMounts:
- name: gitea-data
mountPath: /data
volumes:
- name: gitea-data
persistentVolumeClaim:
claimName: gitea-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-pvc
namespace: gitea
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 20Gi
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: gitea
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec:
rules:
- host: git.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 3000
Apply it and Traefik handles the TLS certificate automatically. Longhorn replicates the /data volume across your two agent nodes. If one agent goes down, K3s reschedules the pod to the surviving node and Longhorn serves the volume from the replica.
That's the full production story in one YAML file.
---
Is It Worth It?
For someone already running Proxmox, yes β absolutely. The K3s setup takes about two hours from fresh VMs to a working cluster with storage and ingress. After that, deploying new services is kubectl apply -f and they get automatic TLS, resource limits, health monitoring, and log aggregation for free.
The overhead is real: Longhorn alone runs about 10 pods per node, and the control plane eats ~1.5GB of RAM at rest. But on a machine with 32GB RAM split across VMs, that's a rounding error.
The bigger win is that your home lab now runs the same primitives as work. When I'm debugging a Kubernetes issue at my day job, I can reproduce it locally in minutes β not on a shared staging environment, but on hardware I control completely.
---
Disclosure: This post contains affiliate links. If you purchase through these links, I may earn a small commission at no extra cost to you.
