Clicking through the Proxmox VE web UI to create a virtual machine is fine the first time. It stops being fine the tenth time — when you need to spin up an identical staging environment, rebuild a lab after testing something destructive, or hand a repeatable process to a teammate who wasn't in the room when you configured the original VM. The GUI doesn't remember what you did, and a shell script full of qm create commands only gets you partway there: it doesn't know what already exists, doesn't clean up safely, and drifts the moment someone changes something by hand.
Terraform (and its open-source fork OpenTofu) solves a specific piece of that problem: you describe the VMs, containers, and storage you want in declarative configuration files, and the tool figures out what needs to be created, changed, or destroyed to match that description. Run the same configuration twice and nothing happens the second time, because nothing changed. Change one number — say, memory from 2048 to 4096 — and only that VM's memory gets touched.
This guide covers bpg/proxmox, the actively maintained community Terraform/OpenTofu provider for Proxmox VE (not to be confused with the older, largely unmaintained Telmate/proxmox provider that still shows up in outdated tutorials). You'll set up authentication, provision both a cloned VM and an LXC container, manage state safely, import machines that already exist, and work through the handful of errors that trip up almost everyone the first time they connect Terraform to a Proxmox cluster.
What You Will Learn
- How the Terraform workflow (write, plan, apply) maps onto Proxmox VE concepts
- Creating a scoped API token dedicated to Terraform, rather than reusing
root@pam - Configuring the
bpg/proxmoxprovider, including TLS and endpoint settings - Cloning a cloud-init VM template into a fully configured virtual machine
- Provisioning an LXC container declaratively
- Managing multiple similar guests with variables and
for_each - Where to keep Terraform state safely, and why local state is a trap for teams
- Importing VMs and containers that already exist into Terraform's state
- Diagnosing the errors that come up most often — timeouts, ID conflicts, and permission failures
Prerequisites
- A working Proxmox VE 9.x cluster or standalone host, reachable over HTTPS
- Administrative access to create users, roles, and API tokens (typically via
root@pam) - Terraform 1.5+ or OpenTofu 1.6+ installed on the machine you'll run it from
- A cloud-init-enabled VM template already built in Proxmox VE, if you plan to follow the VM cloning section (see Proxmox's own cloud-init template workflow if you don't have one yet)
- Basic familiarity with HCL (Terraform's configuration language) is helpful but not required — every example below is complete and explained line by line
Why Not the Telmate Provider?
If you search for "Terraform Proxmox provider," you'll find two names: Telmate/proxmox and bpg/proxmox. Telmate's provider was the default choice for years, but it has seen long stretches without updates, doesn't track newer Proxmox VE API changes well, and has known issues with cloud-init drift and resource re-creation. bpg/proxmox, maintained largely by a single very active maintainer, has become the de facto standard: it's updated frequently, tracks current Proxmox VE releases (including 9.x), and models resources — VMs, containers, storage, ACLs — more completely and more faithfully to how the underlying API actually behaves. Every example in this guide uses bpg/proxmox.
Step 1: Create a Dedicated API Token for Terraform
Terraform needs credentials to talk to the Proxmox API, and reusing your personal root@pam login works but is a bad habit: it's tied to a human account, it's all-or-nothing, and revoking it means changing your own login. Instead, create a service account and a role scoped to what Terraform actually needs to do — create, modify, and destroy VMs, containers, and their disks.
pveum user add terraform@pve --comment "Service account for Terraform"
pveum role add TerraformProv -privs "VM.Allocate VM.Clone VM.Config.CDROM VM.Config.CPU VM.Config.Cloudinit VM.Config.Disk VM.Config.Memory VM.Config.Network VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Pool.Allocate SDN.Use"
pveum aclmod / -user terraform@pve -role TerraformProv
pveum user token add terraform@pve provider --privsep 0
The last command prints a token secret exactly once — copy it immediately, since Proxmox does not store or redisplay it. The --privsep 0 flag disables privilege separation for this token, meaning it inherits the same permissions as the terraform@pve user directly; this is the simpler option for a single-purpose automation token. If you'd rather scope the token itself more tightly than the user, drop that flag and grant the ACL to the token's own terraform@pve!provider identity instead.
You should end up with a token ID that looks like terraform@pve!provider and a secret in UUID format. Store the secret somewhere other than your Terraform files — an environment variable or your shell's secret manager, not a committed .tf file.
Step 2: Configure the Provider
Create a project directory and a versions.tf file that pins the provider to a known-good release:
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.111"
}
}
}
Pinning matters more with this provider than with most — it ships frequent releases, and occasionally a minor version changes default behavior for a resource. Locking to a compatible range keeps terraform init from silently pulling in a version that changes how your existing resources behave.
Next, configure the provider itself in provider.tf:
provider "proxmox" {
endpoint = "https://192.168.1.10:8006/"
api_token = var.proxmox_api_token
insecure = true # set false once you have a trusted certificate installed
ssh {
agent = false
username = "root"
}
}
The endpoint is the base API URL — no /api2/json suffix, no trailing path. The ssh block matters more than it looks: a handful of operations (notably some file uploads and certain cloud-init snippet workflows) go through SSH rather than the REST API, so the provider needs a way to reach the node directly. If you're using key-based SSH auth for root, this default works as-is; if you use a different user or an SSH agent, adjust accordingly.
Declare the token as a variable rather than hardcoding it:
variable "proxmox_api_token" {
type = string
sensitive = true
}
Then supply the value at runtime through an environment variable, never inside the .tf files themselves:
export TF_VAR_proxmox_api_token="terraform@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Run terraform init now. It downloads the provider plugin and sets up the local .terraform directory. If it completes without errors, authentication and connectivity are both working.
Step 3: Clone a VM From a Cloud-Init Template
With the provider connected, define a VM resource that clones an existing cloud-init template — this assumes you already have a template (for example, one built from an Ubuntu or Debian cloud image) sitting at a known VM ID on the target node.
resource "proxmox_virtual_environment_vm" "web01" {
name = "web01"
node_name = "pve1"
vm_id = 8001
clone {
vm_id = 9000 # the template's VM ID
full = true
}
cpu {
cores = 2
type = "x86-64-v2-AES"
}
memory {
dedicated = 2048
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = 20
}
network_device {
bridge = "vmbr0"
}
agent {
enabled = true
timeout = "5m"
}
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")]
}
}
}
A few details matter here. Setting full = true on the clone block creates an independent full clone rather than a linked clone tied to the template's disk chain — worth the extra disk space for anything you plan to keep long-term, since destroying the template later won't break clones. The agent block with enabled = true tells Terraform to wait for the QEMU guest agent to report an IP address before considering the VM "created," which only works if the template already has qemu-guest-agent installed and enabled. The initialization block is where cloud-init settings live — static IP here, but swap in address = "dhcp" if the network hands out addresses automatically.
Run terraform plan to see what Terraform intends to do, then terraform apply to actually create the VM. Terraform shows a diff-style summary before asking for confirmation — read it, especially the first time, since it tells you exactly which attributes are new versus inherited from the template.
Step 4: Provision an LXC Container
Containers follow a similar shape but use a distinct resource type and reference an OS template file rather than a cloned VM:
resource "proxmox_virtual_environment_container" "app01" {
node_name = "pve1"
vm_id = 8101
operating_system {
template_file_id = "local:vztmpl/ubuntu-24.04-standard_24.04-1_amd64.tar.zst"
type = "ubuntu"
}
cpu {
cores = 2
}
memory {
dedicated = 1024
}
disk {
datastore_id = "local-lvm"
size = 8
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
initialization {
hostname = "app01"
ip_config {
ipv4 {
address = "dhcp"
}
}
user_account {
keys = [file("~/.ssh/id_ed25519.pub")]
}
}
unprivileged = true
}
The template_file_id has to reference a template that's already downloaded onto that storage (via the GUI's "CT Templates" download button, or pveam download local ubuntu-24.04-standard_24.04-1_amd64.tar.zst). Terraform doesn't fetch OS templates for you — it only references what's already present. Leaving unprivileged = true is the safer default and matches what the GUI does unless you have a specific reason for a privileged container.
Managing Multiple Similar Guests
Writing out a full resource block per VM works for two or three machines, but a fleet of near-identical workers is a better fit for for_each over a variable:
variable "workers" {
type = map(object({
vm_id = number
ip = string
}))
default = {
worker01 = { vm_id = 8011, ip = "192.168.1.111/24" }
worker02 = { vm_id = 8012, ip = "192.168.1.112/24" }
worker03 = { vm_id = 8013, ip = "192.168.1.113/24" }
}
}
resource "proxmox_virtual_environment_vm" "workers" {
for_each = var.workers
name = each.key
node_name = "pve1"
vm_id = each.value.vm_id
clone {
vm_id = 9000
full = true
}
cpu { cores = 2 }
memory { dedicated = 4096 }
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = 40
}
network_device {
bridge = "vmbr0"
}
initialization {
ip_config {
ipv4 {
address = each.value.ip
gateway = "192.168.1.1"
}
}
}
}
Adding a fourth worker means adding one entry to the map and running terraform apply — Terraform creates only the new resource and leaves the existing three untouched. Removing an entry destroys just that one VM. This is the pattern to reach for whenever you notice you're copy-pasting a resource block with only a name and an IP changed.
Where State Lives — And Why It Matters
Terraform tracks what it created in a state file, terraform.tfstate, written to your local directory by default. That's fine for solo experimentation and disastrous for anything else: the state file contains resource IDs, and in some configurations, sensitive values in plain text. If it's just sitting on your laptop, a second person running Terraform from their own machine has no idea those VMs already exist and will try to recreate them — or worse, two people applying from stale, divergent state files can genuinely conflict and corrupt tracked resources.
Two things fix this. First, never commit terraform.tfstate or terraform.tfstate.backup to version control — add both to .gitignore immediately, before your first commit. Second, for anything beyond solo use, move state to a remote backend with locking, such as an S3-compatible bucket (MinIO works fine for a home lab) or Terraform Cloud:
terraform {
backend "s3" {
bucket = "terraform-state"
key = "proxmox/production.tfstate"
region = "us-east-1"
endpoints = {
s3 = "https://minio.example.lan:9000"
}
skip_credentials_validation = true
skip_requesting_account_id = true
use_path_style = true
}
}
A remote backend with locking means two people (or two CI runs) can't apply conflicting changes at the same time — the second one blocks until the first finishes.
Importing Existing VMs Into Terraform
Most people don't start from a blank Proxmox cluster — you already have VMs created through the GUI, and you want Terraform to manage them going forward without destroying and recreating them. The provider supports import blocks (Terraform 1.5+):
import {
to = proxmox_virtual_environment_vm.web01
id = "pve1/8001"
}
The ID format is <node_name>/<vm_id>. Run terraform plan -generate-config-out=generated.tf, and Terraform will connect to the existing VM, read its current configuration, and write a matching resource block to generated.tf for you to review and clean up. This generated config is rarely perfect on the first pass — expect to trim unnecessary attributes and reorganize it into your own file structure — but it saves you from hand-transcribing every disk, network, and cloud-init setting from the GUI.
Once the resource block matches the real VM closely enough that terraform plan shows no changes, the import is done: Terraform now owns that VM, and future edits to the .tf file will modify the real thing instead of recreating it.
Common Errors and How to Fix Them
"Timeout waiting for VM to become ready" or agent timeouts
This almost always means the QEMU guest agent isn't installed or isn't enabled inside the template you cloned from. Either install and enable qemu-guest-agent in the source template before converting it, or set agent { enabled = false } and drop reliance on agent-reported IPs if you're using static addressing anyway.
"VM 8001 already exists"
You've picked a vm_id that's already in use on that node — Proxmox VE IDs are unique per cluster, not per node. Check existing IDs with pvesh get /cluster/resources --type vm before hardcoding one, or omit vm_id entirely and let the provider request the next free ID automatically.
401 or 403 errors from the API
Almost always a permissions gap on the token's role, not a bad secret. Re-check the ACL assignment with pveum acl list / and confirm the role actually includes the privilege the failing operation needs — cloning, for instance, requires VM.Clone on the source template's path as well as VM.Allocate on the destination.
x509 certificate errors
Proxmox VE's default self-signed certificate will fail TLS verification. insecure = true is the quick fix for a lab, but for anything you care about, install a proper certificate (an internal CA or a Let's Encrypt certificate via ACME, both configurable in the Proxmox web UI) and set insecure = false instead of leaving verification permanently disabled.
Disk size drift on every plan
If terraform plan keeps showing a disk size change that never actually applies cleanly, check whether the disk was resized outside Terraform (through the GUI, for example) after being created. Terraform's state reflects what it last wrote, not the live value — run terraform apply -refresh-only to reconcile state with the real disk size, or accept the drift into your configuration going forward.
Best Practices Before You Go Further
- Keep the Terraform token separate from your personal login and scope its role to only what it provisions
- Pin the provider version and bump it deliberately, reading the changelog, rather than letting
initpull latest - Never commit state files or token secrets — use a remote backend and environment variables from day one, even solo
- Prefer full clones over linked clones for anything you'll keep past a quick test, so template changes can't affect running VMs
- Use
for_eachover a map instead of copy-pasting resource blocks for fleets of similar guests - Run
terraform planand actually read the diff before every apply — don't treat it as a formality
Frequently Asked Questions
Can I manage Proxmox Backup Server or the firewall with this provider too?
bpg/proxmox covers a growing set of resources beyond VMs and containers, including datastores, ACLs, users, roles, and some firewall rules, but coverage varies by resource — check the provider's documentation for the specific object you want to manage before assuming it's supported.
Does this work with a Proxmox VE cluster, or only a single node?
It works with clusters — node_name simply targets whichever member node should host each resource. The provider talks to the cluster's API, not to an individual node in isolation.
What happens to a VM if I delete its resource block from the configuration?
Nothing, by default — Terraform only destroys what you tell it to via terraform destroy or by removing the resource and running apply, which will show a destroy action in the plan before doing anything. Deleting the block without running apply just orphans it from state; the VM keeps running untouched.
Should I use Terraform or Ansible for Proxmox automation?
They solve different problems and pair well together: Terraform is best at provisioning — deciding what VMs and containers should exist and keeping their infrastructure-level configuration (CPU, memory, disks, network) in sync with your code. Ansible is better at configuring what runs inside those machines after they exist. A common pattern is Terraform creating the VM and outputting its IP, then handing off to an Ansible playbook for software installation and configuration.
Conclusion
Terraform doesn't replace the Proxmox web UI — it's still the right tool for one-off exploration and for looking at what's actually running. What it replaces is the fragile, undocumented process of remembering exactly how you built each VM by hand. Once your templates, tokens, and provider configuration are in place, standing up a new environment — or tearing one down completely — becomes a single command instead of an afternoon of clicking, and the configuration itself becomes the documentation of what your infrastructure is supposed to look like.