Skip to main content
RunBook Academy

Proxmox VEXX · CLI & AutomationAPI and automation

Idempotency and drift in Proxmox automation

Advanced⏱ ~30 minpveshjq

What you'll learn

  • Explain why the PVE API is imperative and what that costs a re-run
  • Use the configuration digest to make a write fail rather than clobber a concurrent change
  • Choose guest identity deterministically instead of racing on /cluster/nextid
  • Detect drift between intended and actual guest configuration, and report it before acting
  • Write a reconcile step that is safe to run on a schedule

Prerequisites

Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12

Not yet marked complete on this device.

Part XX’s blurb promises idempotency, and every automation tool in this part — Terraform, Ansible, Proxmoxer — claims to provide it. What none of them can do is get it from the API, because the Proxmox API does not offer it.

The API is a set of remote procedure calls. POST creates a thing. PUT modifies a thing that exists. DELETE removes it. There is no endpoint that means “make the cluster look like this”, no reconciliation loop on the server side, and no notion of desired state anywhere in pvedaemon. Every tool that presents a declarative interface is implementing that on the client, by reading current state and computing the difference — and it is worth understanding how, because the places where that abstraction leaks are the places your automation will hurt you.

What “not idempotent” actually costs

Run this twice:

Read-only / Safethe naive create, run twice
# run 1
pvesh create /nodes/pve-01/qemu --vmid 900 --name app-01 --memory 8192 --cores 4

# run 2, identical
pvesh create /nodes/pve-01/qemu --vmid 900 --name app-01 --memory 8192 --cores 4

The second run fails: unable to create VM 900 - VM 900 already exists. That is the good outcome — noisy, obvious, and safe.

Now consider the version most scripts actually contain, where the VMID is allocated rather than chosen:

Read-only / Safethe version that is worse for being convenient
VMID=$(pvesh get /cluster/nextid)
pvesh create "/nodes/pve-01/qemu" --vmid "$VMID" --name app-01 --memory 8192 --cores 4

Run that twice and you have two virtual machines called app-01, on different VMIDs, both configured identically, both consuming memory, one of them unknown to whatever registered the first. It succeeds. Nothing warns you. And this is the pattern almost every getting-started guide shows.

The three properties an idempotent operation needs

  1. Deterministic identity. The same logical guest maps to the same VMID on every run.
  2. Read before write. Fetch current state, compute the difference, write only what differs.
  3. A guard against concurrent modification. So that “what differs” does not become stale between reading and writing.

Proxmox gives you the third for free, and almost nobody uses it.

The digest: optimistic concurrency you already have

Every guest configuration read returns a digest field. Every configuration write accepts one. The API documentation describes it precisely: “Prevent changes if current configuration file has different SHA1 digest. This can be used to prevent concurrent modifications.”

Read-only / Saferead the config and its digest
NODE=pve-01
VMID=100

pvesh get "/nodes/$NODE/qemu/$VMID/config" --output-format json | jq -r '.digest, .memory, .cores'
Configuration changewrite with the digest as a guard
NODE=pve-01
VMID=100

DIGEST=$(pvesh get "/nodes/$NODE/qemu/$VMID/config" --output-format json | jq -r .digest)

pvesh set "/nodes/$NODE/qemu/$VMID/config" --memory 16384 --digest "$DIGEST"
Read-only / Safethe guard doing its job
# pvesh set /nodes/pve-01/qemu/100/config --memory 16384 --digest 4f2a...
400 Parameter verification failed.
digest: detected modified configuration - file changed by other user? Try again.

Illustrative output

Read before write: computing the difference

Configuration changean idempotent 'ensure' for guest configuration
#!/usr/bin/env bash
set -euo pipefail

NODE=pve-01
VMID=100

# intent
declare -A WANT=( [memory]=16384 [cores]=4 [cpuunits]=400 [onboot]=1 )

CUR=$(pvesh get "/nodes/$NODE/qemu/$VMID/config" --output-format json)
DIGEST=$(echo "$CUR" | jq -r .digest)

ARGS=()
for KEY in "${!WANT[@]}"; do
HAVE=$(echo "$CUR" | jq -r --arg k "$KEY" '.[$k] // "unset"')
if [ "$HAVE" != "${WANT[$KEY]}" ]; then
  echo "drift: $KEY is '$HAVE', want '${WANT[$KEY]}'"
  ARGS+=( "--$KEY" "${WANT[$KEY]}" )
fi
done

if [ "${#ARGS[@]}" -eq 0 ]; then
echo "no change needed"
exit 0
fi

pvesh set "/nodes/$NODE/qemu/$VMID/config" "${ARGS[@]}" --digest "$DIGEST"
echo "applied ${#ARGS[@]} change(s)"

Three things that script does deliberately.

It prints the drift before applying it. A reconcile step that silently fixes things is a reconcile step whose findings you never see. The log line is often more valuable than the fix.

It performs no write when nothing differs. That is not merely efficient. Every write to a guest configuration creates a task, appears in the task log, and — for a running guest — may create a pending change. A loop that writes unconditionally fills the task history with noise and makes real changes impossible to find.

It carries the digest read in the same breath as the values. Reading the config and the digest in two separate calls reintroduces exactly the race the digest exists to close.

Removing a setting: --delete

Setting a key to an empty value does not remove it. The API has a dedicated parameter, documented as “a list of settings you want to delete”.

Configuration changeremove settings rather than blanking them
NODE=pve-01
VMID=100

DIGEST=$(pvesh get "/nodes/$NODE/qemu/$VMID/config" --output-format json | jq -r .digest)

