Terraform for Proxmox: Infrastructure as Actual Code
Declaring virtual machines you can destroy without flinching

Contents
There is a particular kind of homelab paralysis that comes from clicking. You build a VM in the Proxmox web UI, tick the boxes, pick the disk size, set the cores, boot it, install the OS, and three months later you have no idea what you did. The machine works. You are now frightened of it, because rebuilding it means remembering forty small decisions you made on a Tuesday evening while half-watching something. So you never rebuild it. You patch around it. Eventually you have a homelab full of pets you cannot describe.
Terraform fixes the describing problem. You write down the machine — cores, memory, disks, network, the cloud-init user — in a file, commit the file, and from then on the file is the truth. Destroying the VM stops being a loss. It becomes a thirty-second inconvenience. That change in posture is the real payoff, and it matters far more than any time you save on the initial build.
Why the provider choice is the whole story
Proxmox has no official Terraform provider. The community has filled that gap twice. The older one, Telmate/proxmox, is the one most old blog posts use, and it has spent years in a permanent state of nearly-working: cloud-init handling that fights you, disk syntax that changed between minor releases, and a habit of reporting success while quietly doing nothing.
The one worth using is bpg/proxmox. It is actively maintained, it models the Proxmox API properly, and its cloud-init support behaves the way you would expect. If you find a tutorial with qemu_guest_agent blocks and disk { slot = 0 }, you are reading Telmate documentation, and adapting it will waste an evening. Start from the bpg docs and ignore everything else.
Before writing any HCL, create a dedicated API token. Terraform authenticating as root@pam with your actual password is a bad habit that will eventually end up in a state file or a shell history.
| |
That last command prints a token secret exactly once. The --privsep 0 flag means the token inherits the user’s permissions instead of needing its own ACL, which is the sane default here; with privilege separation on you get to debug permission errors twice.
A minimal, honest configuration
Here is a working setup that downloads a cloud image, and builds a VM from it. This is the shape I use for every disposable Linux box in the lab.
| |
A few of those lines earn their place. cpu { type = "host" } passes the physical CPU flags through, which matters the moment anything wants AES-NI or AVX — the default kvm64 is a museum piece from 2009 and will halve your throughput on anything cryptographic. discard = "on" lets fstrim inside the guest actually return blocks to the thin pool, without which your local-lvm fills up with the ghosts of deleted files. And the ssh block in the provider is required for a handful of operations that Proxmox has no API for — the provider quietly shells into the node to do them. If you skip it, disk imports fail with an error that does not mention SSH at all.
The ignore_changes on the SSH keys is a scar. The provider normalises the key string it reads back from the API, so every subsequent plan shows a diff that wants to rebuild the VM to change a key into an identical key.
Cloud images or templates: pick one and commit
There are two ways to get an operating system onto a Terraform-built VM, and mixing them is how people end up confused about which layer owns what.
The first is the one above: point the disk at a downloaded cloud image and let cloud-init do the first-boot configuration. Every VM starts from the distribution’s published image, so there is nothing local to rot, and the only state that matters lives in your HCL. The cost is boot time — cloud-init has to install packages on every single machine, so a box that needs Docker and the guest agent spends its first ninety seconds talking to a mirror. Multiply that by a rebuild of six nodes and you are waiting.
The second is to build a golden template once — with Packer, or by hand and then converting a VM to a template — and have Terraform clone it. Boots are near-instant because the packages are already there. The cost is that the template is now a thing you own, and it drifts: three months after you build it, it is three months behind on security updates, and every VM you clone starts life needing a hundred and forty packages upgraded. Templates want a rebuild cadence, which means they want a pipeline, which is a second project.
My rule is boring. Cloud images for anything I rebuild rarely, where I would rather wait ninety seconds than maintain a template. Templates for anything I rebuild constantly, where the ninety seconds becomes ten minutes across a cluster and I will notice. Cloning in the bpg provider is a clone block instead of a file_id, and everything else in the resource stays identical, so switching later costs a rebuild and nothing else.
The one combination to avoid is a golden template that also runs heavy cloud-init on boot. That is both costs at once: you maintain the template and wait for the package installs, and when something goes wrong you get to work out which of the two layers did it. If cloud-init is doing more than setting a hostname, a user, an IP and installing the guest agent, the work belongs in the template or in a configuration manager.
State, and where to keep it
The terraform.tfstate file contains everything Terraform knows, including the token if you were careless, and including sensitive attributes even if you were careful. Local state is fine for a single-operator homelab, provided the file is on an encrypted disk and covered by your backups. The failure mode of losing state is genuinely nasty: Terraform forgets that the resources exist, and the next apply cheerfully creates a second copy of everything.
If you want state somewhere shared, the pragmatic homelab answer is an S3-compatible bucket on MinIO or Garage with the s3 backend. Whatever you choose, keep the secret out of the repo — I use SOPS with age for that, which I wrote up in secrets management with SOPS and age. A TF_VAR_proxmox_token environment variable sourced from a decrypted file is enough:
| |
Yes, tofu. Since HashiCorp’s licence change I run OpenTofu everywhere, which is a drop-in for this entire article — see OpenTofu after the licence change for why and how the migration went.
Troubleshooting: the five things that will happen to you
“unexpected status code 501” or 403 on disk operations. The token lacks a privilege, most often Datastore.AllocateSpace or VM.Config.Cloudinit. Proxmox error messages here are close to useless. Re-run the pveum role add line above with the full privilege list and try again; guessing one privilege at a time is a long evening.
Cloud-init settings apply on create and never again. This is correct behaviour that feels like a bug. Cloud-init runs once per instance ID. Changing ip_config in HCL updates the cloud-init drive, and the guest ignores it because it has already run. If you need the change to land, taint the VM and let it rebuild — which is the entire point of doing this in the first place.
The VM boots to a black screen with no IP. Nine times in ten the disk file_id points at an installer ISO rather than a cloud image. Generic cloud images are .qcow2/.img files with cloud-init baked in; the netinst ISO you would normally click through has none of that and is waiting for you to press a key.
plan shows a permanent diff on the disk size. Proxmox rounds and reports disk sizes in ways the provider sometimes disagrees with, particularly after a manual resize in the UI. Growing a disk in HCL works; shrinking it silently does nothing, because Proxmox will not shrink a disk and the provider will not tell you. If you need it smaller, rebuild.
Timeouts on download_file for large images. The Proxmox node is doing the download, so the timeout is measuring its internet connection. Raise it with a timeout_download argument rather than assuming the URL is wrong.
Everything hangs at “still creating…” for ten minutes. The provider is waiting for the QEMU guest agent to report an IP. If the image does not have qemu-guest-agent installed, agent { enabled = true } makes Terraform wait for a report that never comes. Debian and Ubuntu cloud images do not ship it by default. Either install it via cloud-init packages, or set agent { enabled = false } and accept that Terraform will not know the VM’s IP.
Multiplying machines without copy-paste
One VM in HCL is a party trick. The value shows up at three, when you notice you have copied the same forty-line block and changed a number in two places. The fix is for_each over a map, and it is worth doing early because retrofitting it later means Terraform sees the renamed resources as new ones and destroys the old.
| |
Now the whole cluster is a table at the top of the file. Adding a fourth node is one line. That output block is the handoff to configuration management: pipe it through jq and you have an Ansible inventory that can never drift from the machines that actually exist, which is a nicer property than it sounds.
Resist the urge to wrap this in a reusable module on day one. Homelab Terraform modules tend to accumulate variables for every field you might one day want to change, until the module is a worse version of the provider with extra indirection. Write the resource plainly; extract a module when you have copied the same block into a third .tf file and you can see what genuinely varies.
Where this stops being enough
Terraform builds the box. It does not configure what is inside it, and the moment you try to make it do that — remote-exec provisioners, inline shell, a user_data blob that grows to two hundred lines — you have reinvented a configuration manager badly. The clean division is: Terraform owns the VM’s existence and its hardware; something else owns the software. For me that is Ansible, and the handoff is a generated inventory. For a Kubernetes node, cloud-init installs the agent and the cluster takes over.
The other honest limit is drift. Terraform describes what you told it. Anyone who clicks in the Proxmox UI — including you, at 23:00, to fix something quickly — creates a difference that plan will helpfully offer to undo, potentially by destroying a machine. Running tofu plan on a schedule and reading the output is the discipline that makes this safe. A weekly cron that runs plan -detailed-exitcode and pushes a notification when the exit code comes back 2 costs ten minutes to set up and turns drift from a nasty surprise into a Sunday-morning line item. The alternative is discovering the drift during an apply you ran for an unrelated reason, at which point Terraform is showing you a destroy plan for a machine you forgot you hand-edited, and you are making the decision under time pressure. I have made that decision badly. Without it, the code is a story about the infrastructure rather than a description of it, which is a specific flavour of technical debt that looks like rigour.
The verdict
If you run one Proxmox host with three VMs that never change, Terraform is a hobby, and a decent one, though it will not pay you back. If you rebuild things — for testing, for upgrades, because you enjoy it — it pays for itself within a month. The threshold in my experience sits around five VMs or the first time you rebuild the same machine twice. The learning curve is real but short: HCL is a small language, the provider docs are good, and the whole thing is about a weekend to get comfortable with if you already understand Proxmox. What takes longer is the habit — remembering that the file is the truth, and that a change made in the UI is a lie you are telling your future self.
The real benefit is psychological. A VM described in twenty lines of HCL is something you can delete on a whim, and being able to delete things on a whim is what separates a lab from a museum. I destroy and rebuild my Kubernetes nodes roughly monthly now, purely because it costs nothing, and every time I do it I find something I had forgotten to write down.
If you are still building your first Proxmox node, start with the basics — I covered the ground floor in Proxmox 101 — and come back here once clicking through the VM wizard has begun to annoy you. That annoyance is the signal. It arrives on schedule.




