Managing hypervisor guests as code runs into a gap that cloud APIs do not have: there is no image service, so the thing you clone from is something you assembled by hand, and several operations quietly need SSH rather than the API. Those two facts cause most of the trouble. This covers them and leaves the language itself to the existing walkthrough.
Terraform against Proxmox is not the same exercise as Terraform against a cloud API. There is no managed image service, no metadata endpoint, and the resource you clone from is something you built by hand first. Most of the friction comes from that gap rather than from the language.
What This Assumes
This covers the Proxmox-specific parts only: which provider, how authentication works, the template the whole thing depends on, and the failure modes. For the language itself, state files, remote state and locking, workspaces and the pipeline around it, our walkthrough for Terraform on an Ubuntu VPS covers the fundamentals, and everything there applies unchanged.
Pick the bpg Provider
Two community providers exist and the choice is no longer close. The older Telmate provider was the original and is thinly maintained. The bpg/proxmox provider is actively developed, covers containers, storage, users and SDN as well as VMs, and has considerably better documentation.
Neither is official, which is worth stating plainly: Proxmox does not publish a Terraform provider, so you are depending on community work. Pin the version.
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.6"
}
}
}
provider "proxmox" {
endpoint = "https://pve-01.example.com:8006/"
api_token = var.proxmox_api_token
insecure = false
ssh {
agent = true
username = "root"
}
}
That ssh block surprises people. Some operations have no API equivalent and the provider performs them over SSH, uploading a cloud-init snippet among them. A configuration that authenticates by API token and cannot reach SSH will work until it hits one of those, then fail with an error that does not obviously name the cause.
API Tokens and the Privileges They Need
pveum user add terraform@pve
pveum role add Terraform -privs "VM.Allocate 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.Monitor \
VM.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit \
SDN.Use Sys.Audit"
pveum aclmod / -user terraform@pve -role Terraform
pveum user token add terraform@pve tf --privsep 0
Use a token, not a password, and give it its own role rather than reaching for administrator. Two notes on this. --privsep 0 means the token carries the user's privileges; with privilege separation on you must grant permissions to the token separately, which is a common cause of unexplained 403 responses.
And an incomplete privilege list produces failures at apply time rather than at plan time, because the plan does not exercise the API. If a create fails partway with a permissions error, you now have a half-created VM that Terraform believes exists. Get the role right first.
The Template Is the Real Prerequisite
Cloud providers hand you images. Here, you build one, and until it exists nothing else works.
qm create 9000 --name ubuntu-2404-tmpl --memory 2048 --net0 virtio,bridge=vmbr0
qm importdisk 9000 noble-server-cloudimg-amd64.img local-lvm
qm set 9000 --scsihw virtio-scsi-single --scsi0 local-lvm:vm-9000-disk-0
qm set 9000 --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000
Four details, each of which causes a specific failure if omitted. The serial console and vga serial0 are what cloud images expect for output; without them a VM boots to a blank console and you cannot see why. The cloud-init drive must exist or none of your user data is delivered. The QEMU guest agent must be enabled in the VM config and installed in the image, or Terraform waits for an IP address it will never learn and eventually times out. And qm template is what makes it cloneable.
Build the template with a tool rather than by hand if you rebuild it often. Packer has a Proxmox builder, and the combination of Packer for images and Terraform for instances is the arrangement that mirrors how cloud infrastructure is normally managed.
Cloning a VM
resource "proxmox_virtual_environment_vm" "node" {
count = 3
name = "k8s-node-${count.index + 1}"
node_name = "pve-01"
clone {
vm_id = 9000
full = true
}
cpu { cores = 4, type = "host" }
memory { dedicated = 8192 }
disk {
datastore_id = "ceph-rbd"
interface = "scsi0"
size = 60
}
initialization {
ip_config {
ipv4 { address = "10.0.10.${count.index + 21}/24", gateway = "10.0.10.1" }
}
user_account {
username = "ubuntu"
keys = [trimspace(file("~/.ssh/id_ed25519.pub"))]
}
}
agent { enabled = true }
}
full = true matters. A linked clone is fast and depends on the template forever, so the template cannot be deleted or modified and the clone cannot move to different storage independently. Full clones cost disk and time and are what you want for anything you intend to keep.
Note that a disk size larger than the template's grows the volume, but the guest filesystem still has to be expanded. Cloud images generally do this on first boot; a custom template may not, and then you have a 60 GB disk with a 4 GB filesystem on it.
The Failures You Will Meet
Terraform hangs waiting for an IP. The guest agent is not running inside the VM. Install qemu-guest-agent in the template, not just in the VM config.
Perpetual diff on every plan. The provider reads back a field in a different form from the one you wrote, commonly disk size units or a network model. Set the value the way the API returns it, or use lifecycle { ignore_changes = [...] } for the specific attribute rather than living with a plan that is never clean.
Apply fails halfway, state is wrong. More likely here than on a cloud API, because operations are not transactional. terraform state rm the resource, delete the orphaned VM in Proxmox, and apply again. Keep state remote and locked so two people cannot do this simultaneously.
Everything lands on one node. The provider does not schedule. node_name is explicit, so distributing across a cluster is your loop's job, not Terraform's.
What Terraform Should Not Own
Two boundaries worth setting deliberately, because crossing them is the most common way this becomes unpleasant.
Terraform creates machines. Configuring what runs on them belongs to Ansible or cloud-init, not to a growing pile of remote_exec provisioners, which run once at create time and are invisible to later plans. And the Proxmox cluster itself, the nodes, Ceph, Corosync, the network, is not Terraform's to manage: the provider can touch some of it, and a mistaken plan against your storage configuration is a different class of incident from a mistaken plan against a VM.
Manage guests with Terraform. Manage the cluster with the tools built for it, and by hand where that is safer. Our runbook for maintenance without downtime covers the cluster side, and cluster sizing covers the capacity decisions that no provider will make for you.
Or an API That Was Built for This
The friction above exists because Proxmox is a hypervisor manager rather than a cloud platform, and the provider bridges that gap with community effort.
MassiveGRID runs Proxmox high-availability clustering with automatic failover over Ceph storage replicating every block three times across independent NVMe drives, and provisioning is handled for you: a Linux VPS at $2.87 per CPU core, $0.80 per GB of RAM and $0.01 per GB of SSD per month arrives without a template to build. A private cloud is where your own Terraform belongs if you want that control, and Proxmox support from $99 per node per month covers the automation design for a cluster you run yourself.
Infrastructure can be ordered across a partner footprint of more than 700 datacenters in 85 metros, 30 countries and six continents, with auto-provisioning in New York, London, Frankfurt and Singapore.