pvesh set "/nodes/$NODE/qemu/$VMID/config" --delete cpulimit,hookscript --digest "$DIGEST"

pvesh get "/nodes/$NODE/qemu/$VMID/config" --output-format json | jq 'has("cpulimit")'

This is the gap that makes most home-grown “declarative” wrappers wrong. They set every key they know about and never delete the ones they do not, so a cpulimit applied during an incident six months ago persists forever, invisible to a tool that believes it owns the guest’s configuration.

Pending changes: the state between intent and reality

A running guest cannot accept every configuration change live. Changing cores, machine or bios on a running VM records a pending change, applied at the next start. qm config shows the current values; the pending set is a separate view.

Read-only / Safewhat is configured versus what is running
NODE=pve-01
VMID=100

pvesh get "/nodes/$NODE/qemu/$VMID/pending" --output-format json \
| jq -r '.[] | select(.pending != null) | [.key, (.value|tostring), (.pending|tostring)] | @tsv'
Read-only / Safea guest that is not what its config says it is
# pvesh get /nodes/pve-01/qemu/100/pending --output-format json | jq ...
cores	4	8
machine	pc-q35-9.2+pve0	pc-q35-10.0+pve0

Illustrative output

Configuration changediscard a pending change you no longer want
NODE=pve-01
VMID=100

pvesh set "/nodes/$NODE/qemu/$VMID/config" --revert cores,machine

pvesh get "/nodes/$NODE/qemu/$VMID/pending" --output-format json | jq -r '.[] | select(.pending != null) | .key'

Marking ownership

Automation that manages some guests on a cluster and not others needs to know which. Two mechanisms, both cheap.

Configuration changetag what you own, and protect what must not be deleted
NODE=pve-01
VMID=100

pvesh set "/nodes/$NODE/qemu/$VMID/config" --tags 'managed-by-ci;env-prod'
pvesh set "/nodes/$NODE/qemu/$VMID/config" --protection 1

# everything this pipeline owns, cluster-wide
pvesh get /cluster/resources --type vm --output-format json \
| jq -r '.[] | select(.tags != null and (.tags | test("managed-by-ci"))) | [.vmid, .node, .name] | @tsv'

protection is documented as “sets the protection flag of the VM. This will disable the remove VM and remove disk operations.” It is enforced by the API, so it protects against your own script as much as against a mis-click. For anything whose loss would be an incident, it costs one line and removes an entire class of accident.

A drift report that does not change anything

The most useful automation in this lesson is the one that writes nothing.

Read-only / Safereport drift across every managed guest
#!/usr/bin/env bash
set -euo pipefail

pvesh get /cluster/resources --type vm --output-format json \
| jq -r '.[] | select(.tags != null and (.tags | test("managed-by-ci"))) | [.node, .vmid] | @tsv' \
| while IFS=$'\t' read -r NODE VMID; do
    CUR=$(pvesh get "/nodes/$NODE/qemu/$VMID/config" --output-format json)
    for KEY in cpuunits onboot protection; do
      HAVE=$(echo "$CUR" | jq -r --arg k "$KEY" '.[$k] // "unset"')
      echo "$NODE $VMID $KEY=$HAVE"
    done
  done

Common mistakes

  • Allocating VMIDs with /cluster/nextid in automation. It does not reserve, and a retry after a failure produces duplicate guests. Derive the ID deterministically.
  • Writing without --digest. A reconcile loop then silently overwrites whatever an operator did during an incident.
  • Reading the config and the digest in two separate calls. That reopens the race the digest closes.
  • Setting a key to an empty value to remove it. Use --delete.
  • Never deleting anything. A tool that only sets keys leaves every historical override in place forever, invisible to itself.
  • Reading config and ignoring pending. A queued change reports as applied while the running guest is still on the old value.
  • Writing unconditionally when nothing differs. It floods the task log and hides the changes that mattered.
  • Turning on reconciliation before running the report. That is an unbounded change to production with no baseline and no rollback.
  • Retrying a digest conflict blindly. Re-read and recompute, and escalate if it recurs.

Key takeaways

  • The PVE API is imperative RPC. Declarative behaviour is implemented by the client, and every tool that offers it is doing read-diff-write.
  • Deterministic identity is the first requirement. /cluster/nextid does not reserve, so a retry after failure creates a duplicate guest.
  • The config digest is a SHA1 of the guest’s configuration file and is accepted as a write guard: “prevent changes if current configuration file has different SHA1 digest”.
  • Read the config and digest in one call, compute the difference, write only what differs, and print the drift you found.
  • --delete removes settings; setting an empty value does not.
  • pending is a separate view from config. A drift check that ignores it reports success for changes that have not happened.
  • Tag what you own; protection 1 genuinely blocks removal, including from your own script.
  • Report drift for weeks before reconciling it. A loop against an unknown baseline is an unbounded production change.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A provisioning script calls GET /cluster/nextid, then POSTs a new VM, and retries the whole sequence on any failure. What is the risk?

  2. Q2. Passing --digest on a configuration write causes the write to be rejected if anything about the guest configuration has changed since you read the digest.

  3. Q3. Which of these make a home-grown "declarative" Proxmox wrapper incorrect? Select all that apply.

  4. Q4. Why does the Proxmox API have no declarative "apply this desired state" endpoint?

  5. Q5. Your reconcile pipeline reports a digest conflict on the same guest three runs in a row. What is the right response?

Passing score: 75%. Answers are checked in this browser.