14

API, users & automation

Everything in Proxmox VE goes through one REST API under /api2/json: the web UI, the CLI tools, Terraform, Ansible and the Kubernetes drivers. So "who can do what" has exactly one answer, computed on every call from users, groups, tokens and ACLs stored in /etc/pve/user.cfg. Learn how that check works and automation stops being a guessing game of 403s.

Authenticating to /api2/json

browser / clientpveproxy :8006
1/8

Log in once with a password against a realm (pam, pve, LDAP, AD or OpenID Connect). With 2FA configured, the first answer asks for the second factor.

@pam

Linux PAM on the node. root@pam lives here. Users must exist on every node separately: fine for a few admins, bad for teams.

@pve

Proxmox VE authentication server: password hashes in /etc/pve/priv/shadow.cfg, so cluster-wide automatically. Good for service accounts.

@ldap / ad

Bind against LDAP or Active Directory. pveum realm sync imports users and groups so you can put ACLs on directory groups.

@openid

OpenID Connect SSO (Keycloak, Entra ID, Authentik…). Optional auto-create of users on first login; roles still come from Proxmox ACLs.

A user ID is always name@realm: alice@pve and alice@corp-ad are two different users. Tokens add !tokenid.

authorization

Paths, roles and the propagate flag

Permissions are ACL entries: (path, user | group | token, role, propagate). Paths form a tree: /vms/<id>, /storage/<id>, /nodes/<node>, /pool/<name>, /sdn/zones/<zone>/<vnet>, /access. A role is just a named set of privileges such as VM.PowerMgmt or Datastore.AllocateSpace. Every API method declares the privilege and path it needs; pick a principal and an action below to see how Proxmox resolves it.

ACL evaluator: who may do what, and why

principal
action
POST /nodes/pve1/qemu/101/status/start
403 Permission check failed (/vms/101, VM.PowerMgmt)
alice@pveno VM.PowerMgmt
/@ops PVEAuditorPVEAuditor
/vmsalice@pve PVEVMAdmin (no propagate)PVEAuditor
/vms/101·PVEAuditor
alice@pve · via pool devno VM.PowerMgmt
/@ops PVEAuditorPVEAuditor
/pool·PVEAuditor
/pool/dev·PVEAuditor

A VM in a pool also gets the privileges granted on the pool path; the two are combined (union).

ACL entries (toggle them)
onpathuser / @group / tokenrolepropagate
/@opsPVEAuditor
/vmsalice@pvePVEVMAdmin
/storage/backup-nfs@opsNoAccess
/pool/dev@devsPVEVMAdmin
/vms/100bob@corp-adPVEVMUser
/storage/local-lvm@devsPVEDatastoreUser
/sdn/zones/localnetwork@devsPVESDNUser
/terraform@pveTerraformProv
/pool/devterraform@pve!ciTerraformProv
/storage/local-lvmterraform@pve!ciTerraformProv
/sdn/zones/localnetworkterraform@pve!ciTerraformProv
# /etc/pve/user.cfg (ACL lines: acl:<propagate>:<path>:<who>:<roles>:)
acl:1:/:@ops:PVEAuditor:
acl:0:/vms:alice@pve:PVEVMAdmin:
acl:1:/storage/backup-nfs:@ops:NoAccess:
acl:1:/pool/dev:@devs:PVEVMAdmin:
acl:1:/vms/100:bob@corp-ad:PVEVMUser:
acl:1:/storage/local-lvm:@devs:PVEDatastoreUser:
acl:1:/sdn/zones/localnetwork:@devs:PVESDNUser:
acl:1:/:terraform@pve:TerraformProv:
acl:1:/pool/dev:terraform@pve!ci:TerraformProv:
acl:1:/storage/local-lvm:terraform@pve!ci:TerraformProv:
Rules, as Proxmox applies them
  1. Walk the path from / down to the target. At each level, ACLs that apply replace what was inherited.
  2. An ACL with propagate=0 only counts on its exact path.
  3. At the same level, ACLs for the user override those for their groups.
  4. NoAccess in the final set cancels every other role.
  5. Pool members also get what's granted on /pool/<name>.
  6. A privsep token gets the intersection of its own ACLs and its user's.

Try: as alice, start VM 101, then tick propagate on her /vms entry · as alice, browse local-lvm vs backup-nfs (NoAccess) · as the token, clone (then enable its SDN entry, or turn privsep off) · as bob, start VM 101 (granted via the pool).

Use pools to scope teams
Put a team's VMs (and storages) in a pool and grant the role on /pool/<name>. New VMs created into the pool are covered automatically; no per-VM ACLs to maintain.
Creating VMs needs more than VM.Allocate
A clone or create also checks Datastore.AllocateSpace on the target storage and, since PVE 8, SDN.Use on the bridge (/sdn/zones/localnetwork/vmbr0 for plain Linux bridges). Missing one of those is the classic Terraform 403.
the api from a shell

pvesh mirrors the REST tree

get, create, set, delete map to GET, POST, PUT, DELETE on the same paths. The API viewer at https://<node>:8006/pve-docs/api-viewer/ lists every endpoint with its parameters and required permissions.
pvesh & curl
# pvesh walks the same tree as /api2/json, without HTTP
pvesh ls /nodes/pve1/qemu                          # list children
pvesh get /cluster/resources --type vm --output-format json-pretty
pvesh get /nodes/pve1/qemu/100/status/current
pvesh create /nodes/pve1/qemu/100/status/start     # POST
pvesh set /nodes/pve1/qemu/100/config --memory 4096  # PUT
pvesh delete /nodes/pve1/qemu/100/snapshot/pre-upgrade

