Skip to main content
  1. Posts/

How to create and use VM templates in Proxmox 9.1

··1358 words·7 mins

Intro
#

Do you find yourself often creating new VMs and going through the tedious OS setup screens yourself? No? well I did. For the past years I’ve always dealt with installing the operating system myself because it’s only a few quick minutes worth of time but now that I’m in the Proxmox ecosystem, creating VMs adds to that, and it all just became too much.

Once the OS was installed, I would typically just run one bash script to set everything else up, but now I’ve leaned into cloud-init which does this even better (since I don’t even have to SSH into the machine first).

So in this guide I will walk you through how I set this up in my homelab.

Note

These steps require you to run commands as root on your Proxmox host. So make sure you SSH in there!

Creating snippets (cloud-init)
#

The first step is to create one (or many) snippets in Proxmox. These are just cloud-init yaml files. To do so, use the following commands:

# Create the snippets dir if it doesn't exist
mkdir -p /var/lib/vz/snippets

# Create an empty template file
touch /var/lib/vz/snippets/debian-13-basic.yaml

# Next you can either open the file using your preferred editor or follow me in using nano
nano /var/lib/vz/snippets/debian-13-basic.yaml

Now this step is open to customization but here is what I use for my base Debian 13 installs:

#cloud-config

# NOTE: Debian cloud images already default to UTC, so this line is
# explicit-but-redundant. Kept for clarity.
timezone: UTC

# User + key. REQUIRED here because --cicustom user= replaces the auto user-data,
# so --ciuser/--sshkeys are ignored.
users:
  - name: <YOUR-USERNAME-HERE> # Replace this with your username!
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    lock_passwd: true # keys only, no password login
    ssh_authorized_keys:
      - ssh-ed25519 <YOUR-KEY-HERE> # Replace this with your ssh key!

# Script's sed block to kill password auth.
ssh_pwauth: false

# Script's apt-get update + upgrade. (package_upgrade = `apt upgrade`)
package_update: true
package_upgrade: true

# Install common utils. I like UFW for firewall management, but you might not need it.
packages:
  - ufw
  - htop
  - tmux
  - nload
  - ncdu
  - zip
  - unzip
  - rsync
  - cron

# Increase open file limits and harden SSH
write_files:
  # Script's limits.conf nofile block — governs PAM login sessions (your SSH shells)
  - path: /etc/security/limits.d/99-nofile.conf
    permissions: '0644'
    content: |
      * soft nofile 65535
      * hard nofile 65535
  # nofile for systemd-managed SERVICES — limits.conf above does NOT cover these.
  # Covers daemons (web server, DB, etc.) started by systemd.
  - path: /etc/systemd/system.conf.d/99-nofile.conf
    permissions: '0644'
    content: |
      [Manager]
      DefaultLimitNOFILE=65535:65535
  # Script's sysctl fs.file-max
  - path: /etc/sysctl.d/99-file-max.conf
    permissions: '0644'
    content: |
      fs.file-max = 2097152
  # SSH hardening drop-in. ssh_pwauth above already sets PasswordAuthentication no;
  # this adds KbdInteractiveAuthentication.
  - path: /etc/ssh/sshd_config.d/99-hardening.conf
    permissions: '0644'
    content: |
      PasswordAuthentication no
      KbdInteractiveAuthentication no

# Run arbitrary commands as needed
runcmd:
  # Script's dist-upgrade (package_upgrade only does plain upgrade)
  - apt-get dist-upgrade -y
  # Script ensured pam_limits.so is loaded for sessions (idempotent)
  - grep -q pam_limits.so /etc/pam.d/common-session || echo "session required pam_limits.so" >> /etc/pam.d/common-session
  - sysctl --system
  # Allow SSH (port 22) through the firewall, block everything else.
  - ufw allow ssh
  - ufw --force enable
  # Ensure the sshd updates are enabled.
  - systemctl restart ssh

# One-time reboot at the end of first boot so file limit and other reboot requiring changes are live.
power_state:
  mode: reboot
  message: "Rebooting after cloud-init base setup"
  condition: true

Make sure to update the template with your values for the <YOUR-USERNAME-HERE> and <YOUR-KEY-HERE> placeholders!

Once you have one or more cloud-init configurations setup, you are ready to move to the next step.

Creating the template
#

You will want to grab the link for the qcow2 file for your distribution. For this demo since I am using Debian 13, it is https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2

