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:

  1. Sets hostname from --name (override with --searchdomain and --ipconfig0)
  2. Configures network from --ipconfig0
  3. Resizes the root partition + filesystem to fill the resized disk
  4. Writes SSH keys to ~ubuntu/.ssh/authorized_keys
  5. Optionally sets ubuntu user password from --cipassword
  6. 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 — and qm start returns 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 --ipconfig0 after first boot doesn't re-apply. To re-trigger, run cloud-init clean inside the VM and reboot — but at that point you're past the "fast" promise.
  • --cipassword is 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-lvm is 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 with pvesh 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.


Related posts