# The same call over HTTPS with a token
curl -s -H 'Authorization: PVEAPIToken=terraform@pve!ci=2b4e1c8a-7f0d-4e7b-9a51-0c3f6d2e9b17' \
  https://pve1.example.com:8006/api2/json/nodes/pve1/qemu/100/status/current | jq .data.status
least-privilege service account
# A least-privilege identity for Terraform
pveum user add terraform@pve --comment "IaC pipeline"
pveum role add TerraformProv --privs "VM.Allocate VM.Audit VM.Clone \
  VM.Config.CDROM VM.Config.CPU VM.Config.Cloudinit VM.Config.Disk \
  VM.Config.HWType VM.Config.Memory VM.Config.Network VM.Config.Options \
  VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit SDN.Use"

pveum pool add dev
pveum acl modify /pool/dev                --users terraform@pve --roles TerraformProv
pveum acl modify /storage/local-lvm       --users terraform@pve --roles TerraformProv
pveum acl modify /sdn/zones/localnetwork  --users terraform@pve --roles TerraformProv

# The token: privsep=1, so it also needs its own ACLs (a subset)
pveum user token add terraform@pve ci --privsep 1
pveum acl modify /pool/dev --tokens 'terraform@pve!ci' --roles TerraformProv

pveum user permissions terraform@pve --path /pool/dev   # debug: what does it end up with?
automation

Terraform, Ansible, Packer

The common pattern: Packer builds a golden template, Terraform declares the VMs cloned from it (with cloud-init for identity), Ansible configures what runs inside. All three authenticate with an API token.
main.tf · bpg/proxmox provider
terraform {
  required_providers {
    proxmox = {
      source = "bpg/proxmox"
    }
  }
}

provider "proxmox" {
  endpoint  = "https://pve1.example.com:8006/"
  api_token = var.pve_api_token # "terraform@pve!ci=2b4e1c8a-…"
}

resource "proxmox_virtual_environment_vm" "web" {
  name      = "web-01"
  node_name = "pve1"
  pool_id   = "dev"

  clone {
    vm_id = 9000
    full  = false # linked clone of the template
  }

  cpu {
    cores = 2
    type  = "x86-64-v2-AES"
  }

  memory {
    dedicated = 2048
  }

  agent {
    enabled = true
  }

  network_device {
    bridge = "vmbr0"
  }

  initialization {
    ip_config {
      ipv4 {
        address = "10.0.10.31/24"
        gateway = "10.0.10.1"
      }
    }
    user_account {
      username = "ops"
      keys     = [trimspace(file("~/.ssh/id_ed25519.pub"))]
    }
  }
}
playbook.yml · community.proxmox
# community.proxmox (the modules formerly in community.general)
- hosts: localhost
  gather_facts: false
  vars:
    pve: &pve
      api_host: pve1.example.com
      api_user: ansible@pve
      api_token_id: ci
      api_token_secret: "{{ vault_pve_token_secret }}"
      node: pve1
  tasks:
    - name: Linked clone of template 9000
      community.proxmox.proxmox_kvm:
        <<: *pve
        clone: debian13-tmpl
        vmid: 9000
        newid: 202
        name: web-02
        full: false
        pool: dev

    - name: Cloud-init settings, then start
      community.proxmox.proxmox_kvm:
        <<: *pve
        vmid: 202
        ciuser: ops
        sshkeys: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
        ipconfig:
          ipconfig0: ip=10.0.10.32/24,gw=10.0.10.1
        update: true
    - community.proxmox.proxmox_kvm:
        <<: *pve
        vmid: 202
        state: started
debian13.pkr.hcl · proxmox-iso builder
packer {
  required_plugins {
    proxmox = {
      source  = "github.com/hashicorp/proxmox"
      version = ">= 1.2.0"
    }
  }
}

source "proxmox-iso" "debian13" {
  proxmox_url = "https://pve1.example.com:8006/api2/json"
  username    = "packer@pve!build"
  token       = var.pve_token
  node        = "pve1"
  vm_id       = 9100

  boot_iso {
    iso_file = "local:iso/debian-13.1.0-amd64-netinst.iso"
    unmount  = true
  }
  http_directory = "http" # serves preseed.cfg
  boot_command   = ["<esc><wait>auto url=http://{{ .HTTPIP }}:{{ .HTTPPort }}/preseed.cfg<enter>"]

  cores           = 2
  memory          = 2048
  scsi_controller = "virtio-scsi-single"
  disks {
    disk_size    = "16G"
    storage_pool = "local-lvm"
    type         = "scsi"
  }
  network_adapters {
    bridge = "vmbr0"
    model  = "virtio"
  }

  cloud_init              = true
  cloud_init_storage_pool = "local-lvm"
  template_name           = "debian13-golden"
  ssh_username            = "root"
  ssh_password            = var.build_password
}

build {
  sources = ["source.proxmox-iso.debian13"]
}
  • why it mattersOne API, one permission check. The UI, pvesh, qm and Terraform all hit the same handlers, so a 403 names the exact path and privilege that's missing.
  • why it mattersInheritance is most specific wins: deeper ACLs replace inherited ones, user entries beat group entries, NoAccess cancels everything, and propagate=0 stops at its own path.
  • why it mattersGive automation privilege-separated API tokens: no CSRF or ticket renewal, a revocable secret per pipeline, and never more power than the owning user.