TerraformXXVI · Cloud and Platform OperationsProduction Terraform
Terraform with Proxmox
What you'll learn
- Configure the bpg/proxmox provider with an API token, TLS verification, and an SSH block
- Provision VMs and LXC containers with proxmox_virtual_environment_vm and proxmox_virtual_environment_container
- Model a small cluster with a pool, a datastore, and a network bridge
- Use dynamic blocks and for_each to scale a fleet from one resource block
- Recognise that the two Proxmox providers have disjoint resource names and must never be mixed
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-18
Proxmox VE is a widely deployed open-source virtualisation platform for homelabs, small businesses, and edge sites. Terraform can drive it, but the first decision you make is the one that breaks most configurations: which Proxmox provider you are using.
Two providers, no overlap
There are two community Terraform providers for Proxmox VE, and they share nothing but the word “proxmox”:
bpg/proxmox | Telmate/proxmox | |
|---|---|---|
| VM resource | proxmox_virtual_environment_vm | proxmox_vm_qemu |
| Container resource | proxmox_virtual_environment_container | proxmox_lxc |
| Node argument | node_name | target_node |
| Guest agent | agent block with enabled | agent integer |
| Disk sizing | size as a number of GB | size as a string such as "32G" |
Every resource name, every argument name, and every nested block
differs. A configuration that declares one provider in
required_providers and then uses the other provider’s resource
names will fail at terraform init, because the resource type it
references belongs to a provider the configuration never required.
This lesson uses bpg/proxmox throughout. Every resource and
argument below belongs to that provider.
The provider block
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.66"
}
}
backend "s3" {
bucket = "homelab-tfstate"
key = "platform/proxmox.tfstate"
region = "eu-west-2"
dynamodb_table = "homelab-tfstate-lock"
encrypt = true
}
}
bpg/proxmox is on a 0.x version line, so the pessimistic
constraint ~> 0.66 allows 0.66.x patches and nothing else. That
is the right shape for a pre-1.0 provider: on a 0.x line the minor
number is where breaking changes land, so a constraint that lets the
minor float will eventually break a plan. Pin to the minor you
tested and read the changelog before you raise it.
The S3 plus DynamoDB backend is the same shape as the AWS pattern. Even a homelab benefits from remote state with locking; a local state on a single laptop is one failed disk away from being lost.
Connecting to Proxmox VE
The provider talks to the Proxmox REST API over HTTPS and authenticates with an API token: a token identifier scoped to a user, plus a secret shown once at creation.
Step 1: create the API token
Use the pve realm rather than pam so the automation identity does
not need a Unix account on the host:
# CONFIGURATION: run on a Proxmox node, as root
pveum user add terraform@pve
pveum acl modify / --user terraform@pve --role Administrator
pveum user token add terraform@pve provisioner --privsep 0
pveum user token add prints the secret exactly once:
┌──────────────┬──────────────────────────────────────┐
│ key │ value │
╞══════════════╪══════════════════════════════════════╡
│ full-tokenid │ terraform@pve!provisioner │
├──────────────┼──────────────────────────────────────┤
│ info │ {"privsep":"0"} │
├──────────────┼──────────────────────────────────────┤
│ value │ REDACTED-UUID │
└──────────────┴──────────────────────────────────────┘
--privsep 0 means the token inherits the user’s permissions. With
--privsep 1 (the default) the token starts with no permissions and
needs its own ACL entries, which is the tighter model but one more
thing to keep in sync. --role Administrator above is the blunt
grant; scope it down to the privileges the configuration actually
uses once the estate is stable.
The secret is not recoverable from the Proxmox side. Store it in a secrets manager and inject it as an environment variable.
Step 2: configure the provider
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token
insecure = false
ssh {
agent = true
username = "root"
}
}
variable "proxmox_endpoint" {
type = string
description = "Proxmox VE API endpoint, e.g. https://pve.example.com:8006/"
}
variable "proxmox_api_token" {
type = string
sensitive = true
description = "Token in the form USER@REALM!TOKENID=SECRET"
}
The endpoint is the base URL of the node or cluster, including the
port: https://pve.example.com:8006/. The provider appends the API
path itself.
The api_token string is the full token, in the form
USER@REALM!TOKENID=SECRET — for the token created above,
terraform@pve!provisioner= followed by the secret. In CI, prefer
the provider’s environment variables so the value never reaches a
.tfvars file:
export PROXMOX_VE_ENDPOINT="https://pve.example.com:8006/"
export PROXMOX_VE_API_TOKEN="terraform@pve!provisioner=$TOKEN_SECRET"
insecure = false is the production setting: it makes the provider
verify the API server’s TLS certificate. Proxmox VE ships a
self-signed certificate, so a fresh install fails that check. The fix
is to install a certificate the runner trusts — an internal CA, or a
public one through the Proxmox ACME integration — not to set
insecure = true.
The ssh block is used for the operations the REST API does not
cover, which in this provider means uploading files to a node’s
datastore. agent = true uses the local SSH agent. If the runner has
no agent, supply private_key instead.
Resource model
+-------------------------------------+
| Cluster |
+-------------------------------------+
| Node pve-01 Node pve-02 |
| +- VM 201 (web) +- VM 203 (web)|
| +- VM 202 (db) +- CT 211 (dns)|
| |
| Datastores: local-zfs, nfs-backup |
| Bridges: vmbr0, vmbr1 |
| Pools: prod, dev |
+-------------------------------------+
The bpg/proxmox resources you will use most:
proxmox_virtual_environment_vm— a QEMU/KVM virtual machine.proxmox_virtual_environment_container— an LXC container.proxmox_virtual_environment_pool— a resource pool, used to attach one ACL to a group of guests.proxmox_virtual_environment_file— a file uploaded to a datastore, such as a cloud-init snippet.proxmox_virtual_environment_download_file— a file fetched onto a node by URL, such as an ISO or a container template.
Note the naming rule: every resource in this provider is prefixed
proxmox_virtual_environment_. A resource name without that prefix
belongs to the other provider.
A pool
resource "proxmox_virtual_environment_pool" "prod" {
pool_id = "prod"
comment = "Production workloads"
}
A pool takes pool_id and comment. It does not take a membership
list — the members attribute is read-only, populated by the
provider from whatever guests point at the pool. Membership is
declared on the guest, with pool_id, which keeps the dependency
edge pointing the way Terraform can order it.
Pools are how the permission model scales. A developer user can be
granted PVEVMUser on dev and nothing else. Without pools, every
grant is per-guest.
Provisioning a VM
The standard pattern is to build one template on the node — a cloud image with cloud-init installed, converted to a VM and marked as a template — and clone it from Terraform.
variable "ubuntu_template_vm_id" {
type = number
description = "VM ID of the cloud-init Ubuntu template on pve-01"
}
resource "proxmox_virtual_environment_vm" "web" {
for_each = toset(["01", "02"])
name = "web-${each.key}"
node_name = "pve-01"
vm_id = 200 + tonumber(each.key)
pool_id = proxmox_virtual_environment_pool.prod.pool_id
on_boot = true
clone {
vm_id = var.ubuntu_template_vm_id
full = true
}
agent {
enabled = true
}
cpu {
cores = 2
sockets = 1
type = "host"
}
memory {
dedicated = 2048
}
disk {
datastore_id = "local-zfs"
interface = "scsi0"
size = 32
discard = "on"
iothread = true
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
initialization {
datastore_id = "local-zfs"
ip_config {
ipv4 {
address = "10.20.30.${100 + tonumber(each.key)}/24"
gateway = "10.20.30.1"
}
}
user_account {
username = "ubuntu"
keys = [trimspace(file("~/.ssh/id_ed25519.pub"))]
}
}
operating_system {
type = "l26"
}
}
Four things worth reading twice:
node_name, nottarget_node. This is the single most common paste error fromTelmate/proxmoxmaterial.full = truemakes a full clone: the VM owns its own disk and survives the template being deleted.full = falseis a linked clone — faster to create and smaller on disk, but permanently dependent on the template. Use full clones for anything you would miss.sizeis a number of gigabytes, not a string.size = 32, neversize = "32G".interfaceidentifies the disk, so everydiskblock on a VM needs a distinct one (scsi0,scsi1,virtio0, and so on).
LXC containers
resource "proxmox_virtual_environment_container" "dns" {
for_each = toset(["01", "02"])
node_name = "pve-01"
vm_id = 210 + tonumber(each.key)
pool_id = proxmox_virtual_environment_pool.prod.pool_id
unprivileged = true
start_on_boot = true
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 256
}
disk {
datastore_id = "local-zfs"
size = 8
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
initialization {
hostname = "dns-${each.key}"
ip_config {
ipv4 {
address = "10.20.30.${150 + tonumber(each.key)}/24"
gateway = "10.20.30.1"
}
}
user_account {
keys = [trimspace(file("~/.ssh/id_ed25519.pub"))]
}
}
operating_system {
template_file_id = "local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst"
type = "ubuntu"
}
features {
nesting = true
}
}
The container template must already exist on the datastore. Either
pull it on the node with pveam download local <template>, or
declare it as a proxmox_virtual_environment_download_file resource
so Terraform owns it too.
Containers provision faster than VMs and use less memory, but they
share the host kernel. unprivileged = true maps container root to
an unprivileged host UID and removes most of the escape surface; it
does not remove all of it. For anything holding production data,
treat a container as the same trust boundary as a VM.
Dynamic blocks and the fleet pattern
disk is a repeatable block, so a VM with a variable number of data
disks is the natural case for dynamic:
variable "data_disks" {
type = map(number)
description = "Disk interface name => size in GB"
default = {
scsi1 = 100
scsi2 = 500
}
}
resource "proxmox_virtual_environment_vm" "db" {
name = "db-01"
node_name = "pve-01"
vm_id = 202
pool_id = proxmox_virtual_environment_pool.prod.pool_id
clone {
vm_id = var.ubuntu_template_vm_id
full = true
}
cpu {
cores = 4
}
memory {
dedicated = 8192
}
# The boot disk, always present.
disk {
datastore_id = "local-zfs"
interface = "scsi0"
size = 32
}
# Data disks, generated from the variable.
dynamic "disk" {
for_each = var.data_disks
content {
datastore_id = "local-zfs"
interface = disk.key
size = disk.value
discard = "on"
}
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
}
Inside dynamic "disk", the iterator is named after the block, so
disk.key is the map key and disk.value is the map value. Because
the map key is the interface name, adding or removing a data disk
changes only the disk it names — the identity of a disk is its
interface, not its position in the file.
How to validate
# READ-ONLY: confirm exactly one Proxmox provider is required
terraform providers
# READ-ONLY: parse and type-check the configuration
terraform validate
# READ-ONLY: confirm the token can reach the API and read the cluster
pvesh get /cluster/resources --type vm
terraform providers is the check that catches the mixed-provider
mistake: if the output lists a resource type the required provider
does not own, init will already have failed, and the output tells
you which address is wrong.
# READ-ONLY against the cluster: proposes changes, applies none
terraform plan -out=tfplan
terraform show tfplan
Once the estate has settled, drift detection is the standard pair:
# CONFIGURATION: writes the refreshed values into state
terraform apply -refresh-only
# READ-ONLY: exit 0 means no drift, exit 2 means drift
terraform plan -detailed-exitcode
Production failure modes
- Resource names from the other provider. The configuration
requires
bpg/proxmoxand declaresproxmox_vm_qemu, or the reverse.terraform initfails because no required provider supplies that resource type. Fix the resource names to match the provider you actually required; do not add the second provider. insecure = trueleft on. The provider stops verifying the API certificate, so anything on the path between the runner and port 8006 can read the API token out of the request. Install a certificate the runner trusts and setinsecure = false.- Token secret committed to the repository. With
--privsep 0on anAdministratoruser this is full cluster access. Revoke withpveum user token remove, issue a new one, and review the task log (pvesh get /cluster/tasks) for what was done with it. - Linked clone whose template was deleted. A VM created with
clone { full = false }shares disk extents with the template. Removing the template takes the clone with it. Usefull = truefor anything you would miss. - Single-host Proxmox described as “production”. One host running the guests is not high availability; a host failure is a full outage, and Terraform cannot recreate what has nowhere to run. The minimum shape that survives a host failure is three nodes with shared or replicated storage.
- ZFS pool filling up.
local-zfsfills as snapshots and backups accumulate, and a full pool wedges every guest on it. Set aprune-backupsretention policy on the backup storage and alert onzpool listcapacity well before it is full.
What to do in production (and what NOT to do)
For a homelab or small-business install:
- Pick one provider and record the choice. This lesson uses
bpg/proxmox;Telmate/proxmoxis a defensible choice for an existing estate already written against it. - Pin the provider to a minor version and commit
.terraform.lock.hcl. - Use remote state with locking, not local state.
- Install a real certificate so
insecure = falseis possible. - Use full clones for anything whose loss would matter.
- Use a pool per environment and grant permissions on the pool.
For an install that has to survive a host failure:
- Three nodes, not one. Proxmox VE needs an odd number for quorum.
- Shared or replicated storage — Ceph, or ZFS replication — so a guest can start on a surviving node.
- Cluster management traffic on its own VLAN, away from guest traffic.
- Terraform provisions the guests; a configuration management tool configures what runs inside them. The boundary is the same one you draw on any cloud.
Do NOT do this in production:
- Mix the two Proxmox providers, or paste an example without checking which provider wrote it.
- Let Terraform manage the cluster’s own bridges and datastores. A
bad apply that removes
vmbr0takes the cluster out of reach of the runner that would fix it. - Run with
insecure = truebecause the certificate is inconvenient. - Treat a single host as an environment.
Verification
After working through this lesson, confirm the following:
- You can name the
bpg/proxmoxresource types for a VM and a container, and theTelmate/proxmoxnames they are confused with. - You can configure the provider with an API token, TLS verification, and an SSH block.
- You can write a
proxmox_virtual_environment_vmthat clones a template, sets cloud-init throughinitialization, and joins a pool. - You can explain the difference between a full clone and a linked clone, and when each is appropriate.
- You can name two reasons a single-host Proxmox install is not a production substitute for a cloud.
Knowledge check · 7 questions
Q1. A configuration has `source = "bpg/proxmox"` in required_providers and a `resource "proxmox_vm_qemu" "web"` block. What happens?
Q2. Which argument names the Proxmox node in a `proxmox_virtual_environment_vm` resource?
Q3. Which of the following belong in a production Proxmox plus Terraform setup? (Select all that apply.)
Q4. A linked clone (clone { full = false }) is appropriate for production VMs.
Q5. How is membership of a `proxmox_virtual_environment_pool` declared?
Q6. In `disk { size = 32 }` on a bpg/proxmox VM, what does 32 mean?
Q7. A homelab runs a single Proxmox host with all production VMs managed by Terraform. The host's motherboard fails. What is the impact?
Passing score: 75%. Answers are checked in this browser.