Provision Proxmox VMs with Terraform and Cloud-Init
Provision repeatable, Git-versioned Proxmox VMs using Terraform and the bpg/proxmox provider. Get a full cloud-init workflow up and running in under an hour.
On this page
Terraform turns Proxmox VM provisioning from a point-and-click ritual into a git commit. Using the bpg/proxmox provider — the actively maintained successor to the Telmate fork, compatible with Proxmox VE 8 and 9 — you declare every VM's CPU, memory, disk, and cloud-init config in HCL, then terraform apply handles the rest: cloning the template, injecting SSH keys, and booting the VM. By the end of this guide you'll have a working provider setup, a reusable Ubuntu 24.04 cloud-init template, and a multi-VM configuration you can version-control and reproduce on any Proxmox node.
Key Takeaways
- Provider: Use
bpg/proxmoxv0.66+ on the Terraform Registry, not the unmaintained Telmate fork — it fully supports Proxmox VE 9.x - Auth: Create a dedicated
terraform@pveAPI token with minimal permissions — never storeroot@pamcredentials in state - Template first: Terraform clones a cloud-init template but won't create one — build the base image before running
apply - Speed: Expect 45–90 seconds per VM on NVMe storage; 3–5 minutes on spinning disk
- Import:
terraform importbrings existing manually-created VMs under IaC control without destroying them
Prerequisites
This guide targets Proxmox VE 9.0 or 9.1 with Terraform 1.9+ on your workstation. The bpg/proxmox provider communicates with both the Proxmox REST API (port 8006) and SSH (port 22), so your workstation needs network access to both.
Install Terraform on Debian/Ubuntu:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform version
Creating the Proxmox API Token
Never store root@pam credentials in a Terraform state file — they end up in plaintext in terraform.tfstate. Create a dedicated user with a scoped role instead:
# Run on the Proxmox host as root
pveum user add terraform@pve --comment "Terraform automation"
pveum role add TerraformRole -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.PowerMgmt Datastore.AllocateSpace Datastore.Audit Pool.Allocate Sys.Audit"
pveum aclmod / -user terraform@pve -role TerraformRole
pveum user token add terraform@pve terraform --privsep 0
The last command prints the token secret exactly once:
full-tokenid: terraform@pve!terraform
value: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Copy it immediately. The --privsep 0 flag means the token inherits the user's full role permissions rather than requiring you to set them again on the token separately.
Configuring the bpg/proxmox Provider
Create a new directory for your Terraform workspace:
mkdir ~/proxmox-terraform && cd ~/proxmox-terraform
versions.tf:
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.66"
}
}
required_version = ">= 1.9"
}
provider.tf:
provider "proxmox" {
endpoint = "https://192.168.1.10:8006/"
api_token = var.proxmox_api_token
insecure = false
ssh {
agent = true
username = "root"
}
}
Set insecure = true only if your Proxmox node is still running its default self-signed certificate. For any node exposed beyond a single private VLAN, replace the cert first.
variables.tf:
variable "proxmox_api_token" {
description = "Format: user@realm!tokenid=secret"
type = string
sensitive = true
}
Store the actual token in terraform.tfvars — and protect it from Git immediately:
proxmox_api_token = "terraform@pve!terraform=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
cat >> .gitignore <<'EOF'
terraform.tfvars
*.tfstate
*.tfstate.backup
.terraform/
EOF
Building the Cloud-Init Base Template
The provider clones a template — it won't create one from scratch. Build your Ubuntu 24.04 base image directly on the Proxmox host:
# Download the Ubuntu 24.04 cloud image
wget -q https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Create the base VM (ID 9000 reserved for templates)
qm create 9000 --name ubuntu-2404-cloud --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0 --ostype l26
# Import the disk
qm importdisk 9000 noble-server-cloudimg-amd64.img local-lvm
# Wire up the disk, cloud-init drive, serial console, and guest agent
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-0,discard=on --boot order=scsi0
qm set 9000 --ide2 local-lvm:cloudinit --serial0 socket --vga serial0 --agent enabled=1
# Convert to a template — this is irreversible; VM 9000 becomes read-only
qm template 9000
VM ID 9000 is now the base for every Terraform-managed VM. If you want to automate template builds and refreshes on a schedule rather than running these commands manually, the Automate Proxmox VE with Ansible Full VM Playbooks guide has a playbook that handles exactly that.
Writing the VM Resource
Create main.tf with a single VM to validate the setup:
resource "proxmox_virtual_environment_vm" "web_01" {
name = "tf-web-01"
description = "Managed by Terraform"
node_name = "pve"
vm_id = 101
clone {
vm_id = 9000
full = true
}
cpu {
cores = 2
sockets = 1
type = "x86-64-v2-AES"
}
memory {
dedicated = 2048
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = 20
discard = "on"
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
initialization {
ip_config {
ipv4 {
address = "192.168.1.101/24"
gateway = "192.168.1.1"
}
}
user_account {
username = "ubuntu"
keys = [file("~/.ssh/id_ed25519.pub")]
}
dns {
servers = ["1.1.1.1", "8.8.8.8"]
}
}
operating_system {
type = "l26"
}
started = true
}
A few decisions worth explaining:
cpu.type = "x86-64-v2-AES"gives meaningfully better AES-NI performance than the defaultkvm64and keeps live migration safe across nodes with the same CPU generation. Usehostonly if you're certain you'll never migrate.full = trueon the clone creates an independent disk copy. Linked clones are faster to provision but will break if you ever regenerate or delete the template — not worth the risk.initializationmaps directly to cloud-init. Thekeysfield injects your SSH public key so you can log in the moment cloud-init finishes, roughly 20 seconds after first boot.
Scaling to Multiple VMs with for_each
One VM validates the config. The real value comes from provisioning a fleet from a single resource block. Extend variables.tf:
variable "vms" {
type = map(object({
vm_id = number
ip = string
cores = number
memory = number
}))
}
Add your VM definitions to terraform.tfvars:
vms = {
"tf-web-01" = { vm_id = 101, ip = "192.168.1.101/24", cores = 2, memory = 2048 }
"tf-web-02" = { vm_id = 102, ip = "192.168.1.102/24", cores = 2, memory = 2048 }
"tf-db-01" = { vm_id = 201, ip = "192.168.1.201/24", cores = 4, memory = 8192 }
}
Replace the single resource in main.tf with a for_each version:
resource "proxmox_virtual_environment_vm" "vms" {
for_each = var.vms
name = each.key
node_name = "pve"
vm_id = each.value.vm_id
clone {
vm_id = 9000
full = true
}
cpu {
cores = each.value.cores
sockets = 1
type = "x86-64-v2-AES"
}
memory {
dedicated = each.value.memory
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = 20
discard = "on"
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
initialization {
ip_config {
ipv4 {
address = each.value.ip
gateway = "192.168.1.1"
}
}
user_account {
username = "ubuntu"
keys = [file("~/.ssh/id_ed25519.pub")]
}
}
operating_system {
type = "l26"
}
started = true
}
terraform apply provisions all three VMs in parallel. On NVMe-to-NVMe cloning, expect all three booted and SSH-accessible within three minutes total.
Running Terraform
# Download the bpg/proxmox provider binary (~35 MB)
terraform init
# Preview — makes no changes, safe to run at any time
terraform plan -out=tfplan
# Apply the saved plan
terraform apply tfplan
The provider polls the Qemu guest agent for the VM's IP before marking the resource complete, so terraform apply won't return until the VM is fully booted and the agent is responding. The default timeout is 15 minutes — on slow storage you may need to increase it with a timeouts block in the resource.
Day-2 Operations
# Resize tf-web-01 to 4 cores — update tfvars, then:
terraform apply
# Destroy one VM without touching others
terraform destroy -target='proxmox_virtual_environment_vm.vms["tf-web-01"]'
# Import an existing manually-created VM (ID 150) into state
terraform import 'proxmox_virtual_environment_vm.vms["legacy-app"]' pve/150
# Inspect current tracked state
terraform show
The terraform import path is how you migrate a homelab that started with hand-crafted VMs into full IaC. Add the matching resource block to main.tf first — import writes state but won't generate HCL for you.
One hard limit: Proxmox cannot shrink a virtual disk, and neither can the provider. Reducing disk.size in config produces a permanent plan diff with no resolution other than destroy-and-recreate. Size up only.
Common Gotchas
Node name case sensitivity: node_name = "PVE" and node_name = "pve" are different strings to the API. Run pvesh get /nodes --output-format json to see the exact value Proxmox expects — copy it verbatim.
VM ID collisions: Reserve a specific ID range for Terraform (e.g., 100–299) and never create manual VMs in that range. If Terraform tries to create VM 101 and it already exists, apply fails with a 500 error that can leave partial state.
SSH key injection timing: Cloud-init takes 15–30 seconds to complete on first boot. Connecting immediately after apply returns may find the SSH key not yet written. Build a 30-second wait or use Terraform's remote-exec provisioner with a retry loop.
Template must exist before apply: If VM 9000 doesn't exist, terraform apply fails instantly. There is no way to make Terraform create the template — that step is always manual or Ansible-driven.
When Terraform Is Worth It
| Scenario | Use Terraform | Reason |
|---|---|---|
| 5+ identical VMs (web farm, k8s nodes) | Yes | for_each eliminates repetition |
| One-off dev VM you'll delete in hours | No | Web UI is faster |
| Team-managed production infra | Yes | Git history + PR reviews for every change |
| Pure lab experimentation | No | State drift is constant friction |
| CI/CD ephemeral test environments | Yes | Fully disposable, fully automated |
| Kubernetes cluster nodes | Yes | Pairs cleanly with k3s or Talos provisioning |
This approach slots naturally into a broader software-defined architecture. If you're thinking at that scale, Build a Software-Defined Datacenter with Proxmox VE covers how the network, storage, and compute layers fit together — Terraform handles the VM provisioning layer on top of that foundation.
Conclusion
With the bpg/proxmox provider and a single cloud-init template, you get reproducible, Git-versioned VM infrastructure that your whole team can review before anything changes on the cluster. The first-time setup takes about 45 minutes; every VM after that is a tfvars line and 90 seconds of apply time. If you're building this into a larger homelab stack, Build a Private Cloud at Home with Proxmox VE is the logical next read — it covers the network and storage foundation that makes Terraform-managed VMs actually useful at scale.