Installing every VM by hand produces slightly different machines and eats time. A cloud-init template moves customization (user, SSH keys, network) to first boot, and Ansible makes the whole process repeatable: cloning, configuration and post-install all live in a repository. This guide covers Proxmox VE 8 or 9 and Debian 12 or 13 VMs. Storage, VMIDs and addresses are examples.
Why a template
A template is a reference VM from which new ones are cloned. With cloud-init the image stays generic: user, SSH keys, hostname and address are injected at first boot from a dedicated drive, the cloud-init disk. The machines come out uniform, no secrets end up in the image, and the procedure is described by commands instead of manual steps.
Creating the template with qm
The official Debian cloud images are published on cloud.debian.org; the genericcloud variant is meant for virtual environments. Before using it, it is worth verifying the checksum against the SHA512SUMS file in the same directory.
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
wget https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
sha512sum --ignore-missing -c SHA512SUMSqm create 9000 --name debian13-cloudinit --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci
qm set 9000 --scsi0 local-lvm:0,import-from=/root/debian-13-genericcloud-amd64.qcow2
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --boot order=scsi0
qm set 9000 --serial0 socket --vga serial0
qm template 9000The second command imports the image directly as a SCSI disk; alternatively, qm disk import (alias qm importdisk) imports it as an unused disk, to be attached afterwards with qm set. The serial console is the setting Proxmox recommends for cloud images.
Warning
Storage names (local-lvm), VMIDs and the image path are examples: check them with pvesm status and qm list before running the commands. A wrong storage name makes the import fail or, worse, uses the wrong storage.
Cloning and configuring a VM
qm clone 9000 201 --name vm-esempio --full 1
qm set 201 --ciuser debian --sshkeys /root/chiavi/amministratore.pub --ipconfig0 ip=192.0.2.50/24,gw=192.0.2.1
qm resize 201 scsi0 +8G
qm start 201The --sshkeys option takes the path of a file that must exist on the node, with one public key per line. Without --ipconfig0, cloud-init uses DHCP. On first boot it also extends the root filesystem to the enlarged disk. A full clone (--full 1) is independent of the template; for a Proxmox template, a linked clone is the alternative, and it depends on the template. In a cluster, such as the one described in three-node Proxmox cluster, a template on local storage can only be cloned on the node that hosts it: choosing the target node requires shared storage.
The same flow with Ansible
Requirements
Note
The Proxmox modules live in the community.proxmox collection. They used to be part of community.general (community.general.proxmox_kvm): the old names remain as deprecated redirects, due to be removed, so new playbooks use the new name.
The module requires the Python libraries proxmoxer and requests in the environment where it runs: with hosts: localhost that is the same one as Ansible. A virtual environment keeps them separate from the system.
python3 -m venv ~/venv-ansible
~/venv-ansible/bin/pip install ansible-core proxmoxer requests
~/venv-ansible/bin/ansible-galaxy collection install community.proxmox ansible.posixAuthenticating with an API token
The module authenticates with an API token of a dedicated user, not with root@pam and not with a password. Privileges are limited to what is needed, for example the PVEVMAdmin role on a pool of VMs and PVEDatastoreUser on the target storages, assigned to user and token with the pveum commands shown in the article on monitoring with Prometheus and Grafana. The source template must belong to the same pool, or the token must have at least the VM.Clone privilege on it. Host and API user go in group_vars/all/vars.yml. The token ID and value (pve_api_token_id and vault_pve_api_token_secret) go in a file encrypted with Ansible Vault. With self-signed certificates it is better to install the CA on the workstation than to disable validate_certs.
The provisioning playbook
- name: Provision a VM from a cloud-init template
hosts: localhost
gather_facts: false
module_defaults:
community.proxmox.proxmox_kvm:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ vault_pve_api_token_secret }}"
vars:
nodo: nodo1
vm_nome: vm-esempio
vm_id: 201
tasks:
- name: Clone the template
community.proxmox.proxmox_kvm:
node: "{{ nodo }}"
clone: debian13-cloudinit
name: "{{ vm_nome }}"
newid: "{{ vm_id }}"
full: true
timeout: 300
register: clonazione
- name: Set user, SSH key and network
community.proxmox.proxmox_kvm:
node: "{{ nodo }}"
name: "{{ vm_nome }}"
update: true
ciuser: debian
sshkeys: "{{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}"
ipconfig:
ipconfig0: "ip=192.0.2.50/24,gw=192.0.2.1"
when: clonazione is changed
- name: Start the VM
community.proxmox.proxmox_kvm:
node: "{{ nodo }}"
name: "{{ vm_nome }}"
state: started
- name: Wait for the SSH port
ansible.builtin.wait_for:
host: 192.0.2.50
port: 22
timeout: 300Idempotence
The clone is idempotent only if newid is fixed: if a VM with that VMID already exists, the task reports changed=false and does not clone again. Without newid, every run would look for a free ID and create another VM. The task with update: true does not compare the current configuration and always reports a change: this is why it is tied to the outcome of the clone with when: clonazione is changed. state: started does nothing if the VM is already running. The check is simple: a second run must end with changed=0. The constraint has a limit, though: if the first run is interrupted after the clone, the second one does not reapply the cloud-init configuration, and the incomplete VM has to be deleted or configured by hand.
Post-install configuration
The second playbook works on the VM that has just started. It connects with the image's default user, which has sudo, using the key injected by cloud-init.
[vm_nuove]
vm-esempio ansible_host=192.0.2.50 ansible_user=debian- name: Post-install configuration
hosts: vm_nuove
become: true
vars:
utente_operativo: operatore
tasks:
- name: Apply updates
ansible.builtin.apt:
update_cache: true
upgrade: dist
cache_valid_time: 3600
- name: Create the operating user
ansible.builtin.user:
name: "{{ utente_operativo }}"
shell: /bin/bash
- name: Install the SSH public key
ansible.posix.authorized_key:
user: "{{ utente_operativo }}"
key: "{{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}"
- name: Install the guest agent
ansible.builtin.apt:
name: qemu-guest-agent
state: presentThe operatore user is an unprivileged account for day-to-day tasks: administration goes through the default user and sudo. The guest agent must also be enabled in the VM configuration with qm set 201 --agent enabled=1. The same playbook is the natural place to roll out the configuration described in SSH hardening on Debian.
Keeping secrets out of Git
The repository with the playbooks is meant to be versioned and shared, so it must not contain anything that grants access to the infrastructure. Ansible Vault encrypts a whole file or a single variable: only the ciphertext ends up in Git.
ansible-vault create group_vars/all/vault.yml
ansible-playbook provisioning.yml --ask-vault-passThe first line opens the editor and saves the file already encrypted. Instead of --ask-vault-pass you can use --vault-password-file with a file outside the repository and 0600 permissions, or a script that reads the credential from a secrets manager.
.vault-pass
.envOther practical rules:
- No values on the command line. A secret passed with
-eends up in the shell history and in the process list. no_log: trueon tasks that handle secrets, so they are not written to the output.- A secret scanner such as gitleaks, run before every commit, catches mistakes before publication.
Caution
A token that has ended up in a repository must be considered compromised: revoke it and generate a new one. A new commit that removes it is not enough, because the value stays in the history and in every copy already cloned.