I’ve since moved this cluster to Talos + FluxCD GitOps, but everything here is still valid if you’re running k3s on Proxmox.
The target layout across my hardware:
- 1× DELL R720 →
k3s-master-1andk3s-worker-1 - 1× DELL Optiplex Micro 3050 →
k3s-master-2andk3s-worker-2 - 1× DELL Optiplex Micro 3050 →
k3s-master-3andk3s-worker-3
Six VMs total on a Proxmox cluster: 3 Ubuntu 24.04 master nodes, 3 Ubuntu 24.04 worker nodes.
DNS and addressing#
Before creating any VMs, get your IP and DNS situation sorted.
For IP assignment, you have two options: assign addresses outside your DHCP range (what I do — network stays stable even if DHCP goes down), or use static MAC→IP mappings in your DHCP server.
I’m using 10.57.57.30/24 through 10.57.57.35/24 for the six VMs, with an A record in Unbound on pfSense for each:

Six VMs, from one script#
Rather than clicking through the Proxmox UI six times, I wrote a bash script that handles template creation, VM deployment, and teardown. If you’d prefer a Packer/Terraform approach, see Homelab as Code.
This script can create or destroy VMs. Keep backups of anything critical before running option 3.
Prerequisites: Proxmox up and running, SSH public key at /root/.ssh/id_rsa.pub on the Proxmox host.
The script has three modes:
- 1
Create the Cloud-Init template
Downloads the Ubuntu 24.04 cloud image, creates a VM from it, adds a cloud-init drive, and converts it to a template. - 2
Deploy the VMs
Clones the template N times and sets IP, gateway, DNS, search domain, SSH key, CPU, RAM and disk size on each, asking for a name per VM. - 3
Destroy the VMs
Stops and removes VMs by ID range.
| |
After running option 2, verify the VMs appear in Proxmox and SSH in:
ssh ubuntu@10.57.57.30Installing K3s#
A fork of TechnoTim’s k3s-ansible does the whole cluster. Ansible goes on your machine, not on the nodes:
sudo apt update && sudo apt install -y ansiblebrew install ansiblegit clone https://github.com/meroxdotdev/k3s-ansible
cd k3s-ansible
cp ansible.example.cfg ansible.cfg
ansible-galaxy install -r ./collections/requirements.yml
cp -R inventory/sample inventory/my-clusterTwo files to edit. hosts.ini is just the addresses:
[master]
10.57.57.30
10.57.57.31
10.57.57.32
[node]
10.57.57.33
10.57.57.34
10.57.57.35
[k3s_cluster:children]
master
nodegroup_vars/all.yml is where the decisions are:
| Field | Value | Why |
|---|---|---|
ansible_user | ubuntu | The cloud image’s default user |
system_timezone | e.g. Europe/Bucharest | Log timestamps you can read |
calico_iface | "eth0" | Comment out flannel_iface and use Calico — Flannel works, but has no NetworkPolicy support |
apiserver_endpoint | 10.57.57.100 | A free LAN address. This is the control-plane VIP, and it must not be assigned to anything |
k3s_token | any alphanumeric string | — |
metal_lb_ip_range | 10.57.57.80-10.57.57.90 | A LAN range outside DHCP and unused. Every LoadBalancer service comes from here |
Both address ranges have to be free of your DHCP pool. A VIP that DHCP later hands to a laptop takes the control plane with it.
SSH key auth has to work from your machine to all six VMs before you run this. The playbook fails partway through otherwise, and a half-configured cluster is worse than none.
ansible-playbook ./site.yml -i ./inventory/my-cluster/hosts.iniOnce done, pull the kubeconfig and verify:
mkdir -p ~/.kube
scp ubuntu@10.57.57.30:~/.kube/config ~/.kube/config
kubectl get nodesTraefik and certificates#
Ingress and Let’s Encrypt, over Cloudflare’s DNS challenge — which means no port ever has to be open for a certificate to renew.
Helm first:
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.shkubectl create namespace traefik
helm repo add traefik https://traefik.github.io/charts
helm repo update
git clone https://github.com/techno-tim/launchpadIn launchpad/kubernetes/traefik-cert-manager/, open values.yaml and set the LoadBalancer IP to something from your MetalLB range, then install:
helm install --namespace=traefik traefik traefik/traefik --values=values.yamlVerify:
kubectl get svc --all-namespaces -o wideExpected output:
NAMESPACE NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
calico-system calico-typha ClusterIP 10.43.80.131 <none> 5473/TCP 2d20h k8s-app=calico-typha
traefik traefik LoadBalancer 10.43.185.67 10.57.57.80 80:32195/TCP,443:31598/TCP,443:31598/UDP 53s app.kubernetes.io/instance=traefik,app.kubernetes.io/name=traefikApply middleware:
kubectl apply -f default-headers.yaml
kubectl get middlewareExpected output:
NAME AGE
default-headers 4sThe dashboard#
Generate the credential line:
sudo apt-get install apache2-utils
htpasswd -nb merox passwordPaste it into dashboard/secret-dashboard.yaml as is — stringData takes plain text and Kubernetes does the base64:
---
apiVersion: v1
kind: Secret
metadata:
name: traefik-dashboard-auth
namespace: traefik
type: Opaque
stringData:
users: 'merox:$apr1$...'Point your DNS server to the MetalLB IP from values.yaml:

