Skip to main content
RunBook Academy

TerraformXXVI · Cloud and Platform OperationsProduction Terraform

Terraform with Proxmox

Intermediate⏱ ~18 minbash

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

Not yet marked complete on this device.

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/proxmoxTelmate/proxmox
VM resourceproxmox_virtual_environment_vmproxmox_vm_qemu
Container resourceproxmox_virtual_environment_containerproxmox_lxc
Node argumentnode_nametarget_node
Guest agentagent block with enabledagent integer
Disk sizingsize as a number of GBsize 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:

  1. node_name, not target_node. This is the single most common paste error from Telmate/proxmox material.
  2. full = true makes a full clone: the VM owns its own disk and survives the template being deleted. full = false is a linked clone — faster to create and smaller on disk, but permanently dependent on the template. Use full clones for anything you would miss.
  3. size is a number of gigabytes, not a string. size = 32, never size = "32G".
  4. interface identifies the disk, so every disk block 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

  1. Resource names from the other provider. The configuration requires bpg/proxmox and declares proxmox_vm_qemu, or the reverse. terraform init fails 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.
  2. insecure = true left 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 set insecure = false.
  3. Token secret committed to the repository. With --privsep 0 on an Administrator user this is full cluster access. Revoke with pveum user token remove, issue a new one, and review the task log (pvesh get /cluster/tasks) for what was done with it.
  4. 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. Use full = true for anything you would miss.
  5. 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.
  6. ZFS pool filling up. local-zfs fills as snapshots and backups accumulate, and a full pool wedges every guest on it. Set a prune-backups retention policy on the backup storage and alert on zpool list capacity 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/proxmox is 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 = false is 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 vmbr0 takes the cluster out of reach of the runner that would fix it.
  • Run with insecure = true because 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/proxmox resource types for a VM and a container, and the Telmate/proxmox names 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_vm that clones a template, sets cloud-init through initialization, 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

  1. Q1. A configuration has `source = "bpg/proxmox"` in required_providers and a `resource "proxmox_vm_qemu" "web"` block. What happens?

  2. Q2. Which argument names the Proxmox node in a `proxmox_virtual_environment_vm` resource?

  3. Q3. Which of the following belong in a production Proxmox plus Terraform setup? (Select all that apply.)

  4. Q4. A linked clone (clone { full = false }) is appropriate for production VMs.

  5. Q5. How is membership of a `proxmox_virtual_environment_pool` declared?

  6. Q6. In `disk { size = 32 }` on a bpg/proxmox VM, what does 32 mean?

  7. 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.