Ansible

Ansible + Proxmox: Automated VM Management

Ansible talks to Proxmox through the REST API on port 8006, so playbooks run on the control node and never SSH into the hypervisor. The community.proxmox collection does that work, and its 2.x releases changed three things to know before you reuse an older ansible proxmox playbook. Certificate checks are now on by default, the modules refuse to run with proxmoxer older than 2.3, and the full user@realm!tokenid token ID only works on its own since 2.1.0, per the 2.1.0 changelog.

Original content from computingforgeeks.com - post 165312

You will create a least-privilege API token and trust the cluster CA instead of disabling validation. Then you’ll use the ansible proxmox modules to clone a cloud-init template, boot it with your SSH key, snapshot it, roll it back and destroy it. Every playbook below was run against Proxmox VE 9.2 on Debian 13 with ansible-core 2.21 and community.proxmox 2.1.0 in October 2026, and all output is real.

Prerequisites

The Ansible side is light, because every Proxmox task is an HTTPS call made from the control node. What you do need is a control node that can reach two networks: the Proxmox API, and the subnet your new VMs land on, because the last play in this guide configures the guest over SSH. Before you start, have these ready:

  • A Proxmox VE 9 node or cluster with root access
  • A Linux control node with Python 3 and the venv module
  • TCP 8006 open from the control node to the API
  • SSH from the control node to the VM subnet
  • A cloud-init template, or 10 minutes to build one below

Our lab was a single Proxmox VE node installed on Debian 13 the way the Proxmox VE on Debian install guide describes. It ran nested inside a 4 vCPU, 8 GB VM with the host CPU type passed through, so the guests got real KVM acceleration. Treat that spec as a floor for following along, not a sizing recommendation, since real hosts are sized for the guests they carry and Ansible adds nothing to that.

The node’s vmbr0 bridge is an internal 10.10.10.0/24 network with NAT, which is why our guests sit on 10.10.10.x and the API on 10.0.1.50. We ran Ansible on that Proxmox node itself, so the control node could reach the 10.10.10.x guests directly. With a separate control node, give it a route to the guest network or put the guests on a routable bridge, otherwise the Wait for SSH step later on times out. If playbooks, roles and variables are still new to you, the Ansible automation guide covers the basics this page assumes, and our pick of the best Ansible books is the slower, deeper route.

Create a least-privilege API token

Give Ansible an API token on a dedicated user, never root@pam with a password. You can revoke a token without touching anyone’s login, every action it takes is logged under its own name, and with privilege separation on it can never do more than the role you hand it. Run these three commands as root on any node in the cluster. They create a role that holds only the privileges the playbooks in this guide call, a pve realm user, and an ACL that gives the user that role:

pveum role add AnsibleProv --privs "VM.Allocate VM.Audit VM.Clone VM.Config.CPU VM.Config.Cloudinit VM.Config.Disk VM.Config.Memory VM.Config.Network VM.Config.Options VM.PowerMgmt VM.Snapshot VM.Snapshot.Rollback Datastore.AllocateSpace Datastore.Audit SDN.Use Sys.Audit"
pveum user add ansible@pve --comment "Ansible automation"
pveum acl modify / --users ansible@pve --roles AnsibleProv

We built that privilege list from what each playbook does, and the table shows which step uses which group. We did not test removing them one at a time, so if you trim the list, expect the matching task to fail with a permission error and add the privilege back when it does.

PrivilegeUsed for
VM.Audit, Sys.Audit, Datastore.AuditSeeing VMs, node CPU and memory, and storage
VM.Clone, VM.Allocate, Datastore.AllocateSpaceCloning the template and removing VMs
VM.Config.CPU, VM.Config.Memory, VM.Config.Cloudinit, VM.Config.Network, VM.Config.Disk, VM.Config.OptionsSetting cores, RAM, the cloud-init user, SSH key and IP
SDN.UseAttaching the clone’s NIC to a bridge such as vmbr0
VM.PowerMgmtStart, stop and force-stop
VM.Snapshot, VM.Snapshot.RollbackCreating, rolling back and deleting snapshots

Next, create the token with privilege separation on. Proxmox prints the secret once and never shows it again, so keep this terminal open until the secret is in your vault file, which you will create a few steps from now:

pveum user token add ansible@pve automation --privsep 1

The value row holds the secret. We replaced ours with zeros below, and you should treat yours like a password because anyone holding it gets the token’s full role.

┌──────────────┬──────────────────────────────────────┐
│ key          │ value                                │
╞══════════════╪══════════════════════════════════════╡
│ full-tokenid │ ansible@pve!automation               │
├──────────────┼──────────────────────────────────────┤
│ info         │ {"privsep":"1"}                      │
├──────────────┼──────────────────────────────────────┤
│ value        │ 00000000-0000-0000-0000-000000000000 │
└──────────────┴──────────────────────────────────────┘

