K3s on Proxmox: Running a Production-Grade Kubernetes Cluster in Your Home Lab
Home Lab

K3s on Proxmox: Running a Production-Grade Kubernetes Cluster in Your Home Lab

Ricardo Gil
March 30, 2026
10 min read
#K3s #Kubernetes #Proxmox #Home Lab #Self-Hosting #Longhorn #Traefik

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:

  • Doesn't require etcd by default (uses SQLite for small clusters; you can add embedded etcd for HA)
  • Removes legacy and alpha APIs that add maintenance surface
  • Bundles Traefik, CoreDNS, Flannel, local-path provisioner β€” sensible defaults, zero extra installation
  • Upgrades with k3s-upgrade-controller β€” a single CRD application

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

bash
# On the Proxmox host
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img

# Create 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:

bash
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):

bash
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):

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

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

Verify the server is up:

bash
sudo k3s kubectl get nodes
# NAME              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):

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

bash
sudo k3s kubectl get nodes
# NAME              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:

bash
# 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:

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

bash
kubectl -n longhorn-system get pods --watch

Set Longhorn as the default StorageClass:

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

bash
# 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:

bash
helm repo add traefik https://traefik.github.io/charts
helm repo update

cat << '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):

bash
helm repo add metallb https://metallb.github.io/metallb
helm install metallb metallb/metallb -n metallb-system --create-namespace

# Configure 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:

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

yaml
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 pre-k3s-upgrade 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:

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

Want this kind of setup for your business?

I deploy private, self-hosted infrastructure on Proxmox: AI, automation and internal tools on hardware you own, documented and handed off. See the Private AI Stack β†’

Not sure what you need? Book a free discovery call.

πŸ“¬Weekly Newsletter

Get the best home lab & AI content

No spam. One email per week. Unsubscribe anytime.

Share this article