Skip to main content
RunBook Academy

Proxmox VEXX · CLI & AutomationAPI and automation

REST API automation patterns

Intermediate⏱ ~26 mincurljq

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

Not yet marked complete on this device.

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 Authorization header. 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.

Read-only / Safea token-authenticated read
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.

Configuration changecreate a scoped automation identity
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 list

Verify 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.

Configuration changetrust the cluster's own CA instead
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

Read-only / Saferead guest configuration across the cluster
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'
Service impact possiblestart a VM, and capture the UPID it returns
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:

StatusMeaningRetry?
200Success
400Bad request; validation failedNo. The request is wrong; retrying sends the same wrong request
401UnauthenticatedNo, unless a ticket expired — then re-authenticate once
403Authenticated but forbiddenNo. A permissions problem does not resolve itself
404Not foundNo, usually — but see below
500Server errorYes, with backoff
596No route to host / node unreachableYes, with backoff
Configuration changea retry wrapper that only retries what is retryable
#!/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@pam with --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 -k from 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 — --userfilter then 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

  1. Q1. Which option creates an API token with its own ACLs, rather than inheriting the user's full permissions?

  2. Q2. Which API responses should an automation script retry with backoff? Select all that apply.

  3. Q3. Passing -k or --insecure to curl in an automation script is acceptable because the traffic is still encrypted.

  4. Q4. Which HTTP status indicates an unauthenticated request?

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