A privilege-separated token gets the intersection of its own ACLs and its user’s, and it starts with no ACLs of its own. If you skip the next command, the token still authenticates, but it sees an empty cluster and every clone fails with an error that blames the template instead of the token. Grant the token the same role on the same path, then list the ACLs:

pveum acl modify / --tokens 'ansible@pve!automation' --roles AnsibleProv
pveum acl list

You want two rows back, one for the user and one for the token. A single user row means the token still has nothing of its own.

┌──────┬─────────────┬───────┬────────────────────────┬───────────┐
│ path │ roleid      │ type  │ ugid                   │ propagate │
╞══════╪═════════════╪═══════╪════════════════════════╪═══════════╡
│ /    │ AnsibleProv │ user  │ ansible@pve            │ 1         │
├──────┼─────────────┼───────┼────────────────────────┼───────────┤
│ /    │ AnsibleProv │ token │ ansible@pve!automation │ 1         │
└──────┴─────────────┴───────┴────────────────────────┴───────────┘

We granted the role on / to keep the lab short. On a shared cluster, scope it down so the token cannot touch anybody else’s guests. The Proxmox user management docs describe the paths involved, and a pool-scoped setup needs all of these (we did not test it):

  • The role on /pool/<name>, with pool: <name> set on the clone task
  • The role, or at least VM.Audit plus VM.Clone, on /vms/9000, or the template inside the pool
  • Datastore privileges on the target /storage/<id>
  • Sys.Audit on /nodes/<node>
  • SDN.Use on /sdn/zones/localnetwork/vmbr0

Miss one and the matching task fails, or worse, a listing quietly comes back empty, as the troubleshooting section shows.

Install Ansible, community.proxmox and proxmoxer

Install everything into a Python virtual environment rather than from Debian’s packages. Debian 13 ships a proxmoxer release that the current collection rejects outright, so the apt route fails on the very first task. Each source gave us these versions in October 2026:

PackageDebian 13 aptPyPI / Galaxy (what we ran)
ansible-core2.19.112.21.5
proxmoxer2.2.0 (python3-proxmoxer)2.3.0
requests2.32.32.34.2
community.proxmoxnot packaged on its own2.1.0

The collection’s metadata asks for ansible-core 2.17 or newer, and its shared module code stops with an error on any proxmoxer below 2.3. A venv also keeps your Ansible version independent of the OS release, which matters once you have playbooks you do not want to rewrite every two years. We installed the collection unpinned, which gave 2.1.0. The upcoming 3.0.0 announces module renames, so for long-lived projects pin the major version with ansible-galaxy collection install 'community.proxmox:>=2.1.0,<3.0.0' instead (we did not run the pinned form). Create the venv and install the three packages plus the collection:

sudo apt install -y python3-venv
python3 -m venv ~/ansible-venv
source ~/ansible-venv/bin/activate
pip install ansible-core proxmoxer requests
ansible-galaxy collection install community.proxmox

Check the versions that matter before you write a single playbook. A stale collection or library shows up here in seconds, while in a playbook it surfaces as a confusing module error.

ansible --version | head -1
ansible-galaxy collection list community.proxmox
pip list | grep -iE '^(proxmoxer|requests) '

Our control node reported these. If you see proxmoxer below 2.3 or a 1.x collection, an older copy is being picked up from somewhere else on your path, usually /usr/share/ansible/collections or a system package:

ansible [core 2.21.5]

# /home/josphat/.ansible/collections/ansible_collections
Collection        Version
----------------- -------
community.proxmox 2.1.0
proxmoxer          2.3.0
requests           2.34.2

A venv only applies to the shell you activated it in. Every later command assumes you have run source ~/ansible-venv/bin/activate in your current session, so run it again after you reconnect.

Trust the Proxmox CA instead of disabling TLS checks

Since community.proxmox 2.0, validate_certs defaults to true. A default Proxmox install signs its API certificate with a cluster CA your control node has never seen, so the first task fails with CERTIFICATE_VERIFY_FAILED unless you hand the modules that CA through ca_path. Create the project directory and copy the CA from any node:

mkdir -p ~/pve-ansible/group_vars/all
cd ~/pve-ansible
scp [email protected]:/etc/pve/pve-root-ca.pem .
openssl x509 -in pve-root-ca.pem -noout -subject -enddate

The subject should name the PVE Cluster Manager CA, valid for ten years from the install date. Every node in a cluster shares this one CA, so you only copy it once.

subject=CN=Proxmox Virtual Environment, OU=dc252e20-ed19-4d74-b609-163c81a1d268, O=PVE Cluster Manager CA
notAfter=Oct 20 10:11:12 2036 GMT

Point api_host at a name or address that’s in the node certificate. Proxmox puts the node’s IP and short hostname in the certificate’s subject alternative names. So 10.0.1.50 or the node name will validate, while a DNS alias you add later will not. If you replaced the API certificate with a Let’s Encrypt one through Proxmox’s ACME support, you can drop ca_path. Inside a venv, requests checks certificates against the certifi bundle rather than the system trust store, and Let’s Encrypt’s ISRG Root X1 is in that bundle. We did not test that variant. You can also set validate_certs: false, but keep that for a throwaway lab, because the token secret then goes to whatever answers on port 8006.

