Infrastructure as a Template
Proxmox VE · Ubuntu 24.04 · Kubernetes 1.30

The Proxmox
Golden Image Handbook

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.

◆ Cloud-Init ◆ Proxmox VE 9.x ◆ containerd 1.7 ◆ kubeadm 1.30 ◆ Calico 3.27
Contents
The Premise

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.

SECTION 00 What we're building & why

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.

The six things every clone must control
1
IP address
Static or DHCP, set per clone
2
Username
Login account created on boot
3
Hostname
Driven by the VM's name in Proxmox
4
Password
For the created user
5
SSH key
Injected public key for key-based login
6
Disk growth
Filesystem expands when disk is resized
The journey at a glance
Base VM Ubuntu 24.04 Prepare cloud-init + k8s Clean reset identity ★ TEMPLATE read-only master Cloud-Init injects per clone: IP · hostname · SSH key · disk grow master-01 192.168.x.11 · ready worker-01 192.168.x.12 · ready worker-02 192.168.x.13 · ready
Build once (prepare + clean) Cloud-Init identity per clone Clone booted & ready
Figure 1 — Build the image once, then fan it out. Each clone boots from the same template but Cloud-Init hands it a unique identity — IP, hostname, SSH key, grown disk — in seconds.
SECTION 01 How Cloud-Init actually works

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:

Source 1 · Baked In
/etc/cloud/cloud.cfg.d/
Lives inside the disk image. Identical on every clone.
Source 2 · Per-Clone
Proxmox Cloud-Init drive
Set in the web console. Unique per VM — IP, user, key.
↓ Cloud-Init · merge & run once on first boot ↓
A fully-configured node
Its own hostname · IP · user · SSH key · grown disk
Figure 2 — Baked-in config + Proxmox drive merge at first boot.
Key insight
The Proxmox Cloud-Init tab only ever exposes a fixed set of fields (User, Password, SSH key, DNS, IP Config). There is no free-text "run commands" box. Anything you want every clone to do automatically must be baked into the image as a drop-in file — it then runs the same on all clones. Truly per-clone custom commands require the advanced cicustom snippet feature, which is CLI-only.

So the mental split you'll use throughout this guide:

  • Same on every clone (installed packages, kernel tuning, k8s binaries) → bake into the image
  • Unique per clone (hostname, IP, user, password, key) → the Proxmox Cloud-Init tab
SECTION 02 Prepare the base VM

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

Install the essential packages

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

bash · on the VM
sudo apt update
sudo apt install -y cloud-init qemu-guest-agent cloud-guest-utils
Why these:
  • qemu-guest-agent must also be enabled on the Proxmox side (Section 06)
  • cloud-guest-utils provides growpart, which Cloud-Init calls to expand the partition
SECTION 03 Disarm the installer's traps

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.

Inspect what the installer left behind
bash · on the VM
ls /etc/cloud/cloud.cfg.d/
# look for the two troublemakers below:
# 90-installer-network.cfg  and  99-installer.cfg
What each file does to you
FileWhat it secretly containsBreaksAction
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
The trap in one line: The single line datasource_list: [None] tells Cloud-Init never to look at the Proxmox drive. Your IP, user, password, SSH key, and hostname all arrive through that drive — so this one setting silently disables everything. Deleting the file fixes it all at once.
Apply the fix
bash · on the VM
# 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
Verify it's clean
bash · on the VM
ls /etc/cloud/cloud.cfg.d/
sudo cloud-init schema --system 2>&1 | tail -5
  Both installer files gone, 99-pve.cfg present, schema valid. A reference to iid-datasource-none is just stale cache — cleanup in Section 06 wipes it.
SECTION 04 Automatic disk growth

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.

Confirm the layout supports it
bash · on the VM
lsblk

You want / on a normal partition that is the last partition on the disk, like this:

output
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 ✓
The one rule: Root must be the last partition so there is free space behind it to grow into. If a swap partition sits after root, growth fails. (A swap file like /swap.img is fine — and we disable it for Kubernetes anyway.)
How to test it later
  • Note the size: df -h /
  • In Proxmox: Hardware → select the disk → Disk ActionResize → add e.g. 10 GiB
  • Reboot the VM
  • Confirm growth: df -h / now shows the larger size
SECTION 05 Bake Kubernetes into the image

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.

The stack we bake in
kubelet · kubeadm · kubectl (v1.30, held)
CNI plugins → /opt/cni/bin
containerd 1.7.14 + runc 1.1.12 (SystemdCgroup)
OS prep: swap off · kernel modules · sysctl forwarding
Ubuntu 24.04 LTS
Figure 3 — The node stack we bake into the template, bottom to top.
Never bake these in: kubeadm init, kubeadm join, and applying Calico are node-specific and cluster-specific. If baked in, every clone would try to bootstrap its own cluster and rewrite the network. We run those manually after cloning (Section 08). Calico is only downloaded into the image, not applied.

SSH into the VM and run these in order. They mirror the official kubeadm bring-up, with 2026-era pins for containerd/runc/CNI.

