Clicking through the Proxmox web UI to build a VM is fine the first few times. It stops being fine once you’re rebuilding the same handful of VMs for the fifth time after a test, or trying to remember exactly which CPU type, disk size, and network bridge you used for a box six months ago. Terraform (or its open-source fork OpenTofu, which this article treats as interchangeable since the Proxmox providers work with either) fixes that by making the VM’s configuration a text file instead of a memory. This is not about replacing Proxmox’s own tooling, it’s about not having to rebuild a guest from memory ever again.
Why bother, specifically for a homelab
The pitch for Terraform in a company data center is usually about team collaboration and change review. None of that applies to a one-person homelab. What actually matters here is narrower:
- Reproducibility. If a VM’s entire definition lives in a
.tffile, destroying and recreating it is one command, not a sequence of remembered UI clicks. That matters more than it sounds like the first time a test VM gets corrupted and rebuilding it from scratch takes two minutes instead of fifteen. - A real diff before you break something.
terraform planshows exactly what would change before anything actually changes. Clicking “Edit” on a VM in the UI gives you no equivalent preview, you find out what you changed after you changed it. - Pairing with cloud-init templates. If you’ve already built a cloud-init-ready template (covered in this site’s cloud-init article), Terraform is the natural next layer on top: the template defines what a fresh guest looks like, Terraform defines how many of them exist and with what per-instance overrides (IP, hostname, resource sizing).
What it isn’t good for: one-off, never-repeated VMs, or anything where the honest answer is “I’ll build this once and never touch it again.” Writing Terraform for a single box you’ll never recreate is pure overhead.
Picking a provider
There are two realistic options, and they are not close in capability anymore.
bpg/proxmox(community-maintained, Terraform Registry). The actively developed option as of this writing, with broad coverage of VMs, LXCs, storage, and even some Proxmox Datacenter Manager functionality on newer Proxmox versions. This is the one to reach for by default.Telmate/proxmox. The older, long-standing provider. It still works, and a lot of existing blog posts and gists reference it, but it has lagged behind newer Proxmox API features and its VM resource schema is clunkier (nested blocks for thingsbpghandles more cleanly). Only worth picking if you’re maintaining existing Telmate-based configs already; don’t start new work on it.
Both talk to the same Proxmox REST API, not to pvesh or SSH, so whichever you pick needs an API token with the right permissions, not shell access.
Setting up the API side
Terraform needs a Proxmox API token, not your login password. Create a dedicated role rather than handing it Administrator:
- In the Proxmox UI (or via
pveum), create a role with the permissions Terraform actually needs: VM allocation, disk/storage management, and (for LXCs) container management. Proxmox’s built-inPVEVMAdminrole covers most of this for VM-only use; for LXCs you’ll likely addPVEDatastoreAdminas well for storage operations the LXC resource triggers. - Create a dedicated user (
terraform@pveor similar) and assign that role to it, scoped to the resource pool or path you actually want Terraform to manage, not the whole datacenter if you can avoid it. - Generate an API token under that user with “Privilege Separation” enabled, so the token’s actual permissions are exactly the role you assigned, not inherited implicitly from the user account.
- Feed the token ID and secret to the provider via environment variables (
PROXMOX_VE_API_TOKEN, followingbpg’s naming) rather than hardcoding them in a.tffile that might end up committed to a repo.
This is the same narrow-scoped-credential instinct that applies everywhere else in a homelab: Terraform doesn’t need admin on your whole cluster to build and tear down test VMs.
A minimal working example
With the bpg/proxmox provider, a VM cloned from an existing cloud-init template looks roughly like this:
resource "proxmox_virtual_environment_vm" "test_vm" {
name = "tf-test-01"
node_name = "hyper1"
clone {
vm_id = 9000 # the cloud-init template's VMID
}
cpu {
cores = 2
}
memory {
dedicated = 2048
}
initialization {
ip_config {
ipv4 {
address = "10.0.50.50/24"
gateway = "10.0.50.1"
}
}
user_account {
username = "admin"
keys = [file("~/.ssh/id_ed25519.pub")]
}
}
network_device {
bridge = "vmbr0"
}
}
terraform plan shows what this would create, terraform apply creates it, and terraform destroy tears it back down cleanly. The template reference (clone.vm_id) is exactly why the cloud-init template work pays off twice: once for fast manual cloning, and again here where Terraform’s job becomes “clone and customize,” not “build a VM from scratch.”
LXCs use a separate resource (proxmox_virtual_environment_container in bpg), with its own block for the container template/OS image rather than a VM clone, since containers and VMs don’t share a provisioning path on the Proxmox side either.
Where this goes wrong
State file drift. Terraform’s state file is its record of what it believes exists. If you go into the Proxmox UI and manually delete, rename, or resize a VM that Terraform manages, the state file is now lying, and the next plan either tries to recreate something that still exists or silently misses a real change. The fix isn’t clever tooling, it’s discipline: once a resource is Terraform-managed, stop touching it by hand. If you must make an emergency manual change, run terraform plan immediately after to see and reconcile the drift rather than letting it accumulate.
Where the state file lives. By default it’s a local terraform.tfstate file sitting next to your .tf files, which is fine for a single-operator homelab as long as you don’t lose the disk it’s on. It contains resource IDs and, depending on what you’ve defined, potentially sensitive values (the cloud-init user_account block above, for instance), so it belongs in your backup rotation and explicitly does not belong in a public git repo. A remote backend (even something as simple as storing it in a private Gitea repo or on NAS-backed storage) is worth the extra setup once you have more than a couple of VMs under management, mostly so a single corrupted or deleted laptop doesn’t orphan your whole state.
Destroy is real. terraform destroy does exactly what it says, on exactly the resources in your state file, with no confirmation beyond the one prompt. Running it from the wrong directory, against the wrong .tf files, against a production VM you forgot was Terraform-managed, is an easy way to have a genuinely bad night. Keep test and production configurations in clearly separate directories (or Terraform workspaces) rather than one shared set of files you edit in place for different purposes.
Not everything needs to be here. Pulling every existing VM into Terraform management (terraform import) is possible but tedious and error-prone for complex, long-lived guests, the per-resource import process doesn’t always capture every setting correctly on the first pass. A more realistic path for an existing homelab: manage new guests with Terraform going forward, leave existing stable VMs alone, and only import something retroactively if you’re already planning to tear it down and rebuild it anyway.
Where it fits in a real workflow
The practical pattern that holds up: cloud-init templates define the base image, Terraform defines which guests exist and how they’re sized/networked, and configuration management (Ansible, if you’re already using it for anything else in this stack) handles what happens inside the guest after boot. Terraform’s job ends at “the VM exists with the right hardware and the right cloud-init-injected SSH key.” Anything past that, package installs, service configuration, is a different tool’s job, and trying to cram it into Terraform via endless remote-exec provisioners fights the tool instead of using it.
For a homelab specifically, start small: pick one category of throwaway VM you rebuild often (test environments, a scratch box for trying out a new self-hosted app before committing to it), define it in Terraform, and get comfortable with plan/apply/destroy on something low-stakes before trusting it anywhere near a VM you actually depend on.