Set up the project, inventory and vault

Every Proxmox task runs on the control node, so the inventory holds only localhost and the real settings live in group variables. When you finish this section, ~/pve-ansible holds five files:

  • ansible.cfg, the inventory and vault password paths
  • inventory.ini, the local connection
  • group_vars/all/proxmox.yml, host, node, template and storage
  • group_vars/all/vault.yml, the encrypted token secret
  • pve-root-ca.pem, the CA you just copied

Start with the Ansible configuration file. Ansible reads ansible.cfg from the directory you run it in, so always run the playbooks from ~/pve-ansible.

vim ansible.cfg

Pointing Ansible at a vault password file means playbooks run without a prompt, which is what you want once they run from cron or CI:

[defaults]
inventory = inventory.ini
vault_password_file = ~/.vault_pass

Now create the inventory. It holds a single host because the API calls never leave the control node. The guests get added at run time instead, as you’ll see in the provisioning playbook.

vim inventory.ini

The ansible_python_interpreter setting matters more than it looks. An explicit localhost entry makes Ansible discover the system Python, which has no proxmoxer, whereas ansible_playbook_python is the venv interpreter that does:

[proxmox]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"

Open the variables file for the non-secret settings. Keeping these out of the vault means you can review and diff them without decrypting anything.

vim group_vars/all/proxmox.yml

Change the host, node name, template ID and storage to match your cluster. Our Debian-based node only has the local directory storage, whereas a node installed from the ISO usually keeps VM disks on local-lvm:

proxmox_api_host: "10.0.1.50"
proxmox_api_token_id: "ansible@pve!automation"
proxmox_api_token_secret: "{{ vault_proxmox_token_secret }}"
proxmox_ca_path: "{{ playbook_dir }}/pve-root-ca.pem"
proxmox_node: "pve-node01"
proxmox_template_id: 9000
proxmox_storage: "local"

The token ID uses the full user@realm!tokenid form, which every module in the collection accepts on its own since 2.1.0, so you don’t need a separate api_user. Generate a random vault password next and make it readable only by you:

openssl rand -base64 32 > ~/.vault_pass
chmod 600 ~/.vault_pass

Then open the vault file, which holds only the token secret for now. Anything else sensitive you add later, such as a guest root password, belongs in the same file.

vim group_vars/all/vault.yml

Paste the secret from the token output in place of the zeros, then save the file. The variable name matches what proxmox.yml references, so the two files connect without any extra setup.

vault_proxmox_token_secret: "00000000-0000-0000-0000-000000000000"

Encrypt it in place and look at the first line. From now on the secret only exists in plain text in memory while a playbook runs:

ansible-vault encrypt group_vars/all/vault.yml
head -1 group_vars/all/vault.yml

A healthy result prints the vault header and nothing you can read. If you see the plain variable instead, the encrypt step did not run, so fix that before the file goes anywhere near Git.

$ANSIBLE_VAULT;1.1;AES256

If more than one person runs these playbooks, the Ansible Vault tutorial covers vault IDs and rekeying, which beat sharing one password file around the team.

List nodes and VMs with the ansible proxmox modules

Start with a read-only playbook, because one run proves the token, the CA and the venv together without changing anything. Every playbook in this guide sets the connection details once through module_defaults and the collection’s proxmox action group, so you never repeat four lines of credentials on each task. Create the first one:

vim pve_info.yml

It reads node status with proxmox_node_info and every QEMU VM on the node with proxmox_vm_info, then prints one line per item. The loop_control labels keep the output to one short line per node and VM instead of a full JSON dump:

---
- name: Read nodes and VMs from the Proxmox API
  hosts: proxmox
  gather_facts: false
  module_defaults:
    group/community.proxmox.proxmox:
      api_host: "{{ proxmox_api_host }}"
      api_token_id: "{{ proxmox_api_token_id }}"
      api_token_secret: "{{ proxmox_api_token_secret }}"
      ca_path: "{{ proxmox_ca_path }}"
  tasks:
    - name: Get node status
      community.proxmox.proxmox_node_info:
      register: nodes

    - name: Show nodes
      ansible.builtin.debug:
        msg: "{{ item.node }} {{ item.status }} cpu={{ item.maxcpu }} mem={{ (item.maxmem / 1073741824) | round(1) }}GiB"
      loop: "{{ nodes.proxmox_nodes }}"
      loop_control:
        label: "{{ item.node }}"

    - name: Get every VM on the node
      community.proxmox.proxmox_vm_info:
        node: "{{ proxmox_node }}"
        type: qemu
      register: vms

    - name: Show VMs
      ansible.builtin.debug:
        msg: "{{ item.vmid }} {{ item.name }} {{ item.status }} template={{ item.template | default(0) }}"
      loop: "{{ vms.proxmox_vms }}"
      loop_control:
        label: "{{ item.vmid }}"

