Proxmox VEXX · CLI & AutomationAPI and automation
REST API automation patterns
What you'll learn
- Choose between a ticket and an API token, and scope the token to what the job needs
- Verify TLS from a script instead of disabling it
- Handle the API error classes correctly, retrying only what is retryable
- Apply backoff so a failing cluster is not made worse by its own automation
- Make automation observable: attribute its actions and record what it did
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
Why this matters in production
The Proxmox REST API is the foundation of every automation tool. Using it correctly means the difference between a reliable automation and a 3 AM incident.
The API calls themselves are the easy part. What separates automation that survives contact with production is everything around them: which identity it uses, whether it verifies who it is talking to, what it does when a call fails, and whether anyone can tell afterwards what it did.
Authentication
Proxmox supports two authentication methods:
- Tickets (
PVEAuthCookie): short-lived, returned by/access/ticket. Use for interactive API use. - API tokens: long-lived, sent in an
Authorizationheader. Use for automation.
The wiki gives the token header format directly:
PVEAPIToken=USER@REALM!TOKENID=UUID, and notes that “API tokens do not need CSRF values
for POST, PUT or DELETE” — which is the practical reason to prefer them for scripts. A
ticket-authenticated client must also fetch and send a CSRF token on every write; a
token-authenticated one does not.
PVE_HOST=pve-01.example.com
PVE_TOKEN='automation@pve!ci=REPLACE_ME'
curl -sS \
-H "Authorization: PVEAPIToken=$PVE_TOKEN" \
"https://$PVE_HOST:8006/api2/json/nodes" | jq -r '.data[].node'Scope the token to the job
API tokens are tied to a user. With --privsep 1, the token has its own ACLs; with
--privsep 0, it inherits the user’s permissions.
pveum user add automation@pve --comment "CI pipeline - ticket OPS-1423"
pveum acl modify /vms/100 --users automation@pve --roles PVEVMAdmin
pveum user token add automation@pve ci --privsep 1
pveum acl listVerify TLS, do not disable it
Almost every Proxmox API example on the internet passes -k or --insecure. It is there
because a fresh install has a self-signed certificate and the example needs to work
without setup. Carrying it into automation means your script will authenticate to anything
that answers on port 8006 — and it will hand over the token while doing so.
PVE_HOST=pve-01.example.com
# on a cluster node:
cat /etc/pve/pve-root-ca.pem
# on the automation host, having placed that file:
curl -sS --cacert /usr/local/share/ca-certificates/pve-root-ca.crt \
-H "Authorization: PVEAPIToken=$PVE_TOKEN" \
"https://$PVE_HOST:8006/api2/json/version" | jq .The better answer, where it is available, is an ACME certificate from a CA the client
already trusts — Proxmox supports this natively through pvenode acme, and then no
client-side configuration is needed at all.
Common API patterns
curl -sS -H "Authorization: PVEAPIToken=$PVE_TOKEN" \
"https://$PVE_HOST:8006/api2/json/cluster/resources?type=vm" \
| jq -r '.data[] | [.vmid, .name, .node, .status] | @tsv'NODE=pve-01
VMID=100
UPID=$(curl -sS -X POST \
-H "Authorization: PVEAPIToken=$PVE_TOKEN" \
"https://$PVE_HOST:8006/api2/json/nodes/$NODE/qemu/$VMID/status/start" \
| jq -r .data)
echo "task: $UPID"Idempotency and drift are large enough subjects to have their own lesson;
xx-automation-idempotency covers the configuration digest, deterministic identity and
reconciliation. Task polling is in xx-cli-tasks-and-upids. This lesson stops at the
envelope.
Error handling: which failures are retryable
The API returns:
| Status | Meaning | Retry? |
|---|---|---|
| 200 | Success | — |
| 400 | Bad request; validation failed | No. The request is wrong; retrying sends the same wrong request |
| 401 | Unauthenticated | No, unless a ticket expired — then re-authenticate once |
| 403 | Authenticated but forbidden | No. A permissions problem does not resolve itself |
| 404 | Not found | No, usually — but see below |
| 500 | Server error | Yes, with backoff |
| 596 | No route to host / node unreachable | Yes, with backoff |
#!/usr/bin/env bash
set -euo pipefail
api() {
local method="$1" path="$2"; shift 2
local attempt=0 max=5 delay=1 code body
while :; do
body=$(curl -sS -X "$method" -w '\n%{http_code}' \
--cacert /usr/local/share/ca-certificates/pve-root-ca.crt \
-H "Authorization: PVEAPIToken=$PVE_TOKEN" \
"https://$PVE_HOST:8006/api2/json$path" "$@" ) || body=$'\n000'
code=$(printf '%s' "$body" | tail -n1)
body=$(printf '%s' "$body" | sed '$d')
case "$code" in
2??) printf '%s' "$body"; return 0 ;;
4??) echo "non-retryable $code on $path: $body" >&2; return 1 ;;
esac
attempt=$(( attempt + 1 ))
if [ "$attempt" -ge "$max" ]; then
echo "giving up after $attempt attempts, last $code: $body" >&2
return 2
fi
echo "attempt $attempt got $code, retrying in ${delay}s" >&2
sleep "$delay"
delay=$(( delay * 2 ))
done
}
api GET /cluster/resources?type=vm | jq -r '.data[].vmid'Three details worth keeping when you port this to another language.
Exponential backoff with a cap, not a fixed interval. A fixed one-second retry from fifty machines against a struggling cluster is a synchronised load spike. Doubling the delay spreads them out, and adding a small random jitter spreads them further.
A distinct exit code for exhaustion. “The call failed” and “we stopped trying” are different facts for the caller, because the second means the operation may still be in progress.
The response body on failure. The API’s error strings are specific —
VM 900 already exists on node 'pve-02' rather than a generic code. Discarding them turns
a five-minute diagnosis into an evening.
Make your automation attributable
Common mistakes
- Using
root@pamwith--privsep 0. It is the fastest thing that works and the worst thing to leak, and it makes every action indistinguishable from an administrator’s. - Carrying
-kfrom an example into production. Your script will hand its token to anything answering on 8006. Trust the cluster CA, use ACME, or pin. - Putting the token secret on the command line. It lands in shell history and in
ps. - Retrying 4xx responses. They do not resolve, and a retried create makes duplicates.
- Fixed-interval retries. Synchronised retries from a fleet turn a degraded cluster into an outage.
- Discarding the response body on failure. The API’s error strings usually name the exact problem.
- Treating a POST response as a result. It is a UPID; the work has only started.
- Parsing the text output format. It is a human table and its columns are not an interface.
Key takeaways
- Tokens for automation, tickets for interactive use. Tokens skip the CSRF requirement on writes, which is why scripts prefer them.
- Create a dedicated user, an ACL on the narrowest path that works, and a token with
--privsep 1. Three commands, and the blast radius becomes one path. - Verify TLS against the cluster CA (
/etc/pve/pve-root-ca.pem) or use ACME. Pin if you must; never disable. - Retry only 5xx and connection failures. 400, 401 and 403 will not resolve.
- Back off exponentially with a cap and jitter, and use a distinct exit code for exhaustion.
- Always log the response body on failure.
- A dedicated identity makes the task log attributable —
--userfilterthen answers “did the pipeline do this”. - Idempotency, drift and task polling have their own lessons; this one is the envelope around the call.
Knowledge check
Knowledge check · 4 questions
Q1. Which option creates an API token with its own ACLs, rather than inheriting the user's full permissions?
Q2. Which API responses should an automation script retry with backoff? Select all that apply.
Q3. Passing -k or --insecure to curl in an automation script is acceptable because the traffic is still encrypted.
Q4. Which HTTP status indicates an unauthenticated request?
Passing score: 75%. Answers are checked in this browser.