m4nifest0-tech

Search the site

Automation

Provisioning Proxmox VMs with cloud-init templates and Ansible

How to build a cloud-init template on Proxmox VE, clone it with qm and automate the same flow with Ansible, keeping secrets out of Git.
Published on Updated on 8 min readLevel: Advanced

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.

On the Proxmox node
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 SHA512SUMS
On the Proxmox node
qm 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 9000

The 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

On the Proxmox node
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 201

The --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.

On the admin workstation
python3 -m venv ~/venv-ansible
~/venv-ansible/bin/pip install ansible-core proxmoxer requests
~/venv-ansible/bin/ansible-galaxy collection install community.proxmox ansible.posix

Authenticating 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

provisioning.yml
- 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: 300

Idempotence

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.

inventory.ini
[vm_nuove]
vm-esempio ansible_host=192.0.2.50 ansible_user=debian
post-install.yml
- 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: present

The 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.

On the admin workstation
ansible-vault create group_vars/all/vault.yml
ansible-playbook provisioning.yml --ask-vault-pass

The 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.

.gitignore
.vault-pass
.env

Other practical rules:

  • No values on the command line. A secret passed with -e ends up in the shell history and in the process list.
  • no_log: true on 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.

Related articles