credativ hands-on workshop
formorer)alexander.wirt@credativ.de| 01 | Ansible in 30 minutes — inventory, ad-hoc, idempotency | 30 |
| 02 | Your first playbook — repositories, packages, time | 30 |
| 03 | Forming the cluster — what the title promises | 40 |
| Break | 10 | |
| 04 | From playbook to role — structure that survives | 25 |
| 05 | Operating the cluster — storage, users, a guest | 45 |
| 06 | Dynamic inventory — the cluster describes itself | 10 |
| 07 | Where to go next | 5 |
Those are topics for dedicated follow-ups.
| Machine | Role | Address |
|---|---|---|
desktop |
Ansible control node (your desktop) | 192.168.0.254 |
pve01 |
Proxmox VE node 1 → cluster primary | 192.168.0.1 |
pve02 |
Proxmox VE node 2 | 192.168.0.2 |
pve03 |
Proxmox VE node 3 | 192.168.0.3 |
Log in as root with the password password123 — the same on all three nodes, for SSH and for the web GUI on :8006.
Every participant has an identical, isolated set.
Everything is on the share already mounted on your desktop. Run it once:
~/ansible-proxmox — the project you work in~/ansible-venv — Ansible with the Proxmox collection, put on your PATH~/workshop-slides — these slidesThen open a new terminal, or the PATH change is not in effect.
Open it once and keep the tab. Every module has a handout — the reference, with more detail than these slides — and an exercise sheet. That page links all of them, plus the cheat sheet and the bonus exercises.
On desktop, in a terminal:
No password and no host key question: deploy.sh handled both. If any of these fail, say so now.
ansible --version looks wrongAnsible runs from a virtualenv on this desktop, and it should already be on your PATH. If the version is older than 2.17, or community.proxmox is missing, you are using the system Ansible instead:
Why a virtualenv: the collection community.proxmox 2.x needs a newer proxmoxer than Debian ships, and the packaged collection is missing two modules we use.
~/ansible-proxmox is not an empty directory:
TODO where the content goesWriting the files yourself works too. The exercise sheets contain everything either way.
ansible-core; the managed nodes only require Python.Which machines
What to do
The plan
Everything else (roles, handlers, variables, templates) is structure on top of these three.
all:
children:
pve: # group: all Proxmox VE nodes
hosts:
pve01: {ansible_host: 192.168.0.1}
pve02: {ansible_host: 192.168.0.2}
pve03: {ansible_host: 192.168.0.3}
vars:
ansible_user: root
pve_primary: # group with exactly one member
hosts: {pve01: null}
pve_secondary:
hosts: {pve02: null, pve03: null}A host may be in several groups. We will use exactly that in module 03.
Check that Ansible reads it the way you meant it:
ansible.cfg: stop typing the same flagshost_key_checking = False disables SSH host key verification. Acceptable in a throwaway lab, never in production. There you pre-seed known_hosts.
One module, one run, no file:
pve is the group from the inventory (all, pve01, pve* also work)-m names the module-a passes its argumentsYour key is on the nodes, so nothing asks for a password. Where it is not, --ask-pass asks once instead of once per task.
Useful for looking. For changing, write a playbook.
Ansible reads ansible.cfg from the current working directory, and that file is what points at your inventory. From anywhere else:
Three warnings, no error, nothing ran. Almost always a wrong cd, not a broken inventory.
A module that returns data:
SUCCESS means nothing changed, CHANGED means it did. The hosts come back in whatever order they finish — ad-hoc runs them in parallel.
command and shell answer differently: a header line, then raw output.
And when it fails, the return code comes with it:
| Word | Meaning |
|---|---|
SUCCESS |
already in the desired state, nothing done |
CHANGED |
Ansible changed something |
FAILED |
the task ran and did not succeed |
UNREACHABLE |
Ansible never got to the host |
You describe the target state, not the steps.
changedokA playbook that reports only ok still did its job: it checked every statement and found reality already matching.
command is the last resortAnsible cannot know what your command does, so it reports changed even for reading a version number. You saw that in exercise 01.
Look for a module first. Nearly everything you would type on a Proxmox VE node has one, or is reachable through the API.
Then the guard is yours to write:
creates: — skip the task when that path already existswhen: — a condition of your ownchanged_when: / failed_when: — decide yourself what countscommand, with every guard listed: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/command_module.htmlEvery module page online has the same content as ansible-doc <module> on your desktop, which is offline and matches the version you are actually running.
ansible.cfg and the inventory, check it with ansible-inventory --graphfile command twice→ Exercise sheet: Ansible in 30 minutes
pve01 appears twice. Groups are labels, not folders.
Three SUCCESS blocks, in whatever order the hosts finish.
Three [WARNING] lines and nothing else means you are in the wrong directory, not that the inventory is broken. Check pwd first.
Quote the wildcard, or the shell gets to it first.
Patterns are not shell globs: pve0[23] matches nothing, and an empty match is not an error.
The module stats the path, finds it already right, and reports SUCCESS.
Same result on the machine, different report. This is why modules beat commands.
--check --diff before you mean itBefore three Proxmox VE nodes can become a cluster, they need:
| Requirement | Why |
|---|---|
| Working package sources | the enterprise repo fails without a subscription |
| Name resolution for each other | corosync and pvecm resolve node names |
| Synchronised time | corosync membership is time-sensitive |
| The same base state | “it works on pve01” is not a plan |
Four requirements, four groups of tasks. That is the playbook.
playbooks/10-node-prep.yml
---
- name: Prepare the Proxmox VE nodes
hosts: pve # which machines (a group from the inventory)
gather_facts: true # collect facts first (distribution, IPs, ...)
tasks:
- name: Install the admin tools we want everywhere
ansible.builtin.apt:
name: "{{ pve_packages }}"
state: present
update_cache: true
cache_valid_time: 3600name: on every task. It is what you read in the output and in the logs.group_varspve{ pve_packages }gather_facts: true collects a few hundred variables per host before the first task:
Use them instead of hard-coding:
Your lab runs Proxmox VE 9 on Debian 13. We use the fact instead of hardcoding trixie.
- name: Disable the enterprise repositories
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/apt/sources.list.d/pve-enterprise.sources
- /etc/apt/sources.list.d/ceph.sources
notify: Update apt cache
- name: Enable the no-subscription repository
ansible.builtin.deb822_repository:
name: pve-no-subscription
types: [deb]
uris: http://download.proxmox.com/debian/pve
suites: "{{ ansible_distribution_release }}"
components: [pve-no-subscription]
signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg
notify: Update apt cacheWe need the cache before installing packages, so we force it:
Name resolution comes from the provisioning, together with DNS and /etc/hosts. The playbook confirms it instead of rewriting it:
groups['pve'] is the inventory, read at runtimedifference([inventory_hostname]) drops the node itself from the loop/etc/hosts?Because something else already owns it.
Automating a file that another system owns is how you break a platform quietly. Check the state you depend on; configure only what is yours.
- name: Install the admin tools
ansible.builtin.apt:
name: "{{ pve_packages }}"
state: present
- name: Check whether the clock is synchronised
ansible.builtin.command: timedatectl show -p NTPSynchronized --value
register: ntp_state
changed_when: false # this only reads, so never report a change
check_mode: false # ... so it is safe to run it in --check too
- name: Fail early if time is not in sync
ansible.builtin.assert:
that: ntp_state.stdout == 'yes'
fail_msg: "Time is not synchronised - corosync will be unhappy"changed=0 on the second run is your proof that the playbook is idempotent. One task fewer, too: the handler only runs when something notifies it.
--check is not perfect: a task that depends on an earlier change cannot know the future. It still catches most mistakes before they reach three nodes at once.
Write playbooks/10-node-prep.yml, run it in check mode, then for real, then again.
→ Exercise sheet: Preparing the nodes
handlers:
- name: Update apt cache
ansible.builtin.apt: {update_cache: true}
- name: Enable the no-subscription repository
ansible.builtin.deb822_repository:
name: pve-no-subscription
uris: http://download.proxmox.com/debian/pve
suites: "{{ ansible_distribution_release }}"
components: [pve-no-subscription]
signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg
notify: Update apt cachenotify must match the handler name exactly. A typo does not error — the handler just never runs.
--check skips command tasks, which would leave ntp_state undefined and fail the assert. That is what check_mode: false on the timedatectl task is for.
One task fewer. Which one?
The handler. It runs only when something notifies it.
changed_when: false — without it command reports changed every run, and the proof from task 3 is gonecheck_mode: false — without it --check skips the task, ntp_state is never registered, and the dry run fails on an undefined variablecommunity.proxmox collectionOne shared configuration filesystem (/etc/pve), one GUI for all three, quorum.
Works everywhere · you own the guards · parsing output is on you
We use the collection module. The CLI command path is in the handout as a fallback.
community.proxmox ships around sixty modules plus an inventory plugin.
The Proxmox modules used to live in community.general. They moved to their own collection and were removed from community.general in version 11. Old examples on the internet still say community.general.proxmox_kvm.
proxmoxer and requests must exist where the task runsdelegate_to: localhostThe play still iterates over pve02 and pve03. You keep the per-host variables and only move the execution.
In inventory/group_vars/pve.yml:
playbooks/20-cluster.yml
- name: Create the cluster on the primary node
hosts: pve_primary
gather_facts: false
tasks:
- name: Ensure the cluster exists
community.proxmox.proxmox_cluster:
state: present
api_host: "{{ ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
cluster_name: "{{ pve_cluster_name }}"
link0: "{{ ansible_host }}"
delegate_to: localhostlink0 pins corosync to the management network. Be explicit; do not let it guess.
- name: Join the remaining nodes
hosts: pve_secondary
gather_facts: false
serial: 1 # one node after the other
vars:
primary: "{{ groups['pve_primary'][0] }}"
tasks:
- name: Read the join information from the primary
community.proxmox.proxmox_cluster_join_info:
api_host: "{{ hostvars[primary].ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
delegate_to: localhost
register: join_info - name: Join this node to the cluster
community.proxmox.proxmox_cluster:
state: present
api_host: "{{ ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
master_ip: "{{ hostvars[primary].ansible_host }}"
fingerprint: "{{ (join_info.cluster_join.nodelist
| selectattr('name', 'equalto', primary)
| first).pve_fp }}"
link0: "{{ ansible_host }}"
delegate_to: localhostThe fingerprint is how the joining node checks it is talking to the right cluster.
serial: 1 mattersWithout it, all plays run in parallel and two nodes try to join the same cluster at the same moment.
serial: 1 turns a race condition into a queue. It costs a few seconds and removes a class of bug that is unpleasant to debug later.
The API accepts the join and returns immediately. The node is not a member yet.
- name: Wait until this node is a quorate cluster member
community.proxmox.proxmox_cluster_status_info:
api_host: "{{ ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
delegate_to: localhost
register: cl
until: cl.cluster_status | default([])
| selectattr('type', 'equalto', 'cluster')
| selectattr('quorate', 'equalto', true) | list | count > 0
retries: 30
delay: 5default([]) matters: the node’s API restarts during the join, and without it a single failed call aborts the loop instead of retrying.
- name: Wait until the primary reports a quorate cluster
community.proxmox.proxmox_cluster_status_info:
...
register: primary_status
until: primary_status.cluster_status | default([])
| selectattr('type', 'equalto', 'cluster')
| selectattr('quorate', 'equalto', true) | list | count > 0
retries: 30
delay: 5Skip this and the first join fails with cluster not ready - no quorum?. Waiting for port 8006 does not help: pveproxy never went away.
Then check, from Ansible and from the node:
Run the playbook a second time: everything ok, nothing changed.
Three nodes, three votes, quorum at 2.
| Situation | Quorate | Effect |
|---|---|---|
| 3 of 3 online | yes | normal operation |
| 2 of 3 online | yes | full operation, no redundancy left |
| 1 of 3 online | no | /etc/pve becomes read-only |
A non-quorate node cannot start guests or change configuration. That is a feature: it prevents two halves of a split cluster from both believing they are in charge.
/etc/pveRoot’s authorized keys are not a per-node file. They live in pmxcfs, which makes them cluster-wide.
A joining node receives the primary’s /etc/pve. Keys that existed only on that node are gone afterwards.
Your key is on all three nodes since deploy.sh, so the join changes nothing for you. On a cluster you build for someone else, it decides whether you can still log in.
Install the collection, set variables in group_vars/pve.yml, write 20-cluster.yml, run it, verify.
→ Exercise sheet: Forming the cluster
The collection is already there — deploy.sh installed it offline.
- name: Wait until the primary reports a quorate cluster
community.proxmox.proxmox_cluster_status_info:
register: primary_status
until: primary_status.cluster_status | default([])
| selectattr('type', 'equalto', 'cluster')
| selectattr('quorate', 'equalto', true) | list | count > 0
retries: 30
delay: 5
delegate_to: localhostdefault([]) is not decoration: the node’s API restarts during the join, and without it one failed call aborts the loop instead of retrying.
pve01 has two tasks, the others three. No --check: a dry run creates no cluster, so the join play has nothing to read.
The same three directories on every node — pmxcfs replicating. Second playbook run: changed=0 everywhere.
10-node-prep.yml into a role without changing its behaviourdefaults/, logic in tasks/, files in templates/Your 10-node-prep.yml is 60 lines. It will not stay that way.
A role is a directory layout with conventions, nothing more.
roles/pve_node/
├── defaults/main.yml # values, lowest precedence - meant to be overridden
├── vars/main.yml # values that are NOT meant to be overridden
├── tasks/main.yml # the tasks (no "hosts:", no "- name: play")
├── handlers/main.yml # the handlers
├── templates/ # Jinja2 templates (.j2)
├── files/ # files copied verbatim
└── meta/main.yml # dependencies, metadataAnsible loads main.yml from each of these automatically. You do not wire anything up yourself.
defaults/ is the role’s interfaceroles/pve_node/defaults/main.yml
Anyone using your role can override any of these. That is the contract.
vars/main.yml is the opposite: values the role needs and callers should not touch. Use it sparingly, because it outranks almost everything.
From weakest to strongest, the parts you will actually meet:
roles/x/defaults/main.ymlinventory/group_vars/inventory/host_vars/roles/x/vars/main.ymlvars: in the play--extra-vars on the command lineThe working rule: put values in defaults/, override them in group_vars/. That covers almost every case.
A template renders a file from inventory data and facts. The classic first one is a message of the day:
templates/motd.j2
The logic leaves the YAML. Diffs become readable, and the template can be inspected on its own.
template owns the whole fileThat is the difference to lineinfile or blockinfile, which own a marked region.
blockinfile |
template |
|
|---|---|---|
| Owns | a marked region | the whole file |
| Foreign changes | survive | are overwritten |
| Use when | the file has other owners | the file is yours alone |
/etc/motd is yours. /etc/hosts is not — the provisioning writes it, and Proxmox VE resolves its own node name through it. Pick the file before you pick the module.
A tagged partial run can leave a host in a state that no full run would produce. Tags are for fast iteration while developing, not a substitute for structure.
ansible-lintIt catches the things reviewers would otherwise catch for you: missing name:, shell where command suffices, deprecated syntax, unsafe file permissions.
Convert 10-node-prep.yml into the role pve_node and prove that nothing changed: the first run after the conversion must report changed=0.
→ Exercise sheet: From playbook to role
Uncomment roles/pve_node/defaults/main.yml, then delete pve_packages from inventory/group_vars/pve.yml.
Leave it in group_vars and group_vars keeps outranking the role default — the interface is then decorative.
Correct behaviour, not a broken refactoring: role defaults are in scope only while the role runs.
The one changed is /etc/motd — the flat playbook never wrote it. Everything else was already in the state the role describes.
module_defaultsOne API call, one cluster-wide change. You address any node; pmxcfs distributes the result.
Every node serves the full API reference, for exactly the version you are running:
Use it when a module lacks an option. The page tells you whether the API has it at all — which decides between “file a feature request” and “use ansible.builtin.uri”.
POST /nodes/{node}/qemu in the API browser lists vmid, cores, memory, scsihw — the same names. That correspondence is what makes the browser the fastest way to find out what a guest option is actually called.
module_defaultsEvery community.proxmox module in this play inherits those four lines.
The node is a Debian machine and a Proxmox VE node.
The backup directory is local to each node. An NFS library is shared, and it is where images and templates live in a real environment:
/mnt/pve/nfs-sharedcontent decides what may live there; no images, so nothing gets allocated on it- community.proxmox.proxmox_group:
name: automation
comment: Service accounts
- community.proxmox.proxmox_user:
name: automation@pve
groups: [automation]
comment: Ansible service account
enable: true
- community.proxmox.proxmox_access_acl:
path: /
type: group
ugid: automation
roleid: PVEVMAdmin
propagate: 1Realms matter: @pam are Linux users on the node, @pve exist only in Proxmox VE.
automation_user.secretsprivsep: false gives the token the user’s permissions; true restricts it further with its own ACLNever print a secret with debug in a shared session. Write it somewhere safe, ideally straight into an encrypted file.
A token can be revoked on its own, is scoped by ACL, and never unlocks an SSH session. The root password can do none of those things.
- community.proxmox.proxmox_kvm:
node: "{{ pve_vm_node }}"
vmid: "{{ pve_vm_id }}"
name: "{{ pve_vm_name }}"
cores: 1
memory: 1024
ostype: l26
scsihw: virtio-scsi-single
scsi:
scsi0: "{{ pve_vm_storage }}:0,import-from={{ pve_cloud_image_path }}"
ide:
ide2: "{{ pve_vm_storage }}:cloudinit"
net:
net0: virtio,bridge=vmbr0
boot: "order=scsi0"
timeout: 300 # NFS import is slower than the 30 s default
state: present:0 means “size taken from the source image”import-from reads the qcow2 straight off the NFS libraryide2: …:cloudinit adds the cloud-init drivecan't find fileWaiting for the path does not help: nothing has asked the node to mount it yet.
The guest configures its user, its SSH key and its address on first boot. No installer, no answer file, no template maintenance.
cipassword alone will not get you in: the Debian cloud image ships with PasswordAuthentication no. Set sshkeys.
Import, boot and cloud-init together take well under a minute on this lab.
The template has to be on the storage first; proxmox_template downloads it. Never hard-code the file name; ask the node which ones exist:
Not everything has a module, and some things are simply faster to write directly. A second bridge, over SSH:
- name: Add a second bridge
hosts: pve
tasks:
- name: Define vmbr1 in /etc/network/interfaces
ansible.builtin.blockinfile:
path: /etc/network/interfaces
marker: "# {mark} ANSIBLE MANAGED - vmbr1"
block: |
auto vmbr1
iface vmbr1 inet manual
bridge-ports none
bridge-stp off
bridge-fd 0
notify: Reload network configurationifreload -a applies the change immediately. A mistake in the file for vmbr0 takes the node off the network, including your own SSH session. Always keep a console (VDI, IPMI) available, and never touch the management interface of all three nodes in one run.
/etc/network/interfaces.new, the same pending state the GUI showsifreload -aBoth routes end in the same file. Only one of them is idempotent and reviewable before it takes effect.
blockinfile over SSH |
proxmox_node_network |
|
|---|---|---|
| Idempotent | you build the guard | yes |
| Visible in the GUI as pending | no, it is already in the live file | yes |
| Works without the collection | yes | no |
| Risk of locking yourself out | high | lower, but real |
Use the module when it covers your case. Reach for the file when it does not, and then go carefully: one node at a time, --limit, console at hand.
wait: true makes the task finish when the backup finishes, not when the API has accepted the request.
Creating a scheduled backup job is not covered by a module today. proxmox_backup_schedule only adds guests to a job that already exists.
ansible.builtin.uri against the REST API works for anything. You lose idempotency, so you add the guard yourself, the same way you do with command.
Storage, a service account with a token, a VM, a backup. One playbook.
→ Exercise sheet: Operating the cluster
pve_vm_storage: local-lvm on this lab. A ZFS install would say local-zfs, and the guest task fails late and confusingly if it is wrong.
privsep: false gives the token its user’s permissions.
With privsep: true the token needs its own ACL as well, and the effective rights are the intersection of both. A token that works for one call and returns an empty list for the next is almost always this.
Proxmox VE mounts NFS on first activation, not when you attach it. Importing in the same run fails with can't find file, and wait_for on the path does not help — nothing has asked the node to mount it yet.
Most tasks run on localhost: the API modules talk HTTPS and are delegated there.
Second run: localhost : ok=10 changed=1 skipped=1. The one changed is Back up the VM — a backup is an action, not a state. The skipped task writes the token secret, which the API returns only at creation.
community.proxmox.proxmox inventory pluginThe cluster already knows all of this. Ask it.
inventory/lab.proxmox.yml
The file name must end in .proxmox.yml or .proxmox.yaml. Otherwise the plugin politely ignores it and you debug an empty inventory.
Groups per node, per type, per status, per pool. Generated, never maintained.
With want_facts: true every guest carries its Proxmox configuration:
Tag a guest in Proxmox VE, group on it in Ansible:
The person creating the VM decides its role, in the GUI, with a tag, and your playbooks follow.
Or set both in ansible.cfg:
The nodes stay static, because they have to exist before anything is dynamic. The guests come from the cluster.
Configure the plugin, list the inventory, find your VM from module 05, tag it and build a group from the tag.
→ Exercise sheet: Dynamic inventory
A 401 means the token does not match this cluster. Usually the secret was never exported, or module 05 ran somewhere else.
Nobody typed any of that into an inventory file.
The file name must end in .proxmox.yml. Otherwise the plugin ignores it, and you get an empty inventory with no error.
ansible-inventory -vvv shows the “Skipping due to inventory source not ending in …” line. Worth showing once — it is the one failure here that looks like nothing at all.
~/ansible-proxmox/
├── ansible.cfg
├── requirements.yml
├── site.yml
├── inventory/
│ ├── hosts.yml # the three nodes
│ ├── lab.proxmox.yml # everything else, from the API
│ └── group_vars/
├── playbooks/
│ ├── 20-cluster.yml # forms the cluster
│ └── 30-operations.yml # storage, users, guests, backup
└── roles/pve_node/ # the reusable partchanged=0 is a test result. It confirms reality matches the definition.| Topic | Where it belongs |
|---|---|
| Ceph, HA, SDN, QDevice | Proxmox VE Clustering & Shared Storage |
| Cloud-init images, golden templates | a VM lifecycle workshop |
| AWX / Ansible Automation Platform, Semaphore | an Ansible operations workshop |
| Molecule, CI for roles | an Ansible testing workshop |
| Ansible for the guests inside the VMs | a general Ansible course |
Your playbooks currently run from your laptop. That works exactly as long as it is only you.
ansible-lint and --check on every merge requestIn that order.
The bonus exercises go further than the workshop had time for:
ansible.builtin.uriSolutions included. They assume the state you have right now.
https://<node>:8006/pve-docs/api-viewer/ansible-doc <module>Bookmark the third and the fifth. Those are the two you will actually use.
Questions, discussion, and code:
code/ are yours to keepcredativ · Ansible for Proxmox VE