Templates & Cloud-Init: Faster VMs Without Chaos

Installing an OS from ISO takes 15-30 minutes. Do that for every VM and you’ll spend more time waiting for installers than doing actual work. Templates solve this: install once, clone many times. A new VM in seconds instead of minutes.

But templates have a hidden cost. When the template changes, everything cloned from it is different. When the template is inconsistent, every VM is a surprise. The template is a contract — if it floats, everything downstream breaks.

This is how to build templates that work reliably.

The Basic Workflow

  1. Install OS from ISO (once)
  2. Configure base system (packages, settings)
  3. Add cloud-init for per-VM customization
  4. Convert to template
  5. Clone template for new VMs
  6. Cloud-init configures hostname, network, SSH keys
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ ISO │───▶│ Base VM │───▶│ Template │
│ Install │ │ Configure │ │ (frozen) │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Clone 1 │ │ Clone 2 │ │ Clone 3 │
│ web-server │ │ db-server │ │ app-server │
└─────────────┘ └─────────────┘ └─────────────┘

Creating a Base VM

Start with minimal install:

Terminal window
# Download cloud image (Ubuntu example)
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Create VM
qm create 9000 --name "ubuntu-24.04-template" --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0
# Import cloud image as disk
qm importdisk 9000 noble-server-cloudimg-amd64.img local-zfs
# Attach disk
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-zfs:vm-9000-disk-0
# Add cloud-init drive
qm set 9000 --ide2 local-zfs:cloudinit
# Set boot order
qm set 9000 --boot c --bootdisk scsi0
# Enable serial console (for cloud-init)
qm set 9000 --serial0 socket --vga serial0

Or via Web UI:

  1. Create VM with name like ubuntu-2404-template
  2. OS: Do not use any media (we’ll import cloud image)
  3. System: SCSI Controller = VirtIO SCSI
  4. Disks: Delete default disk
  5. After creation: Hardware → Add → CloudInit Drive

Using ISO Instead of Cloud Image

If you prefer traditional install:

Terminal window
# Create VM with ISO
qm create 9001 --name "debian-12-template" --memory 2048 --cores 2 --cdrom local:iso/debian-12.iso --net0 virtio,bridge=vmbr0
# Add disk
qm set 9001 --scsihw virtio-scsi-pci --scsi0 local-zfs:32
# Start and install via console
qm start 9001

Install OS, then prepare for templating (see next section).

Preparing for Template

Before converting to template, clean up the VM:

On Debian/Ubuntu

Terminal window
# Update everything
apt update && apt full-upgrade -y
# Install cloud-init and QEMU guest agent
apt install -y cloud-init qemu-guest-agent
# Enable guest agent
systemctl enable qemu-guest-agent
# Clean package cache
apt clean
apt autoremove -y
# Remove machine-specific data
rm -f /etc/machine-id
rm -f /var/lib/dbus/machine-id
truncate -s 0 /etc/machine-id
# Remove SSH host keys (regenerate on first boot)
rm -f /etc/ssh/ssh_host_*
# Remove cloud-init state (so it runs fresh on clone)
cloud-init clean
# Clear logs
journalctl --rotate
journalctl --vacuum-time=1s
rm -rf /var/log/*.log
rm -rf /var/log/*.gz
# Clear bash history
history -c
rm -f /root/.bash_history
rm -f /home/*/.bash_history
# Shutdown
shutdown -h now

On RHEL/AlmaLinux/Rocky

