Terraform can decide that a VM should exist, with the right CPU, memory, disks, and network interface. It has no opinion about what happens after the VM boots — which packages get installed, which config files get written, which services get enabled. That's a different job, and on a Proxmox VE host running a dozen or a hundred guests, doing it by hand through SSH sessions one machine at a time stops scaling almost immediately.
Ansible fills that gap. It can also provision the VMs and LXC containers themselves through the Proxmox API, which means a single playbook can take you from "nothing exists" to "a fully configured, software-installed guest" without switching tools. This guide covers both halves: creating VMs and containers against the Proxmox VE API using the current, actively maintained Ansible collection, and then using Ansible's dynamic inventory to reach into those guests afterward and configure them.
One detail trips up almost everyone who searches for this: most existing tutorials, and a lot of AI-generated advice, still reference community.general.proxmox_kvm. That module is deprecated. Proxmox support was split out of community.general into its own community.proxmox collection, and the old module now just forwards to the new one with a deprecation warning. This guide uses the current collection throughout.
What You Will Learn
- Why
community.general.proxmox_kvmis deprecated and what replaced it - Creating a scoped API token and role dedicated to Ansible, instead of reusing
root@pam - Installing the
community.proxmoxcollection and its Python dependencies - Storing API credentials safely with Ansible Vault
- Setting up the Proxmox dynamic inventory plugin so Ansible discovers your guests automatically
- Cloning a cloud-init VM template into a running, configured virtual machine
- Provisioning an LXC container declaratively, with resource limits and features set correctly
- Making playbook runs idempotent, so re-running them updates rather than duplicates
- Using inventory groups to configure software across many guests in one run
- Diagnosing the errors that come up most often: token auth failures, TLS verification, clone timeouts, and permission errors
Prerequisites
- A Proxmox VE 8.x or 9.x host or cluster with API access reachable from your Ansible control node
- Ansible-core 2.15 or newer on the control node (check with
ansible --version) - Python 3 on the control node, with the
proxmoxerandrequestspackages installed — the Proxmox modules useproxmoxerto talk to the API, while the dynamic inventory plugin talks to the REST API directly overrequests - A cloud-init-enabled VM template already built on the Proxmox host, if you intend to clone VMs rather than only manage containers
- Enough familiarity with Ansible playbooks, inventory, and Vault to follow along — this guide doesn't start from "what is a playbook"
Install the Python dependencies and the collection on the control node:
pip install --upgrade proxmoxer requests
ansible-galaxy collection install community.proxmox
If you have an older setup still pulling in community.general, it's worth running ansible-galaxy collection install community.general --upgrade too, since recent releases carry the forwarding shim that redirects proxmox_kvm calls to the new collection with a warning rather than failing outright. New playbooks should just target community.proxmox directly and skip the warning entirely.
Step 1: Create a Scoped API Token for Ansible
Reusing your personal root@pam login in a playbook works, right up until that playbook leaks into a git repository or a CI log. Create a dedicated user, role, and API token instead, scoped to only the privileges Ansible actually needs.
On any cluster node, as root:
# Create a role with the privileges needed to manage VMs and containers
pveum role add AnsibleProvisioner -privs "VM.Allocate VM.Clone VM.Config.CDROM VM.Config.CPU \
VM.Config.Cloudinit VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network \
VM.Config.Options VM.Monitor VM.PowerMgmt VM.Audit Datastore.AllocateSpace Datastore.Audit \
Sys.Audit SDN.Use Pool.Allocate"
# Create a dedicated user
pveum user add ansible@pve
# Assign the role at the datacenter level (or scope it to a specific pool/node)
pveum aclmod / -user ansible@pve -role AnsibleProvisioner
# Create an API token for that user, with its own secret
pveum user token add ansible@pve automation --privsep 0
Save the token secret that command prints — Proxmox shows it exactly once and doesn't store it in retrievable form. --privsep 0 means the token inherits the same permissions as the user directly; if you'd rather scope the token more tightly than the user account itself, leave privilege separation on and assign an ACL to the token specifically.
Step 2: Store Credentials with Ansible Vault
Create an encrypted variables file rather than writing the token secret into a playbook or a plain inventory file:
ansible-vault create group_vars/all/vault.yml
vault_proxmox_api_host: "pve1.example.com"
vault_proxmox_api_user: "ansible@pve"
vault_proxmox_token_id: "automation"
vault_proxmox_token_secret: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Reference those as variables in your playbooks ("{{ vault_proxmox_api_host }}" and so on), and run playbooks with --ask-vault-pass or a configured vault password file. Never commit the unencrypted secret, and rotate the token if it ever ends up in a log or a shared terminal session.
Step 3: Set Up Dynamic Inventory
A static inventory file listing VM names and IPs by hand works for a handful of guests and becomes a maintenance burden fast. The community.proxmox.proxmox inventory plugin queries the API directly and builds inventory from what's actually running on the cluster, grouped by node, pool, tag, or status.
Create inventory/proxmox.yml:
plugin: community.proxmox.proxmox
url: "https://{{ vault_proxmox_api_host }}:8006"
user: "{{ vault_proxmox_api_user }}"
token_id: "{{ vault_proxmox_token_id }}"
token_secret: "{{ vault_proxmox_token_secret }}"
validate_certs: false
want_facts: true
want_proxmox_nodes_ansible_host: false
group_prefix: pve_
groups:
webservers: "'web' in (proxmox_tags | default([]))"
databases: "'db' in (proxmox_tags | default([]))"
compose:
ansible_host: proxmox_agent_interfaces.1.ip-addresses.0 | default(proxmox_net0.split(',')[0].split('=')[1] | default(omit))
The plugin talks to the REST API using the requests library rather than proxmoxer, so it works independently of whichever version of proxmoxer the provisioning modules need. validate_certs: false is fine against Proxmox's default self-signed certificate for a lab; point it at a proper CA bundle for anything you actually care about.
Test it before writing a single task:
ansible-inventory -i inventory/proxmox.yml --list
If that comes back empty or errors out, it's almost always the token, the URL, or TLS — see the troubleshooting section below before assuming the plugin itself is broken.
Step 4: Clone a VM from a Cloud-Init Template
This assumes you already have a cloud-init-enabled template built on the target node — the process for building one (installing cloud-init, converting a configured VM to a template) is its own topic and worth getting right once by hand before automating it. With a template in place, cloning and configuring a new VM from it is a single task:
- name: Provision a VM from a cloud-init template
hosts: localhost
gather_facts: false
vars_files:
- group_vars/all/vault.yml
tasks:
- name: Clone template into a new VM
community.proxmox.proxmox_kvm:
api_host: "{{ vault_proxmox_api_host }}"
api_user: "{{ vault_proxmox_api_user }}"
api_token_id: "{{ vault_proxmox_token_id }}"
api_token_secret: "{{ vault_proxmox_token_secret }}"
node: pve1
clone: ubuntu-2404-cloudinit-template
vmid: 9000
newid: 301
name: web-301
full: true
storage: local-zfs
format: qcow2
timeout: 300
- name: Configure the cloned VM's hardware and cloud-init settings
community.proxmox.proxmox_kvm:
api_host: "{{ vault_proxmox_api_host }}"
api_user: "{{ vault_proxmox_api_user }}"
api_token_id: "{{ vault_proxmox_token_id }}"
api_token_secret: "{{ vault_proxmox_token_secret }}"
node: pve1
vmid: 301
cores: 2
memory: 4096
ciuser: deploy
sshkeys: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
ipconfig:
ipconfig0: "ip=192.168.10.101/24,gw=192.168.10.1"
tags: "web"
update: true
state: present
- name: Start the VM
community.proxmox.proxmox_kvm:
api_host: "{{ vault_proxmox_api_host }}"
api_user: "{{ vault_proxmox_api_user }}"
api_token_id: "{{ vault_proxmox_token_id }}"
api_token_secret: "{{ vault_proxmox_token_secret }}"
node: pve1
vmid: 301
state: started
A few details matter here. full: true performs a full clone rather than a linked clone, so the new VM doesn't stay tied to the template's disk — use a linked clone only for genuinely disposable, short-lived guests, since editing the template afterward would otherwise risk affecting every VM cloned from it. update: true on the second task is what makes re-running the playbook safe: without it, the module tries to treat the same VMID as a fresh create and fails or errors on the second run instead of reconciling the existing VM's config.
Step 5: Provision an LXC Container
Containers use a separate module, community.proxmox.proxmox, with its own set of parameters for templates, resource limits, and features:
- name: Provision an LXC container
hosts: localhost
gather_facts: false
vars_files:
- group_vars/all/vault.yml
tasks:
- name: Create an unprivileged container
community.proxmox.proxmox:
api_host: "{{ vault_proxmox_api_host }}"
api_user: "{{ vault_proxmox_api_user }}"
api_token_id: "{{ vault_proxmox_token_id }}"
api_token_secret: "{{ vault_proxmox_token_secret }}"
node: pve1
vmid: 401
hostname: cache-401
ostemplate: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
storage: local-zfs
cores: 2
memory: 1024
swap: 512
disk: "local-zfs:8"
unprivileged: true
features: "nesting=1"
netif: '{"net0":"name=eth0,bridge=vmbr0,ip=192.168.10.201/24,gw=192.168.10.1"}'
password: "{{ vault_container_root_password }}"
pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
tags: "cache"
state: present
- name: Start the container
community.proxmox.proxmox:
api_host: "{{ vault_proxmox_api_host }}"
api_user: "{{ vault_proxmox_api_user }}"
api_token_id: "{{ vault_proxmox_token_id }}"
api_token_secret: "{{ vault_proxmox_token_secret }}"
node: pve1
vmid: 401
state: started
Leave containers unprivileged unless you have a specific, understood reason not to — an unprivileged container's root user maps to an unprivileged UID on the host, so a container-escape bug doesn't hand an attacker host-level root for free. Only set features: "nesting=1" if the container genuinely needs to run its own containers or systemd services that expect it (Docker-in-LXC being the usual case); it's a real, if narrow, reduction in isolation, and it's easy to bulk-copy into every container definition "just in case" without noticing you've done it.
Step 6: Configure Guests After Provisioning
This is where dynamic inventory earns its keep. Once VMs and containers are running and reachable, target them by the groups the inventory plugin built from their tags, without maintaining a separate list of hostnames anywhere:
- name: Install and configure nginx on every web-tagged guest
hosts: pve_webservers
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
Run it against the dynamic inventory source directly:
ansible-playbook -i inventory/proxmox.yml configure-web.yml --ask-vault-pass
For this to work, Ansible needs SSH reachability into the guest and a working ansible_host, which is exactly what the compose block in the inventory file above is doing — pulling an address either from the QEMU guest agent's reported interfaces or, if the agent isn't installed or hasn't reported yet, falling back to whatever static IP was set in net0 at provisioning time. Containers report their configured IP directly without needing a guest agent.
Idempotency and Re-Running Playbooks
The whole point of Ansible over a one-off shell script is that running the same playbook twice should converge, not duplicate or error. For the Proxmox modules specifically:
- Use a fixed, explicit
vmidrather than letting Proxmox auto-assign one — auto-assignment makes a VM's identity different on every run, which breaks idempotency outright - Set
update: trueon configuration tasks so the module reconciles an existing VM's settings instead of failing on "this VMID already exists" - Keep
state: presentandstate: startedas separate tasks, as shown above, rather than trying to do both in one call — it makes the eventual diff inansible-playbook --checkoutput far easier to read when something changes - Avoid re-cloning on every run by conditioning the clone task on the VM not already existing, using
community.proxmox.proxmox_vm_infoto check first, or simply accepting that the clone task becomes a fast no-op once the target VMID exists
Common Errors and How to Fix Them
401 Unauthorized / "authentication failure"
Almost always a malformed token reference rather than a wrong secret. The api_user must be the full user@realm form (ansible@pve, not just ansible), and it needs to match the realm the user was actually created under. Double check with pveum user list on the Proxmox host if you're not sure which realm a user landed in.
SSL: CERTIFICATE_VERIFY_FAILED
Proxmox VE ships a self-signed certificate by default. validate_certs: false (or the module-level api_host equivalent) is a reasonable shortcut for a lab, but for production, issue a real certificate through Proxmox's built-in ACME support and turn verification back on — disabling TLS verification permanently is a habit that outlives the lab it started in.
Clone tasks time out on larger templates
The default timeout is tight for a template with a large disk on spinning storage. Bump the module's timeout parameter (300 seconds is a reasonable starting point, higher for multi-hundred-gigabyte templates) rather than assuming the clone silently failed.
"permission check failed" on specific operations
The role created in Step 1 covers the common provisioning path, but specific operations — attaching an existing disk, using SDN-managed networks, resizing storage on a pool the token wasn't scoped to — each need their own privilege. Check the exact privilege string in the API error against pveum role list, add it to the role, and re-run rather than widening the token to Administrator out of frustration.
Deprecation warning for community.general.proxmox_kvm
This is a warning, not a failure, on current collection versions — but it won't stay that way forever. Update the module FQCN in your playbooks to community.proxmox.proxmox_kvm (and community.proxmox.proxmox for containers) now rather than waiting for the forwarding shim to be removed in a future release.
Best Practices Before You Go Further
- Give the Ansible API token its own role scoped to what it provisions, separate from any human user's permissions
- Pin collection versions in a
requirements.ymland install withansible-galaxy collection install -r requirements.ymlso a fleet-wide upgrade doesn't happen by accident on a Tuesday - Keep the dynamic inventory plugin's cache enabled (
cache: truewith a shortcache_timeout) once your inventory grows past a handful of guests, to avoid hitting the API on every single playbook run - Tag guests at creation time specifically so the inventory plugin's group logic has something reliable to key off — retrofitting tags onto dozens of existing VMs later is tedious
- Run destructive-looking changes (resizing down, container recreation) through
--check --difffirst, the same discipline you'd apply toterraform plan
Frequently Asked Questions
Should I use Ansible or Terraform to provision Proxmox VMs?
Either can create the VM; they differ in what they're good at afterward. Terraform tracks infrastructure state and is precise about drift — it notices when a VM's disk size or CPU count no longer matches your configuration. Ansible has no built-in state file and instead re-applies its desired configuration idempotently every run, but it's far better at what happens inside the guest once it exists: installing packages, writing config files, restarting services. Plenty of setups use both — Terraform to provision, then hand off to Ansible for configuration — rather than picking one exclusively.
Do I need the QEMU guest agent installed for this to work?
Not strictly, but it makes dynamic inventory considerably more reliable. Without the guest agent, the inventory plugin can only report whatever static IP was set in the VM's network configuration at provisioning time; with it, Proxmox reports the guest's actual reported interfaces, which stays accurate even if the guest's networking changes after boot through DHCP or manual reconfiguration.
Can community.general.proxmox_kvm still be used, or do I have to migrate immediately?
It still works today via the forwarding shim in recent community.general releases, with a deprecation warning on every run. There's no hard cutover date publicly announced, but treating deprecation warnings as a to-do list rather than background noise avoids a scramble later when the shim is eventually removed.
Why did my container get created but never boot?
Check the state parameter first — state: present only creates the container, it doesn't start it, which is intentional and mirrors how the VM module behaves. The second most common cause is a template that failed to download or extract correctly; verify it with pveam list local on the node before assuming the playbook itself is at fault.
Conclusion
The biggest practical shift here isn't the specific module names — it's moving off tutorials that still point at community.general.proxmox_kvm and onto community.proxmox, and treating dynamic inventory as the default rather than a static list you update by hand. Once the token, role, and inventory plugin are wired up correctly, provisioning a new guest and configuring what runs on it becomes one playbook run instead of a clone in the GUI followed by an SSH session you'll forget the details of the next time you need to repeat it.