Set your domain in dashboard/ingress.yaml:
routes:
- match: Host(`traefik.k3s.your.domain`)Apply everything from the traefik/dashboard folder:
kubectl apply -f secret-dashboard.yaml
kubectl get secrets --namespace traefik
kubectl apply -f middleware.yaml
kubectl apply -f ingress.yamlThe dashboard will be up but using a self-signed cert. The next section fixes that.
cert-manager#
From traefik-cert-manager/cert-manager:
helm repo add jetstack https://charts.jetstack.io
helm repo update
kubectl create namespace cert-managerCheck the releases page and use the latest version of cert-manager.
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.crds.yaml
helm install cert-manager jetstack/cert-manager --namespace cert-manager --values=values.yaml --version v1.17.0Apply your Cloudflare API secret (use an API Token, not a global key):
kubectl apply -f issuers/secret-cf-token.yamlBefore applying the remaining files, edit:
issuers/letsencrypt-production.yaml:email,dnsZonescertificates/production/your-domain-com.yaml:name,secretName,commonName,dnsNames
kubectl apply -f issuers/letsencrypt-production.yaml
kubectl apply -f certificates/production/your-domain-com.yamlMonitor progress:
kubectl logs -n cert-manager -f deploy/cert-manager
kubectl get challenges
Rancher and Longhorn#
A UI for the cluster, and somewhere for volumes to live.
Rancher#
helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
kubectl create namespace cattle-systemTraefik is already handling ingress, so set tls=external:
helm install rancher rancher-stable/rancher \
--namespace cattle-system \
--set hostname=rancher.k3s.your.domain \
--set tls=external \
--set replicas=3Create ingress.yml:
| |
kubectl apply -f ingress.yml
Longhorn#
Install prerequisites on the nodes you want to use for storage:
sudo apt update && sudo apt install -y open-iscsi nfs-common
sudo systemctl enable --now iscsidLabel your three worker nodes for HA:
kubectl label node k3s-worker-1 storage.longhorn.io/node=true
kubectl label node k3s-worker-2 storage.longhorn.io/node=true
kubectl label node k3s-worker-3 storage.longhorn.io/node=trueDeploy (this manifest is patched to use the storage.longhorn.io/node=true label):
kubectl apply -f https://raw.githubusercontent.com/meroxdotdev/merox.docs/refs/heads/master/K3S/cluster-deployment/longhorn.yamlVerify:
kubectl get pods --namespace longhorn-system --watch
kubectl get nodes
kubectl get svc -n longhorn-systemExposing Longhorn via Traefik#
Create middleware.yml:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: longhorn-headers
namespace: longhorn-system
spec:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"Create ingress.yml:
| |

Where to go next#
- NFS storage — the manifests, for anything too big to live on Longhorn
- Monitoring — Netdata is what I use. Prometheus and Grafana are a click away in Rancher, but untuned Prometheus will eat this cluster alive on query volume
- Continuous deployment — ArgoCD
- Upgrades — how to upgrade K3s
I wrote this because when I built my first K3s cluster a year earlier, there was no single page that covered all of it — every guide stopped at kubectl get nodes and left ingress, certificates and storage to somebody else.
Shoutout to TechnoTim and James Turland, whose repos most of this is built on.