Terminal window
# Update
dnf update -y
# Install cloud-init and guest agent
dnf install -y cloud-init qemu-guest-agent
# Enable services
systemctl enable qemu-guest-agent
systemctl enable cloud-init
# Clean
dnf clean all
rm -rf /var/cache/dnf/*
# Same cleanup as Debian
rm -f /etc/machine-id
rm -f /etc/ssh/ssh_host_*
cloud-init clean
# ... etc

Cloud-Init Configuration

Cloud-init reads metadata at boot and configures the VM. Proxmox provides this via a special drive.

Proxmox Cloud-Init Settings

Terminal window
# Set cloud-init options
qm set 9000 --ciuser admin
qm set 9000 --cipassword 'temporary-password'
qm set 9000 --sshkeys ~/.ssh/id_ed25519.pub
qm set 9000 --ipconfig0 ip=dhcp

Or via Web UI: VM → Cloud-Init tab:

  • User: admin
  • Password: (set or leave empty for SSH-only)
  • SSH public key: paste your key
  • IP Config: DHCP or static

Custom Cloud-Init

For advanced configuration, use snippets:

Terminal window
# Create snippets storage if needed
pvesm add dir snippets --path /var/lib/vz/snippets --content snippets
# Create custom cloud-init config
cat > /var/lib/vz/snippets/custom-user-data.yaml << 'EOF'
#cloud-config
package_update: true
package_upgrade: true
packages:
- vim
- htop
- curl
- git
users:
- name: admin
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAA... your-key
write_files:
- path: /etc/motd
content: |
Welcome to the VM
Provisioned by cloud-init
runcmd:
- systemctl enable --now qemu-guest-agent
- timedatectl set-timezone UTC
EOF

Apply to VM:

Terminal window
qm set 9000 --cicustom "user=snippets:snippets/custom-user-data.yaml"

Converting to Template

Once the VM is prepared:

Terminal window
# Convert to template
qm template 9000

Or via Web UI: Right-click VM → Convert to template

The VM icon changes to indicate it’s a template. Templates cannot be started — only cloned.

Cloning VMs

Full Clone

Creates independent copy. Disk is duplicated.

Terminal window
qm clone 9000 100 --name "web-server" --full

Linked Clone

Shares base disk with template. Uses less space but depends on template.

Terminal window
qm clone 9000 101 --name "test-server" --full 0

Warning: If you delete the template, linked clones break. Use full clones for production.

Post-Clone Configuration

After cloning, customize via cloud-init:

Terminal window
# Set hostname (cloud-init will apply on boot)
qm set 100 --name "web-server"
# Set static IP
qm set 100 --ipconfig0 ip=10.0.0.100/24,gw=10.0.0.1
# Start VM
qm start 100

Cloud-init runs on first boot, setting hostname, network, and SSH keys.

Template Versioning

Templates evolve. Kernel updates, package changes, security patches. Track versions:

Naming Convention

ubuntu-2404-v1 # Initial release
ubuntu-2404-v2 # Security update
ubuntu-2404-v3 # Added monitoring agent

Version in Description

Terminal window
qm set 9000 --description "Ubuntu 24.04 LTS
Version: 3
Date: 2025-01-08
Changes:
- Added node_exporter
- Updated to kernel 6.8
- Fixed cloud-init network"

Golden Image Process

1. Monthly: Create new template from fresh ISO
2. Weekly: Update packages on existing template (requires unconverting)
3. Document: What changed, why, who approved
Template lifecycle:
new-template → testing → production → deprecated → deleted

Multiple Templates

Different workloads need different templates:

9000: ubuntu-2404-minimal # Base, SSH only
9001: ubuntu-2404-web # + nginx, certbot
9002: ubuntu-2404-docker # + docker, compose
9003: debian-12-minimal # Different OS
9004: almalinux-9-minimal # RHEL-compatible

Build specialized templates from minimal:

Terminal window
# Clone minimal as base for web template
qm clone 9000 9100 --name "ubuntu-2404-web-prep" --full
qm start 9100
# SSH in, install web packages
ssh admin@<ip>
sudo apt install -y nginx certbot python3-certbot-nginx
# ... configure ...
sudo cloud-init clean
sudo shutdown -h now
# Convert to template
qm template 9100

Troubleshooting Cloud-Init

Cloud-Init Not Running

Terminal window
# Check if cloud-init ran
cloud-init status
# View logs
cat /var/log/cloud-init.log
cat /var/log/cloud-init-output.log
# Re-run cloud-init
cloud-init clean
cloud-init init

Network Not Configured

Check Proxmox cloud-init settings:

Terminal window
# View current cloud-init config
qm cloudinit dump 100 user
qm cloudinit dump 100 network

Inside VM:

Terminal window
# Check cloud-init network config
cat /etc/netplan/*.yaml # Ubuntu
cat /etc/sysconfig/network-scripts/* # RHEL

SSH Key Not Working

Terminal window
# Verify key was injected
cat /home/admin/.ssh/authorized_keys
# Check cloud-init log for errors
grep -i ssh /var/log/cloud-init.log

Hostname Not Set

Cloud-init sets hostname early. If it’s still “localhost”:

Terminal window
# Check cloud-init status
cloud-init status --long
# Force hostname update
hostnamectl set-hostname web-server

Automation with Templates

Combine templates with automation:

Terraform

resource "proxmox_vm_qemu" "web_servers" {
count = 3
name = "web-${count.index + 1}"
target_node = "pve1"
clone = "ubuntu-2404-minimal"
full_clone = true
cores = 2
memory = 4096
network {
model = "virtio"
bridge = "vmbr0"
tag = 20
}
ipconfig0 = "ip=10.20.0.${count.index + 10}/24,gw=10.20.0.1"
ciuser = "admin"
sshkeys = file("~/.ssh/id_ed25519.pub")
}

Ansible

- name: Create VM from template
community.general.proxmox_kvm:
api_host: pve1.lab.local
api_user: admin@pam
api_token_id: ansible
api_token_secret: "{{ vault_proxmox_token }}"
node: pve1
name: "web-server"
clone: "ubuntu-2404-minimal"
full: yes
ciuser: admin
sshkeys: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
ipconfig:
ipconfig0: "ip=10.0.0.100/24,gw=10.0.0.1"

The Lesson

A template is a contract. If it floats, everything downstream breaks.

The template defines what every cloned VM starts with:

  • Installed packages
  • Security configuration
  • User accounts
  • Base services

When you change the template, new VMs get the change. Existing VMs don’t — they’re already deployed. This creates drift.

Treat templates like production artifacts:

  • Version them (ubuntu-2404-v3, not just ubuntu-2404)
  • Document changes (what, when, why)
  • Test before promoting (clone, verify, then use for production)
  • Retire old versions (don’t let 5 versions of “ubuntu template” accumulate)

A stable template means predictable deployments. An unstable template means debugging why “this VM is different” every time something breaks.