Manually clicking through Create VM for the 50th time on the same Proxmox host gets old. Build a cloud-init template once, clone in a single command, get a fully provisioned Ubuntu VM in 30 seconds — hostname, IP, SSH key, no console interaction.
This is what I run on node1. Five-minute setup, every VM after that is one command.
TL;DR — once the template exists:
qm clone 9000 110 --name web02 --full
qm set 110 --ipconfig0 ip=10.10.10.110/24,gw=10.10.10.1
qm set 110 --sshkeys ~/.ssh/authorized_keys
qm resize 110 scsi0 +30G
qm start 110
Wait 30 seconds, ssh ubuntu@10.10.10.110, you're in.
Build the template once
Download the Ubuntu cloud image (24.04 LTS shown — substitute newer release as you like):
cd /var/lib/vz/template/iso
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
Cloud images are pre-installed Ubuntu with cloud-init enabled — that's what makes the runtime configuration work. No installer, no console.
Create the template VM (ID 9000 — high enough to never collide with real VMs):
qm create 9000 --name ubuntu-2404-template --memory 2048 --cores 2 \
--net0 virtio,bridge=vmbr0 --ostype l26 --machine q35 --bios ovmf
qm set 9000 --efidisk0 local-lvm:0,format=raw,efitype=4m,pre-enrolled-keys=1
qm importdisk 9000 noble-server-cloudimg-amd64.img local-lvm
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-1
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --boot c --bootdisk scsi0
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000
The serial-console + qemu-agent lines matter. Without them, qm start hangs at boot waiting for graphical console initialization. Cloud images don't ship a graphical console.
qm template 9000 converts to template — read-only, can be cloned but not started directly.
Clone — the actual fast bit
Linked clone (fast, shares base disk — quick boot, can't migrate cleanly):
qm clone 9000 110 --name web02
Full clone (recommended, takes 10-20 seconds depending on disk):
qm clone 9000 110 --name web02 --full
Set the cloud-init config:
qm set 110 --ipconfig0 ip=10.10.10.110/24,gw=10.10.10.1 --nameserver 1.1.1.1
qm set 110 --sshkeys ~/.ssh/authorized_keys
qm set 110 --ciuser ubuntu --cipassword $(openssl rand -base64 12)
qm resize 110 scsi0 +30G
qm start 110
Cloud images default to a tiny 2.2 GB disk. qm resize +30G grows it; cloud-init expands the filesystem at first boot.
--ipconfig0 ip=dhcp if you'd rather DHCP. I prefer static — DHCP on a homelab gets messy when the lease database goes stale.
Wait 20-30 seconds for cloud-init to finish first-boot. Then:
ssh ubuntu@10.10.10.110
Default user is ubuntu. Cloud-init dropped your SSH key into ~ubuntu/.ssh/authorized_keys. Sudo is passwordless for the ubuntu group.
What cloud-init actually does
On first boot, cloud-init reads the cloud-init disk (the --ide2 local-lvm:cloudinit we attached) and runs configuration:
- Sets hostname from
--name(override with--searchdomainand--ipconfig0) - Configures network from
--ipconfig0 - Resizes the root partition + filesystem to fill the resized disk
- Writes SSH keys to
~ubuntu/.ssh/authorized_keys - Optionally sets
ubuntuuser password from--cipassword - Runs any custom user-data scripts you pass via
--cicustom
Then it disables itself for subsequent boots (touches /etc/cloud/cloud-init.disabled style flags). Reboots are normal Ubuntu.
Custom user-data — the hidden multiplier
--cicustom is where this stops being "fast clone" and starts being "fully provisioned". Drop a snippet at /var/lib/vz/snippets/web-baseline.yaml:
#cloud-config
packages:
- nginx
- ufw
- fail2ban
runcmd:
- ufw allow 22/tcp
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw --force enable
- systemctl enable --now nginx
- timedatectl set-timezone Europe/Berlin
write_files:
- path: /etc/ssh/sshd_config.d/hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
Then on clone:
qm set 110 --cicustom "user=local:snippets/web-baseline.yaml"
The new VM boots with nginx, UFW configured, SSH hardened, timezone set. Total elapsed: 45 seconds. Every web VM I provision uses this baseline.
A wrapper script
I have this saved as /root/bin/newvm.sh:
#!/bin/bash
set -e
ID=$1
NAME=$2
IP=$3
qm clone 9000 $ID --name $NAME --full
qm set $ID --ipconfig0 ip=$IP/24,gw=10.10.10.1 --nameserver 1.1.1.1
qm set $ID --sshkeys /root/.ssh/authorized_keys
qm set $ID --cicustom "user=local:snippets/web-baseline.yaml"
qm resize $ID scsi0 +30G
qm start $ID
echo "VM $ID ($NAME) starting at $IP — 30s to ready"
Then newvm.sh 110 web02 10.10.10.110 and walk away.
Updating the template
When Ubuntu releases a new point release, update the template:
qm stop 9000 # template can't be started, but if you converted back...
# Easiest: build a new template (e.g. 9001) and update the wrapper script
I don't update in place — I number templates by date (ubuntu-2404-2026-05 etc.) and bump the script when a new one is ready. Old VMs stay on the image they were cloned from; they get patched via unattended-upgrades.
Gotchas
- No serial console = silent boot failure. If you skip
--serial0 socket --vga serial0, the VM boots but you can't tell — andqm startreturns success while nothing happened. Always include them. - Linked clone + template move = broken VMs. Linked clones share the template's base disk. Move the template to different storage, every linked clone breaks. Use full clones unless you know what you're doing.
- Cloud-init runs once. Changing
--ipconfig0after first boot doesn't re-apply. To re-trigger, runcloud-init cleaninside the VM and reboot — but at that point you're past the "fast" promise. --cipasswordis plaintext in the cloud-init disk. Anyone who can read the cloud-init disk image can read the password. Use SSH keys + a random throwaway password.- Disk fills slowly. A 30 GB disk on
local-lvmis thin-provisioned by default. The actual disk fills as the VM uses it. Means you can over-allocate, but means you can also fill the host's storage by accident — monitor withpvesh get /nodes/<node>/storage/local-lvm/status.
Why this beats Terraform on a homelab
For 5 VMs on one host, Terraform's overhead (provider config, state file, plan/apply ceremony) costs more time than typing qm clone directly. The wrapper script above is 15 lines, lives on the Proxmox host, has zero state.
For 50 VMs across multiple hosts and DCs — yes, Terraform. But that's a different post.
For homelab context, also see unattended-upgrades and Linux user management — the things you'd otherwise repeat per VM.