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
- Install OS from ISO (once)
- Configure base system (packages, settings)
- Add cloud-init for per-VM customization
- Convert to template
- Clone template for new VMs
- 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:
# Download cloud image (Ubuntu example)wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Create VMqm create 9000 --name "ubuntu-24.04-template" --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0
# Import cloud image as diskqm importdisk 9000 noble-server-cloudimg-amd64.img local-zfs
# Attach diskqm set 9000 --scsihw virtio-scsi-pci --scsi0 local-zfs:vm-9000-disk-0
# Add cloud-init driveqm set 9000 --ide2 local-zfs:cloudinit
# Set boot orderqm set 9000 --boot c --bootdisk scsi0
# Enable serial console (for cloud-init)qm set 9000 --serial0 socket --vga serial0Or via Web UI:
- Create VM with name like
ubuntu-2404-template - OS: Do not use any media (we’ll import cloud image)
- System: SCSI Controller = VirtIO SCSI
- Disks: Delete default disk
- After creation: Hardware → Add → CloudInit Drive
Using ISO Instead of Cloud Image
If you prefer traditional install:
# Create VM with ISOqm create 9001 --name "debian-12-template" --memory 2048 --cores 2 --cdrom local:iso/debian-12.iso --net0 virtio,bridge=vmbr0
# Add diskqm set 9001 --scsihw virtio-scsi-pci --scsi0 local-zfs:32
# Start and install via consoleqm start 9001Install OS, then prepare for templating (see next section).
Preparing for Template
Before converting to template, clean up the VM:
On Debian/Ubuntu
# Update everythingapt update && apt full-upgrade -y
# Install cloud-init and QEMU guest agentapt install -y cloud-init qemu-guest-agent
# Enable guest agentsystemctl enable qemu-guest-agent
# Clean package cacheapt cleanapt autoremove -y
# Remove machine-specific datarm -f /etc/machine-idrm -f /var/lib/dbus/machine-idtruncate -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 logsjournalctl --rotatejournalctl --vacuum-time=1srm -rf /var/log/*.logrm -rf /var/log/*.gz
# Clear bash historyhistory -crm -f /root/.bash_historyrm -f /home/*/.bash_history
# Shutdownshutdown -h nowOn RHEL/AlmaLinux/Rocky
# Updatednf update -y
# Install cloud-init and guest agentdnf install -y cloud-init qemu-guest-agent
# Enable servicessystemctl enable qemu-guest-agentsystemctl enable cloud-init
# Cleandnf clean allrm -rf /var/cache/dnf/*
# Same cleanup as Debianrm -f /etc/machine-idrm -f /etc/ssh/ssh_host_*cloud-init clean# ... etcCloud-Init Configuration
Cloud-init reads metadata at boot and configures the VM. Proxmox provides this via a special drive.
Proxmox Cloud-Init Settings
# Set cloud-init optionsqm set 9000 --ciuser adminqm set 9000 --cipassword 'temporary-password'qm set 9000 --sshkeys ~/.ssh/id_ed25519.pubqm set 9000 --ipconfig0 ip=dhcpOr 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:
# Create snippets storage if neededpvesm add dir snippets --path /var/lib/vz/snippets --content snippets
# Create custom cloud-init configcat > /var/lib/vz/snippets/custom-user-data.yaml << 'EOF'#cloud-configpackage_update: truepackage_upgrade: truepackages: - 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 UTCEOFApply to VM:
qm set 9000 --cicustom "user=snippets:snippets/custom-user-data.yaml"Converting to Template
Once the VM is prepared:
# Convert to templateqm template 9000Or 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.
qm clone 9000 100 --name "web-server" --fullLinked Clone
Shares base disk with template. Uses less space but depends on template.
qm clone 9000 101 --name "test-server" --full 0Warning: If you delete the template, linked clones break. Use full clones for production.
Post-Clone Configuration
After cloning, customize via cloud-init:
# Set hostname (cloud-init will apply on boot)qm set 100 --name "web-server"
# Set static IPqm set 100 --ipconfig0 ip=10.0.0.100/24,gw=10.0.0.1
# Start VMqm start 100Cloud-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 releaseubuntu-2404-v2 # Security updateubuntu-2404-v3 # Added monitoring agentVersion in Description
qm set 9000 --description "Ubuntu 24.04 LTSVersion: 3Date: 2025-01-08Changes:- Added node_exporter- Updated to kernel 6.8- Fixed cloud-init network"Golden Image Process
1. Monthly: Create new template from fresh ISO2. Weekly: Update packages on existing template (requires unconverting)3. Document: What changed, why, who approved
Template lifecycle: new-template → testing → production → deprecated → deletedMultiple Templates
Different workloads need different templates:
9000: ubuntu-2404-minimal # Base, SSH only9001: ubuntu-2404-web # + nginx, certbot9002: ubuntu-2404-docker # + docker, compose9003: debian-12-minimal # Different OS9004: almalinux-9-minimal # RHEL-compatibleBuild specialized templates from minimal:
# Clone minimal as base for web templateqm clone 9000 9100 --name "ubuntu-2404-web-prep" --fullqm start 9100
# SSH in, install web packagesssh admin@<ip>sudo apt install -y nginx certbot python3-certbot-nginx# ... configure ...sudo cloud-init cleansudo shutdown -h now
# Convert to templateqm template 9100Troubleshooting Cloud-Init
Cloud-Init Not Running
# Check if cloud-init rancloud-init status
# View logscat /var/log/cloud-init.logcat /var/log/cloud-init-output.log
# Re-run cloud-initcloud-init cleancloud-init initNetwork Not Configured
Check Proxmox cloud-init settings:
# View current cloud-init configqm cloudinit dump 100 userqm cloudinit dump 100 networkInside VM:
# Check cloud-init network configcat /etc/netplan/*.yaml # Ubuntucat /etc/sysconfig/network-scripts/* # RHELSSH Key Not Working
# Verify key was injectedcat /home/admin/.ssh/authorized_keys
# Check cloud-init log for errorsgrep -i ssh /var/log/cloud-init.logHostname Not Set
Cloud-init sets hostname early. If it’s still “localhost”:
# Check cloud-init statuscloud-init status --long
# Force hostname updatehostnamectl set-hostname web-serverAutomation 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.