You will have to run the following sequence of commands, substituting some key network information as required (such as bridge=vmbr0 if you are like me using a custom bridge to allow multiple VMs behind a host with only one IP to have internet access)

  cd /tmp
  
  # Download the Debian 13 cloud image
  wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
  
  # Create empty VM 9000, virtio NIC on vmbr0 (no disk yet). 9000 = template convention
  qm create 9000 --name debian13-tmpl --net0 virtio,bridge=vmbr0
  
  # Import the qcow2 AND attach as primary disk (scsi0) in one step.
  # local:0 -> storage "local", ":0" = size from image; virtio-scsi-pci = fast controller
  qm set 9000 --scsi0 local:0,import-from=/tmp/debian-13-generic-amd64.qcow2 --scsihw virtio-scsi-pci
  
  # Cloud-init drive (feeds per-VM config on first boot)
  qm set 9000 --ide2 local:cloudinit
  
  # Boot from disk, not network/CD
  qm set 9000 --boot c --bootdisk scsi0
  
  # Serial console — cloud images expect serial output
  qm set 9000 --serial0 socket --vga serial0
  
  # Portable CPU baseline (also the PVE default; explicit so restores onto other hosts work)
  # The default might be different if you are using a version of Proxmox older than 9.1
  qm set 9000 --cpu x86-64-v2-AES
  
  # If you are manually configuring IPs and don't have DHCP, you will want to set the
  # nameservers up, otherwise you will not have working DNS. You may also choose to always
  # want set nameservers instead of leaving it to the host default
  # Omit this if you have working DHCP
  qm set 9000 --nameserver "1.1.1.1 8.8.8.8"
  
  # Default cloud-init flavor (clones can override before start)
  qm set 9000 --cicustom "user=local:snippets/debian-13-basic.yaml"
  
  # Convert to template (can't start directly, only clone)
  qm template 9000
  
  # Image is now inside "local" storage; delete the download
  rm /tmp/debian-13-generic-amd64.qcow2

Using the template
#

Spinning up VMs from the VM template + cloud-init configs (snippets) you created is simple:

# Replace 106 (VM ID) with whatever is next on your instance.

# Clone from template. Inherits disk, NIC, cloud-init drive, cpu, AND nameserver
# Replace web01 by whatever you want the VM name as in Proxmox
# Append --full to create a fully independent clone instead of the default linked clone
# (linked = fast/space-efficient, shares the template's base disk; full = independent but copies the whole disk).
# Use --full for VMs you want to keep long-term or migrate between hosts.
qm clone 9000 106 --name web01

# Pick this VM's flavor by overriding cicustom BEFORE first boot.
# Omit to keep the template default (basic)
qm set 106 --cicustom "user=local:snippets/debian-13-basic.yaml"

# This VM's cores + RAM (MiB)
qm set 106 --cores 4 --memory 8192

# Grow disk; cloud-init expands the filesystem on first boot
# This gives you base image size + 30G of usable disk.
qm resize 106 scsi0 +30G

# Network — must match your vmbr0 subnet. Don't reuse an IP a live VM already holds
# Omit this if you have working DHCP
qm set 106 --ipconfig0 ip=10.10.10.7/24,gw=10.10.10.1

# Boot. cloud-init runs once: user, key, packages, runcmd, then a one-time reboot
qm start 106

Once the VM is started, you should see it running in the Proxmox UI. Keep in mind that it can take a few minutes for the VM to finish setting itself up. What I like to do is ssh in and the run the following command that tells you the cloud-init status

# Prints dots if the process is still ongoing
cloud-init status --wait

Then once the VM reboots on me I know it is done. Once it comes back, I like to set the hostname of the server (because --cicustom user= replaces Proxmox’s generated user-data this does not get set automatically)

sudo hostnamectl set-hostname web01

Extra
#

To tear a VM down you can either use the Proxmox UI or in the nature of this guide, use the command line:

# Stops the VM
qm stop <id>
# Destroys the VM. This deletes the VM config and disk (only the primary disk)
qm destroy <id>
Warning

If you used linked clones (the default), you can’t destroy the template (id 9000) while clones still depend on it. Proxmox will block it with a “still referenced” error. The simplest way to break this link is to clone your linked VM into a new full one using qm clone <linked-id> <new-id> --full.

Thanks for reading,

Danny Ferguson
Author
Danny Ferguson
Self-taught software developer and Linux system administrator with over a decade of experience