Run the playbook. Because it only reads, you can run it as often as you like while you sort out the token or the CA, and it makes a good first stage in any pipeline that touches the cluster.

ansible-playbook pve_info.yml

You should see the node with its CPU count and memory, and the template you are about to clone. The CPU and memory fields only appear when the token holds Sys.Audit, so their presence is itself a permission check:

PLAY [Read nodes and VMs from the Proxmox API] *********************************

TASK [Get node status] *********************************************************
ok: [localhost]

TASK [Show nodes] **************************************************************
ok: [localhost] => (item=pve-node01) => {
    "msg": "pve-node01 online cpu=4 mem=7.8GiB"
}

TASK [Get every VM on the node] ************************************************
ok: [localhost]

TASK [Show VMs] ****************************************************************
ok: [localhost] => (item=9000) => {
    "msg": "9000 debian13-cloud stopped template=True"
}

PLAY RECAP *********************************************************************
localhost                  : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The template only shows up if you already have one; on a fresh node the VM list stays empty until you build the template in the next section. Don’t read an empty VM list as proof of an empty node, though. Before we added the token’s own ACL row, this same playbook authenticated without complaint and returned zero VMs. The node record came back with no maxcpu or maxmem fields at all, so the debug task failed on a missing attribute instead of a permission error. If the node has VMs and you still see either symptom, check pveum acl list for the token row before anything else.

Build the cloud-init template

Cloning needs a template with a cloud-init drive, because that drive is how Proxmox injects the user, SSH key and IP address on first boot. If you already have one, put its VMID in proxmox_template_id and skip ahead. Otherwise, run these as root on the node to turn Debian’s generic cloud image into template 9000 (our cloud image template guide covers Ubuntu, Rocky and other images the same way):

cd /tmp
wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
qm create 9000 --name debian13-cloud --memory 2048 --cores 2 --cpu host --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci --ostype l26 --agent enabled=1 --serial0 socket --vga serial0
qm set 9000 --scsi0 local:0,import-from=/tmp/debian-13-genericcloud-amd64.qcow2,format=qcow2
qm set 9000 --ide2 local:cloudinit --boot order=scsi0
qm disk resize 9000 scsi0 10G
qm template 9000

On an ISO-installed node, swap local for local-lvm in both qm set lines and drop format=qcow2, because LVM-thin stores raw volumes. The serial console lines follow Proxmox’s own cloud-init documentation, which recommends a serial display since many cloud images expect one.

Clone a VM from the template

The proxmox_kvm module starts a clone when you set both clone and a source vmid. With vmid present, the clone value is only a trigger and could be any string, but we keep the real template name there so the playbook still reads correctly six months from now. Create the playbook:

vim clone_vm.yml

It clones template 9000 into VMID 120, named web01. Change vm_id and vm_name per VM, or pass them with -e on the command line.

---
- name: Clone a VM from the cloud-init template
  hosts: proxmox
  gather_facts: false
  vars:
    vm_id: 120
    vm_name: web01
  module_defaults:
    group/community.proxmox.proxmox:
      api_host: "{{ proxmox_api_host }}"
      api_token_id: "{{ proxmox_api_token_id }}"
      api_token_secret: "{{ proxmox_api_token_secret }}"
      ca_path: "{{ proxmox_ca_path }}"
  tasks:
    - name: Full clone of the template
      community.proxmox.proxmox_kvm:
        node: "{{ proxmox_node }}"
        vmid: "{{ proxmox_template_id }}"
        clone: debian13-cloud
        newid: "{{ vm_id }}"
        name: "{{ vm_name }}"
        full: true
        storage: "{{ proxmox_storage }}"
        format: qcow2
        timeout: 300
      register: clone

    - name: Show the clone result
      ansible.builtin.debug:
        var: clone.msg

With full: true the disk is copied, so the VM survives if the template is ever deleted. Set format to match the target storage: qcow2 on directory storage, raw on LVM-thin or ZFS. Linked clones (full: false with format: unspecified) are faster and smaller but tie every VM to the template’s base disk, so we only use them for CI guests that live for an hour. Run the clone under time:

time ansible-playbook clone_vm.yml

A full clone of our 10 GB template, of which about 950 MB is real data according to du on our first lab build, finished in under three seconds on the lab node:

PLAY [Clone a VM from the cloud-init template] *********************************

TASK [Full clone of the template] **********************************************
changed: [localhost]

TASK [Show the clone result] ***************************************************
ok: [localhost] => {
    "clone.msg": "VM web01 with newid 120 cloned from vm with vmid 9000"
}

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0


real	0m2.736s
user	0m0.638s
sys	0m0.084s

Running the same playbook a second time is safe. The module sees that VMID 120 already exists and reports ok rather than failing or cloning over it:

TASK [Full clone of the template] **********************************************
ok: [localhost]

TASK [Show the clone result] ***************************************************
ok: [localhost] => {
    "clone.msg": "vmid 120 with VM name web01 already exists"
}

