Build one Ubuntu VM, prepare it once with Cloud-Init and Kubernetes, then clone it forever. Every new node gets its own identity — IP, hostname, user, SSH key, disk size — in seconds, straight from the Proxmox web console.
A reproducible workflow for turning one Ubuntu VM into an infinitely cloneable, Cloud-Init-driven Kubernetes node template on Proxmox VE. Build once, clone forever — each new node configures itself from the Proxmox web console in seconds.
A template is a frozen, read-only master copy of a VM. When you clone it, you get an identical machine — but identical is a problem if you need fifty of them on one network. Two VMs with the same IP, hostname, and SSH keys collide instantly.
Cloud-Init solves this. It runs on a machine's first boot and applies per-machine settings handed to it from outside. Proxmox injects those settings through a small virtual drive, which you control entirely from the web console. The result: one template, unlimited unique clones.
On top of that, we pre-install everything a Kubernetes node needs into the image itself, so a clone boots ready to join a cluster in seconds — no waiting for a slow install on every machine.
This is the one concept that trips everyone up. Understand it now and the rest of the guide is mechanical.
When a clone first boots, Cloud-Init reads its configuration from two completely separate places and merges them. Each place has a different job:
So the mental split you'll use throughout this guide:
Start from a freshly installed Ubuntu 24.04 LTS VM. During install, choose custom storage and put all space into a single / partition — no LVM. This makes automatic disk growth trivial later (Section 04).
Three packages make the template work: Cloud-Init itself, the QEMU guest agent (lets Proxmox read the VM's IP and shut it down cleanly), and the growpart utility (resizes partitions).
sudo apt update
sudo apt install -y cloud-init qemu-guest-agent cloud-guest-utils
This is the step nobody warns you about. The Ubuntu Server installer silently drops two Cloud-Init files that sabotage four of your six requirements. Until they're gone, nothing you set in Proxmox will apply.
ls /etc/cloud/cloud.cfg.d/
# look for the two troublemakers below:
# 90-installer-network.cfg and 99-installer.cfg
| File | What it secretly contains | Breaks | Action |
|---|---|---|---|
90-installer-network.cfg |
Static network config pinning the install-time IP to ens18. | IP injection | DELETE |
99-installer.cfg |
datasource_list: [None] (ignores the Proxmox drive entirely) plus growpart: off, resize_rootfs: false, preserve_hostname: true, and a hardcoded user. | Datasource · disk grow · hostname | DELETE |
99-pve.cfg (you create) |
datasource_list: [ConfigDrive, NoCloud] — points Cloud-Init at the formats Proxmox provides. | — | CREATE |
99-pwauth.cfg (optional) |
ssh_pwauth: true — allows SSH login by password. | — | OPTIONAL |
# remove the two saboteurs
sudo rm /etc/cloud/cloud.cfg.d/90-installer-network.cfg
sudo rm /etc/cloud/cloud.cfg.d/99-installer.cfg
# point Cloud-Init at the Proxmox datasource
echo 'datasource_list: [ConfigDrive, NoCloud]' | sudo tee /etc/cloud/cloud.cfg.d/99-pve.cfg
# OPTIONAL: allow SSH password login (skip if you use keys only)
echo 'ssh_pwauth: true' | sudo tee /etc/cloud/cloud.cfg.d/99-pwauth.cfg
ls /etc/cloud/cloud.cfg.d/
sudo cloud-init schema --system 2>&1 | tail -5
Because you used a single plain / partition (no LVM), this works with zero scripting. When you give a clone a bigger virtual disk, Cloud-Init's growpart and resizefs modules expand the filesystem on boot.
lsblk
You want / on a normal partition that is the last partition on the disk, like this:
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sda 8:0 0 50G 0 disk
├─sda1 8:1 0 1M 0 part # BIOS-boot, harmless
└─sda2 8:2 0 50G 0 part / # ← root, last partition ✓
We install all the node components now, into the image, by running the steps once by hand. Since a clone is a copy of this disk, every clone is born k8s-ready — no slow per-clone install. We deliberately stop before creating any cluster.
SSH into the VM and run these in order. They mirror the official kubeadm bring-up, with 2026-era pins for containerd/runc/CNI.
sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
curl -LO https://github.com/containerd/containerd/releases/download/v1.7.14/containerd-1.7.14-linux-amd64.tar.gz
sudo tar Cxzvf /usr/local containerd-1.7.14-linux-amd64.tar.gz
curl -LO https://raw.githubusercontent.com/containerd/containerd/main/containerd.service
sudo mkdir -p /usr/local/lib/systemd/system/
sudo mv containerd.service /usr/local/lib/systemd/system/
sudo mkdir -p /etc/containerd
# default config + the SystemdCgroup and pause:3.9 fixes
containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
sudo sed -i 's/pause:3.8/pause:3.9/g' /etc/containerd/config.toml
sudo systemctl daemon-reload
sudo systemctl enable --now containerd
curl -LO https://github.com/opencontainers/runc/releases/download/v1.1.12/runc.amd64
sudo install -m 755 runc.amd64 /usr/local/sbin/runc
curl -LO https://github.com/containernetworking/plugins/releases/download/v1.5.0/cni-plugins-linux-amd64-v1.5.0.tgz
sudo mkdir -p /opt/cni/bin
sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.5.0.tgz
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gpg iptables conntrack ethtool
echo 'deb [trusted=yes] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
sudo crictl config runtime-endpoint unix:///var/run/containerd/containerd.sock
sudo mkdir -p /opt/calico
sudo curl -fsSL https://raw.githubusercontent.com/projectcalico/calico/v3.27.3/manifests/tigera-operator.yaml -o /opt/calico/tigera-operator.yaml
sudo curl -fsSL https://raw.githubusercontent.com/projectcalico/calico/v3.27.3/manifests/custom-resources.yaml -o /opt/calico/custom-resources.yaml
A clone is a byte-for-byte copy. If we don't wipe the machine's unique identity first, every clone shares the same machine-id (duplicate DHCP leases) and the same SSH host keys. Because we never ran kubeadm init, there are no cluster certificates to scrub — the standard cleanup is all we need.
# optional: shrink the template by removing install leftovers
cd ~ ; rm -f containerd-*.tar.gz runc.amd64 cni-plugins-*.tgz 2>/dev/null
sudo apt-get clean
# reset machine identity (critical for unique clones)
sudo cloud-init clean --logs
sudo truncate -s 0 /etc/machine-id
sudo rm -f /var/lib/dbus/machine-id
sudo rm -f /etc/ssh/ssh_host_*
cat /dev/null > ~/.bash_history && history -c
sudo shutdown now
Everything you built now pays off. Each clone takes seconds to create and gets its own identity entirely from the web console.
| Field | What it sets |
|---|---|
| User | The login account created on the clone |
| Password | That user's password (console login always; SSH if ssh_pwauth is on) |
| SSH public key | Paste your key for passwordless SSH |
| IP Config (net0) | DHCP, or static — IP/CIDR + gateway, e.g. 192.168.20.50/24, gw 192.168.20.254 |
| DNS | Optional domain + resolvers |
These steps are run by hand, on the clones, because they are cluster-specific. The control-plane node initializes the cluster, workers join it, then we apply the network plugin we staged earlier.
Calico's default pod network is 192.168.0.0/16. If your LAN lives anywhere in 192.168.x.x — and the example LAN here is 192.168.20.x — that default overlaps your real network and breaks routing. The fix: give pods a 10.x range that doesn't collide.
# note the 10.x pod CIDR instead of the default 192.168.0.0/16
sudo kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--apiserver-advertise-address=<this-node-ip> \
--node-name <node-name>
# set up kubectl for your user
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
# edit the cidr in the staged manifest to match the init above
sudo vim /opt/calico/custom-resources.yaml # set cidr: 10.244.0.0/16
kubectl create -f /opt/calico/tigera-operator.yaml
kubectl create -f /opt/calico/custom-resources.yaml
Run the kubeadm join … command that kubeadm init printed on the master. It already contains the token and CA hash this cluster needs.
sudo kubeadm join <master-ip>:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
cloud-init status --wait # waits until done, reports status
hostnamectl # hostname == the VM name
ip a # the injected IP
df -h / # disk grew to the resized size
kubectl get nodes -o wide # all nodes should reach Ready after Calico
kubectl get pods -A # calico + system pods Running
| Symptom | Likely cause | Fix |
|---|---|---|
| Injected IP never applies | An installer network file crept back, or datasource is wrong | Re-check Section 03; ensure 90-installer-network.cfg / 99-installer.cfg are gone and 99-pve.cfg exists |
| Hostname stays "ubuntu" | preserve_hostname: true leftover | Confirm 99-installer.cfg was deleted |
| Disk didn't grow | Root not the last partition, or growpart disabled | Check lsblk; grep logs: grep growpart /var/log/cloud-init.log |
| kubelet keeps restarting | Normal before init/join | Nothing — it heals on bootstrap |
| Pods can't reach each other / LAN flaky | Pod CIDR overlaps the LAN | Reinstall with a 10.x CIDR matching Calico (Figure 5) |
| Two clones get the same DHCP IP | machine-id wasn't reset | Re-run the cleanup in Section 06 before templating |
If you prefer Cloud-Init to install k8s on each clone's first boot instead of baking it into the image, drop this file at /etc/cloud/cloud.cfg.d/99-k8s.cfg on the template. (Baking into the image, as in Section 05, is faster per-clone — this is the alternative.)
write_files:
- path: /usr/local/sbin/install-k8s.sh
permissions: '0755'
content: |
#!/bin/bash
set -euxo pipefail
exec > /var/log/k8s-install.log 2>&1
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 5; done
cd /tmp
# --- OS prep, containerd/runc/CNI, kube packages ---
# (paste the exact commands from Section 05, steps 1–3, without sudo)
# --- stage Calico, download only ---
mkdir -p /opt/calico
curl -fsSL https://raw.githubusercontent.com/projectcalico/calico/v3.27.3/manifests/tigera-operator.yaml -o /opt/calico/tigera-operator.yaml
curl -fsSL https://raw.githubusercontent.com/projectcalico/calico/v3.27.3/manifests/custom-resources.yaml -o /opt/calico/custom-resources.yaml
runcmd:
- [ /usr/local/sbin/install-k8s.sh ]
| Phase | Where | Key command / action |
|---|---|---|
| Prep | VM | apt install cloud-init qemu-guest-agent cloud-guest-utils |
| Fix traps | VM | rm 90-installer-network.cfg 99-installer.cfg · create 99-pve.cfg |
| Install k8s | VM | OS prep → containerd/runc/CNI → kube packages → stage Calico |
| Clean | VM | cloud-init clean · reset machine-id · remove host keys · shut down |
| Templatize | Proxmox | Add CloudInit drive · detach ISO · enable agent · Convert to template |
| Clone | Proxmox | Full Clone (name = hostname) · Cloud-Init tab · resize · Start |
| Cluster | Clones | kubeadm init --pod-network-cidr=10.244.0.0/16 · join · apply Calico |
You now have a reusable golden image: one template that spins up unique, cluster-ready Kubernetes nodes on demand, configured entirely from the Proxmox web console. Bump the version pins periodically, re-bake, and you're set.