API, users & automation
/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
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.
Linux PAM on the node. root@pam lives here. Users must exist on every node separately: fine for a few admins, bad for teams.
Proxmox VE authentication server: password hashes in /etc/pve/priv/shadow.cfg, so cluster-wide automatically. Good for service accounts.
Bind against LDAP or Active Directory. pveum realm sync imports users and groups so you can put ACLs on directory groups.
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.
Paths, roles and the propagate flag
ACL evaluator: who may do what, and why
A VM in a pool also gets the privileges granted on the pool path; the two are combined (union).
| on | path | user / @group / token | role | propagate |
|---|---|---|---|---|
| / | @ops | PVEAuditor | ||
| /vms | alice@pve | PVEVMAdmin | ||
| /storage/backup-nfs | @ops | NoAccess | ||
| /pool/dev | @devs | PVEVMAdmin | ||
| /vms/100 | bob@corp-ad | PVEVMUser | ||
| /storage/local-lvm | @devs | PVEDatastoreUser | ||
| /sdn/zones/localnetwork | @devs | PVESDNUser | ||
| / | terraform@pve | TerraformProv | ||
| /pool/dev | terraform@pve!ci | TerraformProv | ||
| /storage/local-lvm | terraform@pve!ci | TerraformProv | ||
| /sdn/zones/localnetwork | terraform@pve!ci | TerraformProv |
# /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:
- Walk the path from / down to the target. At each level, ACLs that apply replace what was inherited.
- An ACL with propagate=0 only counts on its exact path.
- At the same level, ACLs for the user override those for their groups.
- NoAccess in the final set cancels every other role.
- Pool members also get what's granted on /pool/<name>.
- 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).
pvesh mirrors the REST tree
# 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# 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?Terraform, Ansible, Packer
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"))]
}
}
}# 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: startedpacker {
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.