1 · OS preparation
bash
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
2 · containerd, runc & CNI plugins
bash
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
3 · Kubernetes 1.30 packages
bash
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
4 · Stage Calico (download only — do not apply)
bash
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
Expected weirdness
After this, systemctl status kubelet will show kubelet failing in a restart loop. That is normal. kubelet has no configuration until kubeadm init or join creates it — it self-heals the moment you bootstrap. Don't try to fix it in the template.
SECTION 06 Clean identity & convert to a template

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.

Cleanup, then shut down
bash · on the VM
# 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
Don't boot again: After cleaning, go straight to templating. Booting the VM regenerates a machine-id and may mark Cloud-Init as already-run, partly undoing the clean.
In the Proxmox web console
  • Add the Cloud-Init drive: Hardware → Add → CloudInit Drive → choose storage (e.g. local-lvm), bus ide2
  • Detach the install ISO: Hardware → CD/DVD Drive → Do not use any media
  • Enable the guest agent: Options → QEMU Guest Agent → Enabled
  • Rename the VM to k8s-template via Options → Name
  • Convert to template: right-click the VM → Convert to template
SECTION 07 Clone & customize — the payoff

Everything you built now pays off. Each clone takes seconds to create and gets its own identity entirely from the web console.

The clone workflow:
  • Right-click k8s-templateClone → choose Full Clone → set a Name. The VM name becomes the guest hostname — there is no separate hostname field.
  • Select the clone → Cloud-Init tab → fill in the fields (see table)
  • Click Regenerate Image if prompted
  • Resize the disk if this node needs more space: Hardware → disk → Disk Action → Resize
  • Start the VM
Cloud-Init tab — what each field sets
FieldWhat it sets
UserThe login account created on the clone
PasswordThat user's password (console login always; SSH if ssh_pwauth is on)
SSH public keyPaste 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
DNSOptional domain + resolvers
  On first boot Cloud-Init applies hostname, IP, user, password, key, regenerates unique machine-id & SSH host keys, and grows the disk — all automatically
SECTION 08 Bring up the cluster

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.

kubeadm init on master
prints join token
worker join
worker join
apply Calico
nodes Ready
Figure 4 — Manual cluster bring-up sequence.
The CIDR rule you must not get wrong

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.

Your LAN 192.168.20.0/24
✗ Calico default 192.168.0.0/16 — overlaps the LAN
✓ Use a 10.x CIDR 10.244.0.0/16 — no collision
Golden rule: kubeadm --pod-network-cidr == Calico cidr
Figure 5 — Why the default breaks, and the matching rule that fixes it.
On the master node only
bash · master
# 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
Apply the staged Calico (with a matching CIDR)
bash · master
# 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
On each worker node

Run the kubeadm join … command that kubeadm init printed on the master. It already contains the token and CA hash this cluster needs.

bash · worker
sudo kubeadm join <master-ip>:6443 --token <token> \
  --discovery-token-ca-cert-hash sha256:<hash>
SECTION 09 Verify & troubleshoot
Confirm a fresh clone configured itself
bash · on the clone
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
Confirm the cluster
bash · master
kubectl get nodes -o wide      # all nodes should reach Ready after Calico
kubectl get pods -A            # calico + system pods Running
When something's off
SymptomLikely causeFix
Injected IP never appliesAn installer network file crept back, or datasource is wrongRe-check Section 03; ensure 90-installer-network.cfg / 99-installer.cfg are gone and 99-pve.cfg exists
Hostname stays "ubuntu"preserve_hostname: true leftoverConfirm 99-installer.cfg was deleted
Disk didn't growRoot not the last partition, or growpart disabledCheck lsblk; grep logs: grep growpart /var/log/cloud-init.log
kubelet keeps restartingNormal before init/joinNothing — it heals on bootstrap
Pods can't reach each other / LAN flakyPod CIDR overlaps the LANReinstall with a 10.x CIDR matching Calico (Figure 5)
Two clones get the same DHCP IPmachine-id wasn't resetRe-run the cleanup in Section 06 before templating
Where the logs live
Cloud-Init: /var/log/cloud-init.log and /var/log/cloud-init-output.log. Full state dump: cloud-init query --all
SECTION 10 Reference: the optional baked-in config file

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

yaml — /etc/cloud/cloud.cfg.d/99-k8s.cfg
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 ]
Indentation matters: Keep every script line — including the EOF markers from the heredocs — at the same indentation under content: |. Cloud-Init strips the common indent when it writes the file. And keep all runcmd entries in a single file to avoid list-merge surprises.
One-page command map
PhaseWhereKey command / action
PrepVMapt install cloud-init qemu-guest-agent cloud-guest-utils
Fix trapsVMrm 90-installer-network.cfg 99-installer.cfg · create 99-pve.cfg
Install k8sVMOS prep → containerd/runc/CNI → kube packages → stage Calico
CleanVMcloud-init clean · reset machine-id · remove host keys · shut down
TemplatizeProxmoxAdd CloudInit drive · detach ISO · enable agent · Convert to template
CloneProxmoxFull Clone (name = hostname) · Cloud-Init tab · resize · Start
ClusterCloneskubeadm init --pod-network-cidr=10.244.0.0/16 · join · apply Calico
You did it

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.

Related Documentation
Proxmox Golden Image Handbook  ·  Cloud-Init · Kubernetes 1.30 · Calico