That is the behavior you want in a pipeline that reruns after a partial failure. It also means a new vm_name on an existing ID is silently ignored, so pick IDs deliberately, ideally from a range you reserve for automation.

Configure cloud-init, start the VM and wait for SSH

A fresh clone still carries the template’s empty cloud-init settings, so set the user, SSH key and address before its first boot. This playbook has two plays. The first talks to the API, and the second connects to the new guest over SSH and installs packages. It reads your public key from ~/.ssh/id_ed25519.pub, so run ssh-keygen -t ed25519 first if you don’t have one. Create the playbook:

vim configure_start.yml

Adjust vm_ip, vm_gateway and vm_user for your network before you run it. The IP must be free on the bridge the template’s NIC uses, because cloud-init sets it statically and nothing checks for a clash:

---
- name: Configure cloud-init, boot the VM and wait for SSH
  hosts: proxmox
  gather_facts: false
  vars:
    vm_id: 120
    vm_ip: 10.10.10.21
    vm_gateway: 10.10.10.1
    vm_user: josphat
  module_defaults:
    group/community.proxmox.proxmox:
      api_host: "{{ proxmox_api_host }}"
      api_token_id: "{{ proxmox_api_token_id }}"
      api_token_secret: "{{ proxmox_api_token_secret }}"
      ca_path: "{{ proxmox_ca_path }}"
  tasks:
    - name: Set CPU, memory and cloud-init on the clone
      community.proxmox.proxmox_kvm:
        node: "{{ proxmox_node }}"
        vmid: "{{ vm_id }}"
        cores: 2
        memory: 2048
        ciuser: "{{ vm_user }}"
        sshkeys: "{{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}"
        ipconfig:
          ipconfig0: "ip={{ vm_ip }}/24,gw={{ vm_gateway }}"
        nameservers:
          - 1.1.1.1
        update: true

    - name: Start the VM
      community.proxmox.proxmox_kvm:
        vmid: "{{ vm_id }}"
        state: started

    - name: Wait for SSH on the new VM
      ansible.builtin.wait_for:
        host: "{{ vm_ip }}"
        port: 22
        delay: 5
        timeout: 300
      register: ssh_up

    - name: Report boot time
      ansible.builtin.debug:
        msg: "SSH answered after {{ ssh_up.elapsed }} seconds"

    - name: Add the VM to the in-memory inventory
      ansible.builtin.add_host:
        name: "{{ vm_ip }}"
        groups: new_vms
        ansible_user: "{{ vm_user }}"
        ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"

- name: Configure the new guest over SSH
  hosts: new_vms
  gather_facts: true
  become: true
  tasks:
    - name: Install base packages
      ansible.builtin.apt:
        name:
          - qemu-guest-agent
          - htop
        update_cache: true
        state: present

    - name: Start the guest agent
      ansible.builtin.service:
        name: qemu-guest-agent
        state: started

    - name: Show what booted
      ansible.builtin.debug:
        msg: "{{ ansible_facts.hostname }} {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }} kernel {{ ansible_facts.kernel }}"

Three details in that playbook trip people up. Each one fails differently when you get it wrong, and only one of them produces a clear error:

  • update: true is required on an existing VM, otherwise the module leaves its config alone
  • The IP goes under the ipconfig dictionary, keyed ipconfig0 for net0, never as a flat option
  • wait_for polls port 22, since Debian’s cloud image has no guest agent yet

Playbooks, including some older guides, that pass a flat ipconfig0: option fail with an unsupported parameter error, covered in the troubleshooting section below. Run the playbook under time. Unlike the info playbook, this one changes things: it writes the cloud-init settings, boots the VM, waits for SSH and then installs packages inside the guest, so expect it to take the better part of a minute.

time ansible-playbook configure_start.yml

SSH answered 13 seconds after the start task, and the whole run took 39 seconds, most of it the apt update and two package installs inside the guest. On our first lab build the same playbook reported 12 seconds to SSH and 63 seconds in total, most likely down to apt mirror speed:

PLAY [Configure cloud-init, boot the VM and wait for SSH] **********************

TASK [Set CPU, memory and cloud-init on the clone] *****************************
changed: [localhost]

TASK [Start the VM] ************************************************************
changed: [localhost]

TASK [Wait for SSH on the new VM] **********************************************
ok: [localhost]

TASK [Report boot time] ********************************************************
ok: [localhost] => {
    "msg": "SSH answered after 13 seconds"
}

TASK [Add the VM to the in-memory inventory] ***********************************
changed: [localhost]

PLAY [Configure the new guest over SSH] ****************************************

TASK [Gathering Facts] *********************************************************
[WARNING]: Host '10.10.10.21' is using the discovered Python interpreter at '/usr/bin/python3.13', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.21/reference_appendices/interpreter_discovery.html for more information.
ok: [10.10.10.21]

TASK [Install base packages] ***************************************************
changed: [10.10.10.21]

