How to Set Up a K3S Cluster in 2025
Rebuilding my K3s cluster from scratch with Ansible — VM provisioning via Cloud-Init on Proxmox, HA across three nodes, and full automation.
Note
I’ve since moved this cluster to Talos + FluxCD GitOps, but everything here is still valid if you’re running k3s on Proxmox.
My first Kubernetes clusters are gone. This time I want a proper HA setup — if any machine goes down, the cluster keeps running. 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 22.04 master nodes, 3 Ubuntu 22.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.
Warning
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:
Option 1 — Create Cloud-Init Template: Downloads the Ubuntu 24.04 cloud image, creates a VM, configures cloud-init, and converts it to a template.
Option 2 — Deploy VMs: Clones the template N times, sets IPs, gateway, DNS, search domain, SSH keys, CPU, RAM, and disk size per VM. Prompts for a name on each one.
Option 3 — Destroy VMs: Stops and removes VMs by ID range.
#!/bin/bash
# Function to get user input with a default valueget_input() { local prompt=$1 local default=$2 local input read -p "$prompt [$default]: " input echo "${input:-$default}"}
# Ask the user whether they want to create a template, deploy or destroy VMsecho "Select an option:"echo "1) Create Cloud-Init Template"echo "2) Deploy VMs"echo "3) Destroy VMs"read -p "Enter your choice (1, 2, or 3): " ACTION
if [[ "$ACTION" != "1" && "$ACTION" != "2" && "$ACTION" != "3" ]]; then echo "❌ Invalid choice. Please run the script again and select 1, 2, or 3." exit 1fi
# === OPTION 1: CREATE CLOUD-INIT TEMPLATE ===120 collapsed lines
if [[ "$ACTION" == "1" ]]; then TEMPLATE_ID=$(get_input "Enter the template VM ID" "300") STORAGE=$(get_input "Enter the storage name" "local") TEMPLATE_NAME=$(get_input "Enter the template name" "ubuntu-cloud") IMG_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img" IMG_FILE="/root/noble-server-cloudimg-amd64.img"
echo "📥 Downloading Ubuntu Cloud image for cloud-init setup..." cd /root wget -O $IMG_FILE $IMG_URL || { echo "❌ Failed to download the image"; exit 1; }
echo "🖥️ Creating VM $TEMPLATE_ID..." qm create $TEMPLATE_ID --memory 2048 --cores 2 --name $TEMPLATE_NAME --net0 virtio,bridge=vmbr0
echo "💾 Importing disk to storage ($STORAGE)..." qm disk import $TEMPLATE_ID $IMG_FILE $STORAGE || { echo "❌ Failed to import disk"; exit 1; }
echo "🔗 Attaching disk..." qm set $TEMPLATE_ID --scsihw virtio-scsi-pci --scsi0 $STORAGE:vm-$TEMPLATE_ID-disk-0
echo "☁️ Adding Cloud-Init drive..." qm set $TEMPLATE_ID --ide2 $STORAGE:cloudinit
echo "🛠️ Configuring boot settings..." qm set $TEMPLATE_ID --boot c --bootdisk scsi0
echo "🖧 Adding serial console..." qm set $TEMPLATE_ID --serial0 socket --vga serial0
echo "📌 Converting VM to template..." qm template $TEMPLATE_ID
echo "✅ Cloud-Init Template created successfully!" exit 0fi
# === OPTION 2: DEPLOY VMs ===if [[ "$ACTION" == "2" ]]; then TEMPLATE_ID=$(get_input "Enter the template VM ID" "300") START_ID=$(get_input "Enter the starting VM ID" "301") NUM_VMS=$(get_input "Enter the number of VMs to deploy" "6") STORAGE=$(get_input "Enter the storage name" "dataz2") IP_PREFIX=$(get_input "Enter the IP prefix (e.g., 10.57.57.)" "10.57.57.") IP_START=$(get_input "Enter the starting IP last octet" "30") GATEWAY=$(get_input "Enter the gateway IP" "10.57.57.1") DNS_SERVERS=$(get_input "Enter the DNS servers (space-separated)" "8.8.8.8 1.1.1.1") DOMAIN_SEARCH=$(get_input "Enter the search domain" "merox.dev") DISK_SIZE=$(get_input "Enter the disk size (e.g., 100G)" "100G") RAM_SIZE=$(get_input "Enter the RAM size in MB" "16384") CPU_CORES=$(get_input "Enter the number of CPU cores" "4") CPU_SOCKETS=$(get_input "Enter the number of CPU sockets" "4") SSH_KEY_PATH=$(get_input "Enter the SSH public key file path" "/root/.ssh/id_rsa.pub")
if [[ ! -f "$SSH_KEY_PATH" ]]; then echo "❌ Error: SSH key file not found at $SSH_KEY_PATH" exit 1 fi
for i in $(seq 0 $((NUM_VMS - 1))); do VM_ID=$((START_ID + i)) IP="$IP_PREFIX$((IP_START + i))/24" VM_NAME=$(get_input "Enter the name for VM $VM_ID" "ubuntu-vm-$((i+1))")
echo "🔹 Creating VM: $VM_ID (Name: $VM_NAME, IP: $IP)"
if qm status $VM_ID &>/dev/null; then echo "⚠️ VM $VM_ID already exists, removing..." qm stop $VM_ID &>/dev/null qm destroy $VM_ID fi
if ! qm clone $TEMPLATE_ID $VM_ID --full --name $VM_NAME --storage $STORAGE; then echo "❌ Failed to clone VM $VM_ID, skipping..." continue fi
qm set $VM_ID --memory $RAM_SIZE \ --cores $CPU_CORES \ --sockets $CPU_SOCKETS \ --cpu host \ --serial0 socket \ --vga serial0 \ --ipconfig0 ip=$IP,gw=$GATEWAY \ --nameserver "$DNS_SERVERS" \ --searchdomain "$DOMAIN_SEARCH" \ --sshkey "$SSH_KEY_PATH"
qm set $VM_ID --delete ide2 || true qm set $VM_ID --ide2 $STORAGE:cloudinit,media=cdrom qm cloudinit update $VM_ID
echo "🔄 Resizing disk to $DISK_SIZE..." qm resize $VM_ID scsi0 +$DISK_SIZE
qm start $VM_ID echo "✅ VM $VM_ID ($VM_NAME) created and started!" done exit 0fi
# === OPTION 3: DESTROY VMs ===if [[ "$ACTION" == "3" ]]; then START_ID=$(get_input "Enter the starting VM ID to delete" "301") NUM_VMS=$(get_input "Enter the number of VMs to delete" "6")
echo "⚠️ Destroying VMs from $START_ID to $((START_ID + NUM_VMS - 1))..." for i in $(seq 0 $((NUM_VMS - 1))); do VM_ID=$((START_ID + i))
if qm status $VM_ID &>/dev/null; then echo "🛑 Stopping and destroying VM $VM_ID..." qm stop $VM_ID &>/dev/null qm destroy $VM_ID else echo "ℹ️ VM $VM_ID does not exist. Skipping..." fi done echo "✅ Specified VMs have been destroyed." exit 0fiAfter running option 2, verify the VMs appear in Proxmox and SSH in:
ssh ubuntu@k3s-master-01Installing K3s
A fork of TechnoTim’s k3s-ansible does the whole cluster. Ansible goes on your machine, not on the nodes:
Debian/Ubuntu:
sudo apt update && sudo apt install -y ansiblemacOS:
brew install ansiblegit clone https://github.com/meroxdotdev/k3s-ansiblecd k3s-ansiblecp ansible.example.cfg ansible.cfgansible-galaxy install -r ./collections/requirements.ymlcp -R inventory/sample inventory/my-clusterTwo files to edit. hosts.ini is just the addresses:
[master]10.57.57.3010.57.57.3110.57.57.32
[node]10.57.57.3310.57.57.3410.57.57.35
[k3s_cluster:children]masternodegroup_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.
Note
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:
scp ubuntu@10.57.57.30:~/.kube/config .mkdir -p ~/.kubemv config ~/.kube/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-3chmod 700 get_helm.sh./get_helm.shkubectl create namespace traefikhelm repo add traefik https://helm.traefik.io/traefikhelm repo updategit 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 SELECTORcalico-system calico-typha ClusterIP 10.43.80.131 <none> 5473/TCP 2d20h k8s-app=calico-typhatraefik 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.yamlkubectl get middlewareExpected output:
NAME AGEdefault-headers 4sThe dashboard
Generate a base64-encoded credential:
sudo apt-get install apache2-utilshtpasswd -nb merox password | openssl base64Paste the output into dashboard/secret-dashboard.yaml:
---apiVersion: v1kind: Secretmetadata: name: traefik-dashboard-auth namespace: traefiktype: Opaquedata: users: abc123==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.yamlkubectl get secrets --namespace traefikkubectl apply -f middleware.yamlkubectl 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.iohelm repo updatekubectl create namespace cert-managerNote
Check 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.yamlhelm 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 values.yamlkubectl apply -f issuers/letsencrypt-production.yamlkubectl apply -f certificates/production/your-domain-com.yamlMonitor progress:
kubectl logs -n cert-manager -f cert-manager-(your-instance-name)kubectl get challenges
Rancher and Longhorn
A UI for the cluster, and somewhere for volumes to live.
Rancher
helm repo add rancher-latest https://releases.rancher.com/server-charts/stablekubectl 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:
apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: rancher namespace: cattle-systemspec: entryPoints: - websecure routes: - match: Host(`rancher.k3s.your.domain`) kind: Rule services: - name: rancher port: 443 middlewares: - name: default-headers tls: secretName: k3s-your-domain-tlskubectl 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-commonsudo systemctl enable iscsidsudo systemctl start iscsidLabel your three worker nodes for HA:
kubectl label node k3s-worker-1 storage.longhorn.io/node=truekubectl label node k3s-worker-2 storage.longhorn.io/node=truekubectl 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 --watchkubectl get nodeskubectl get svc -n longhorn-systemExposing Longhorn via Traefik
Create middleware.yml:
apiVersion: traefik.io/v1alpha1kind: Middlewaremetadata: name: longhorn-headers namespace: longhorn-systemspec: headers: customRequestHeaders: X-Forwarded-Proto: "https"Create ingress.yml:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: longhorn-ingress namespace: longhorn-system annotations: traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.tls: "true" traefik.ingress.kubernetes.io/router.middlewares: longhorn-system-longhorn-headers@kubernetescrdspec: rules: - host: storage.k3s.your.domain http: paths: - path: / pathType: Prefix backend: service: name: longhorn-frontend port: number: 80 tls: - hosts: - storage.k3s.your.domain secretName: k3s-your-domain-tls
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.