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— Read-only here because it is deliberately not executed — read it and predict the second run's behaviour before continuing. The answer is different depending on one detail.
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— Read-only here for the same reason. nextid returns the lowest free VMID, so a second run allocates a different one — and succeeds.
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
Deterministic identity. The same logical guest maps to the same
VMID on every run.
Read before write. Fetch current state, compute the difference,
write only what differs.
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— Read-only. The digest is a SHA1 over the configuration file's contents. Any change by anyone — GUI, another script, an operator at a shell — changes it.
Configuration changewrite with the digest as a guard— Changes the VM configuration only if it has not been modified since you read it. If someone else changed anything in between, the write is rejected rather than applied on top of their change. This is the single highest-value line in this lesson.
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— Illustrative. This is a rejection, not a bug. Someone changed the configuration between your read and your write; re-read, recompute the difference, and try again.
# 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— Reads current state, compares against intent, and writes only the keys that differ — carrying the digest so a concurrent change aborts rather than being overwritten. Running it repeatedly on an already-correct guest performs no write at all.
#!/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— Removes the named keys from the guest configuration entirely, so the guest falls back to the default. This is what a reconcile step needs when intent no longer includes a setting that the guest currently has.
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— Read-only. The pending endpoint returns key, value (in effect now) and pending (queued for next start). A drift check that reads only the config will report success while the running guest is still on the old value.
Read-only / Safea guest that is not what its config says it is— Illustrative. cores is 4 in effect and 8 pending; the change was accepted, is recorded, and has not happened. A monitoring check reading the config alone reports 8.
# pvesh get /nodes/pve-01/qemu/100/pending --output-format json | jq ...
Configuration changediscard a pending change you no longer want— Reverts queued changes without restarting the guest. Useful when a reconcile run queued something that a later decision reversed, and you would rather not carry it to the next restart.
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— Tags are described in the API as meta information only — they change no behaviour, which is exactly why they are safe to use as ownership markers. protection is different: it genuinely disables the remove VM and remove disk operations, including from a script.
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— Read-only. Runs the comparison without applying anything, so it is safe on a schedule and safe during an incident. Run this daily for a fortnight before you let anything reconcile automatically.
#!/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
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?
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.
Q3. Which of these make a home-grown "declarative" Proxmox wrapper incorrect? Select all that apply.
Q4. Why does the Proxmox API have no declarative "apply this desired state" endpoint?
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.