TASK [Start the guest agent] ***************************************************
changed: [10.10.10.21]

TASK [Show what booted] ********************************************************
ok: [10.10.10.21] => {
    "msg": "web01 Debian 13.7 kernel 6.12.113+deb13-cloud-amd64"
}

PLAY RECAP *********************************************************************
10.10.10.21                : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
localhost                  : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0


real	0m38.609s
user	0m1.912s
sys	0m0.323s

Cloud-init named the guest web01 after the VM, because no separate hostname was set. You can ignore the interpreter warning, or silence it by adding ansible_python_interpreter: /usr/bin/python3 to the add_host task. From here the guest is an ordinary SSH host, so put the rest of its setup in reusable roles rather than growing this provisioning playbook.

For the screenshot below we gave ansible@pve a temporary password with pvesh set /access/password (pveum passwd ansible@pve does the same interactively) and logged in to the web UI as that user. The view shows exactly what the role allows: the node, the template, the new VM and the storage. Keep the automation user token-only in production, because a password adds a second way in that nobody rotates. The task log at the bottom lists the clone, start and snapshot under ansible@pve, and the IPs field asks for VM.GuestAgent.Audit, which our role leaves out on purpose. Add that privilege if you want the token to read guest IPs through the agent:

Proxmox VE summary of a VM cloned by Ansible with the ansible@pve task log

Every line in that task log maps to one Ansible task, which makes the log a handy audit trail when a playbook misbehaves on a shared cluster.

Snapshot and roll back a VM

Take a snapshot before any change you might want to undo, such as a package upgrade or a config rewrite. proxmox_snap accepts three states (present, absent and rollback), so one playbook with a snap_state variable covers the whole cycle. Create it:

vim snapshot.yml

The second task starts the VM again after a rollback, because rolling back to a snapshot taken without RAM state leaves the VM powered off. Without it, your pipeline would carry on against a VM that is not running:

---
- name: Create, roll back or delete a VM snapshot
  hosts: proxmox
  gather_facts: false
  vars:
    vm_id: 120
    snap_name: pre-change
    snap_state: present
  module_defaults:
    group/community.proxmox.proxmox:
      api_host: "{{ proxmox_api_host }}"
      api_token_id: "{{ proxmox_api_token_id }}"
      api_token_secret: "{{ proxmox_api_token_secret }}"
      ca_path: "{{ proxmox_ca_path }}"
  tasks:
    - name: Apply the snapshot state
      community.proxmox.proxmox_snap:
        vmid: "{{ vm_id }}"
        snapname: "{{ snap_name }}"
        description: "Taken by Ansible before a change"
        vmstate: false
        state: "{{ snap_state }}"
        timeout: 120

    - name: Start the VM again after a disk-only rollback
      community.proxmox.proxmox_kvm:
        vmid: "{{ vm_id }}"
        state: started
      when: snap_state == "rollback"

We keep vmstate: false because a RAM snapshot also writes the guest’s whole memory to storage, and what you usually want back after a bad change is the disk, not the running processes. Create the snapshot with the default state:

ansible-playbook snapshot.yml

On a create, the rollback-only start task shows as skipped, and the snapshot itself takes a couple of seconds on qcow2 storage.

PLAY [Create, roll back or delete a VM snapshot] *******************************

TASK [Apply the snapshot state] ************************************************
changed: [localhost]

TASK [Start the VM again after a disk-only rollback] ***************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

The Snapshots tab of the VM shows the new entry with its description, taken by the token rather than by a person, and the RAM column reads No because of vmstate: false:

Proxmox VE snapshots tab showing the pre-change snapshot taken by Ansible

To prove a rollback really undoes something, make a change the snapshot does not contain. We installed nginx on the guest with an ad-hoc command, then queried the package database to confirm it landed:

ansible all -i 10.10.10.21, -u josphat -b -m ansible.builtin.apt -a "name=nginx state=present"
ansible all -i 10.10.10.21, -u josphat -m ansible.builtin.command -a "dpkg-query -W nginx"

The trailing comma after the IP tells Ansible it is an inline host list rather than an inventory file. The second command reports the installed version:

10.10.10.21 | CHANGED | rc=0 >>
nginx	1.26.3-3+deb13u10

Now roll back by passing the state on the command line. The disk goes back to the snapshot, the VM comes back powered off, and the second task boots it again:

time ansible-playbook snapshot.yml -e snap_state=rollback

Both tasks report changed, which confirms the VM really was stopped by the rollback, and the whole thing took about four seconds:

PLAY [Create, roll back or delete a VM snapshot] *******************************

TASK [Apply the snapshot state] ************************************************
changed: [localhost]

TASK [Start the VM again after a disk-only rollback] ***************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   


real	0m4.142s
user	0m0.948s
sys	0m0.125s

Give the guest a few seconds to boot, then check for nginx again. The first command waits until SSH answers, and the second one should now fail:

ansible all -i 10.10.10.21, -u josphat -m ansible.builtin.wait_for_connection -a timeout=120
ansible all -i 10.10.10.21, -u josphat -m ansible.builtin.command -a "dpkg-query -W nginx"

A failure is the result you want here, because it means the package is gone and the disk really went back in time:

10.10.10.21 | FAILED | rc=1 >>
dpkg-query: no packages found matching nginxThe command exited with a non-zero return code.

Snapshots on qcow2 and LVM-thin grow as the VM writes, so delete one once you no longer need the way back. On the node, qm listsnapshot 120 confirms it is gone:

ansible-playbook snapshot.yml -e snap_state=absent

After the delete, only the current state is left in the snapshot tree.

`-> current                                             You are here!

If you snapshot on a schedule instead of before each change, the module’s retention option removes the oldest snapshots once their count passes the number you set. We did not test retention in the lab.

Destroy the VM

Removing an ansible proxmox VM has one trap that is easy to miss. We ran state: absent without force against VM 120 while it was still running, and the module did nothing at all while still reporting success:

localhost | SUCCESS => {
    "changed": false,
    "msg": "VM 120 is running. Stop it before deletion or use force=true.",
    "vmid": 120
}

A cleanup job built on that task would leave every running VM behind with a green run. According to the proxmox_kvm source in 2.1.0, adding force: true to the absent task makes the module stop the VM and then delete it in one task; we did not test that form. What we ran is the explicit two-task version, which stops the VM first and then removes it. Create the playbook.

vim destroy_vm.yml

With force: true on the stop task, the module asks for a clean shutdown and powers the VM off if it is still running after the 60 second timeout. A guest that ignores ACPI therefore cannot block the teardown:

---
- name: Stop and remove a VM
  hosts: proxmox
  gather_facts: false
  vars:
    vm_id: 120
  module_defaults:
    group/community.proxmox.proxmox:
      api_host: "{{ proxmox_api_host }}"
      api_token_id: "{{ proxmox_api_token_id }}"
      api_token_secret: "{{ proxmox_api_token_secret }}"
      ca_path: "{{ proxmox_ca_path }}"
  tasks:
    - name: Stop the VM, forcing it off after the timeout
      community.proxmox.proxmox_kvm:
        vmid: "{{ vm_id }}"
        state: stopped
        force: true
        timeout: 60

    - name: Remove the VM and its disks
      community.proxmox.proxmox_kvm:
        vmid: "{{ vm_id }}"
        state: absent

Run it under time, so you can see how little of a teardown is the API itself. Double-check vm_id before you run it, because nothing asks for confirmation.

time ansible-playbook destroy_vm.yml

Both tasks changed, and the VM was gone six seconds later. qm list on the node afterwards showed only template 9000, and the VM’s disk directory under /var/lib/vz/images was removed with it:

PLAY [Stop and remove a VM] ****************************************************

TASK [Stop the VM, forcing it off after the timeout] ***************************
changed: [localhost]

TASK [Remove the VM and its disks] *********************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   


real	0m6.037s
user	0m0.942s
sys	0m0.104s

Across the whole cycle, the API calls themselves were quick on our node: about 3 seconds to clone, 4 to roll back and 6 to destroy. Booting to SSH and installing packages in the guest is where the time goes.

Point the playbooks at a multi-node cluster

We ran everything above on a single node, so this section comes from the module documentation and was not tested in our lab. On a cluster, api_host can be any member, because every node serves the whole cluster’s API. The node option only matters when you create or clone, where it decides which host the new VM lands on. For start, stop, snapshot and delete, the module finds the VM’s node by itself, which is why the later tasks above leave node out.

Cloning a template that sits on local storage only works on the template’s own node. Keep templates on shared storage such as Ceph or NFS if you want to clone straight onto any member, or clone locally and move the VM afterwards with migrate: true and the target node on proxmox_kvm. Some older tutorials show a migrate_node option, which the module does not have. One pve-root-ca.pem covers every member, since the whole cluster shares that CA. If you also want the hosts and networks under code, our Terraform and Ansible walkthrough shows how the two tools split that work.

Troubleshooting

We reproduced every error below in the lab except the 401, which is documented from a failed run whose log we did not keep. Most of the error captures come from our first lab build. In most of them the message points somewhere other than the real cause, which is why each one gets the fix and the reason.

Error: “VM with vmid 9000 does not exist in cluster”

The template exists, but your token can’t see it. A token created with --privsep 1 starts with no ACLs, and Proxmox filters every listing down to what the caller may audit, so the module finds nothing and calls the template missing. The same token returned an empty proxmox_vm_info list without any error. Grant the role to the token itself with pveum acl modify / --tokens 'ansible@pve!automation' --roles AnsibleProv and run the playbook again.

Error: “401 Unauthorized: Authentication failed!”

The token ID or the secret is wrong. In our first lab build a scripted copy grabbed the word value from the table header together with the UUID, which produced a 42-character secret instead of 36 and a 401 on the first task. Check what is actually in your vault with ansible localhost -m debug -a "msg={{ vault_proxmox_token_secret | length }}", because anything other than 36 means the paste went wrong.

Error: “certificate verify failed: unable to get local issuer certificate”

The module is validating the API certificate, the 2.x default, without the cluster CA. This is the same call made without ca_path.

localhost | FAILED! => {
    "changed": false,
    "msg": "HTTPSConnectionPool(host='10.0.1.50', port=8006): Max retries exceeded with url: /api2/json/version (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1029)')))"
}

Copy /etc/pve/pve-root-ca.pem to the control node and set ca_path as shown earlier. If the error stays with the CA in place, api_host is probably a name that isn’t in the node certificate, so switch to the node’s IP or short hostname.

Error: “Failed to import the required Python library (proxmoxer)”

proxmoxer isn’t installed in the Python that runs the module. The message names that interpreter, which is the fastest way to spot the mismatch:

fatal: [localhost]: FAILED! => {"changed": false, "msg": "Failed to import the required Python library (proxmoxer) on pve-node01's Python /home/josphat/ansible-venv/bin/python3. Please read the module documentation and install it in the appropriate location. If the required library is installed, but Ansible is using the wrong Python interpreter, please consult the documentation on ansible_python_interpreter"}

Run pip install proxmoxer requests with the venv active. If the path in your message is /usr/bin/python3 rather than the venv, the inventory is missing the ansible_python_interpreter setting for localhost.

Error: “Requires proxmoxer 2.3 or newer; found version 2.2.0”

Version 2.2.0 is what Debian 13’s python3-proxmoxer package installs, and we reproduced the error by pinning that version in the venv. Upgrade inside the venv with pip install --upgrade proxmoxer. Leave the system package alone, since other Debian packages may depend on it.

Error: “Unsupported parameters for (community.proxmox.proxmox_kvm) module: ipconfig0”

The playbook passes ipconfig0 as a flat option. proxmox_kvm takes cloud-init IP settings as an ipconfig dictionary, so playbooks (including older guides) that use the flat form fail. Move the value under the dictionary, one key per NIC.

        ipconfig:
          ipconfig0: "ip=10.10.10.21/24,gw=10.10.10.1"

The full error message lists every supported parameter, which is a quick way to check other renamed options without opening the documentation. In our run against community.proxmox 2.1.0 that list ran to 104 names, which is why we don’t paste it here.

Error: “value of state must be one of: present, absent, rollback, got: list”

Older tutorials show proxmox_snap with state: list, which the module in community.proxmox 2.1.0 does not accept. Its only states are present, absent and rollback, so check existing snapshots with qm listsnapshot on the node or in the VM’s Snapshots tab instead.

Warning: “community.general.proxmox_vm_info has been deprecated”

Playbooks that still call the modules under community.general keep working for now, because that collection redirects them to community.proxmox. We ran one through the full ansible package from PyPI, which bundles community.general, and got this warning:

[DEPRECATION WARNING]: community.general.proxmox_vm_info has been deprecated. The proxmox content has been moved to community.proxmox. This feature will be removed from collection 'community.general' version 15.0.0.

Replace the community.general. prefix with community.proxmox. and install that collection. Check the options while you are at it, because current 2.x releases come with the certificate and proxmoxer changes covered above.

Quick reference: community.proxmox modules

The collection ships 63 modules in 2.1.0. We checked the ones a VM lifecycle playbook reaches for against ansible-doc on the installed version, and the full list lives in the community.proxmox documentation.

ModuleUse it forKey options
proxmox_kvmCreate, clone, configure, start, stop, migrate, template and remove QEMU VMsclone, newid, full, ipconfig, update, state
proxmox_vm_infoList VMs and containers, optionally with their confignode, type, vmid, name, config
proxmox_node_infoNode status, CPU, memory and versiononly the connection options
proxmox_snapCreate, roll back and delete snapshotssnapname, state, vmstate, retention
proxmoxLXC containersostemplate, hostname, state
proxmox_templateUpload or download ISOs, container templates and import imagescontent_type, src, url, storage
proxmox_diskAdd, resize, move or detach VM disksdisk, size, storage, state
proxmox_nicAdd or change VM network interfacesinterface, bridge, tag
proxmox_poolResource pools, useful for scoping the token’s ACLpoolid, comment

Every module in the table takes the same connection options, so the module_defaults block from the first playbook works unchanged for all of them. That block, the CA file and a token scoped to a pool are the three pieces worth carrying into any new Proxmox project, and the playbooks on top of them are the easy part.

Keep reading

OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Install KVM and Virt-Manager on Arch Linux Virtualization Install KVM and Virt-Manager on Arch Linux Install KVM on Debian 13 / Debian 12: Complete Guide KVM Install KVM on Debian 13 / Debian 12: Complete Guide Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Automation Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Automation Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Essential KVM CLI Tools for Administrators – virt-edit, virt-df, virt-clone KVM Essential KVM CLI Tools for Administrators – virt-edit, virt-df, virt-clone

Leave a Comment

Press ESC to close