Terraform keeps turning up in the senior network engineering roles I've been watching, and I've got real production time in it, just not evenly spread across what the tool is fully capable of. At my last position it was how we pushed firewall policy changes through our Azure VNET hubs, and the VNETs themselves were Terraform-defined as well, although that side got touched far less once it was set. What I've done less of is the other half: standing a provider up from nothing and authoring the infrastructure myself, rather than operating an estate someone else had already built. That was already on my homelab roadmap as an admin-side deep dive, with the Proxmox provider as the obvious first target, since a hypervisor full of services I already run is about as homelab-native a place to close that gap as I'll find. What follows is one evening of this: standing up the provider, pulling my already-running containers under management, building a fresh VM from nothing, and very nearly blowing the whole thing up at the end.

Picking a provider

My research showed that there are two Terraform providers for Proxmox worth knowing about, and most of the tutorials land on the one that should be avoided. Telmate/proxmox is the old default: it ranks first in search results and it works, but it's thinly maintained and really only handles VMs, containers, and pools. The bpg/proxmox provider is the actively maintained one (dozens of releases a year), it understands Proxmox 8 and 9, and it covers what Telmate doesn't: SDN, storage, cluster config. It also works with OpenTofu if you go that way.

The tradeoff is that Telmate has more years of blog posts written against it, so if you copy-paste your way through a build you'll hit fewer surface-level snags. bpg's resource names are more verbose and a few of its behaviors assume you've actually read the docs. I'd still take bpg every time. Following stale examples into a barely-maintained provider is a worse evening than reading current docs against a live one.

terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.66"
    }
  }
}

One thing that differentiates the two providers: bpg authenticates two ways at once. An API token handles most operations, but a good chunk of what it does (uploading images, writing cloud-init snippets) happens over SSH straight to the Proxmox host. So you set up both a scoped API token and SSH-key access from wherever Terraform runs. Skip the SSH half and your container work still succeeds, then your VM work mysteriously hangs later with no obvious reason. Wire both up front and save yourself the head-scratching.

Two directions: import and provision

The explanation that makes this click is that Terraform can come at your infrastructure from two directions. It can adopt things that already exist (import), and it can build things that don't (provision). My homelab needs both. I have a stack of hand-built LXCs I did not want to rebuild (Pi-hole, Authelia, the reverse proxy), and I wanted every new VM to come from code going forward.

Importing what's already running

The reassuring part here is that import is read-only against a real container. It reads the current state into Terraform and writes nothing back to the running box, so a live service is never at risk as long as you don't then apply a plan that wants to change something. On a recent enough Terraform you also get config generation, which reverse-engineers the HCL for you instead of making you hand-write a resource block per container:

import {
  to = proxmox_virtual_environment_container.pihole
  id = "proxmox1/101"
}
terraform plan -generate-config-out=generated.tf

That writes a full resource block from the live container. It won't be completely without issues (more on that shortly), but it beats typing one out from scratch. The goal after import isn't "it's in state," it's "terraform plan reports no changes." A clean plan means your generated config actually matches reality, so Terraform won't try to helpfully fix a difference that was never a problem. Walk away from an import that still shows pending changes and the next apply will push those onto a real, running service.

Building what doesn't exist yet

The other direction is a cloud image plus cloud-init. You don't bake a template by hand: a download_file resource pulls the distro's cloud image once, and a vm resource attaches it and hands cloud-init the hostname, user, key, and IP to configure itself on first boot.

resource "proxmox_download_file" "ubuntu_2404_cloud" {
  content_type = "iso"
  datastore_id = "datastore"
  node_name    = "proxmox1"
  url          = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
}

Thirty seconds after apply I have a VM I can SSH into, with a user and key that had only ever existed as text in an HCL file. That's the part a container import can't show you, and it's the part that makes this feel like actual infrastructure-as-code rather than a fancy remote console.

The part that fought me

It wouldn't be a homelab project with a couple of interesting snags, and this one was no exception. Neither were hugely impacting, but they're worth mentioning just so that I can imprint these behaviors into memory.

The first was relatively simple: my initial container plan couldn't find its template, because pveam list local came back empty. That looked like a problem for about as long as it took to remember that local is a storage name, not a default. My templates were sitting on the NFS datastore where I'd parked them months ago, so the reference just needed to point there. Read the storage name, not the tutorial.

The second one was an odd quirk with the config generation, the feature that reverse-engineers HCL from a running container, blowing up like this:

Error: expected units to be in the range (1 - 500000), got 0

The generator had read my container's CPU settings, found no custom cpuunits value (so the API reports it as 0), and dutifully written units = 0 into the config. Which the provider's own schema then rejects, because the valid range starts at 1. So bpg generated a config that bpg refuses to accept: the generator and the validator, both halves of the same provider, disagreeing with each other. The fix is to delete the line and let it default, but the diagnosis is the fun part. The tool was confidently wrong about its own output, and the only way through was to stop trusting the generated config and read it myself.

That theme, trust the output less than you want to, is worth sitting on, because it's also how I should be honest about how this got built.

I checked on some of these things with the assistance of AI, but it's not perfect. It's great at the shape of a solution and unreliable on the precise, checkable details, and telling the difference is on the user. This brings me to the command my helper suggested that immediately planted some red flags.

The command I'm glad I didn't run

By the end of the night my Terraform state held five things: the four imported production containers and one throwaway test VM. I wanted to tear down the test VM to prove the destroy path worked. The suggestion I got, delivered with total confidence, was this:

terraform destroy

Bare. No target. Which sure, would have destroyed the test VM that was always planned to be temporary. But it would have also destroyed everyting else, including the Pi-hole doing my DNS, the Authelia guarding everything, and the reverse proxy sitting in front of the lot. terraform destroy with no arguments operates on the entire state, and by that point the state was mostly 'production', or at least it can be within the context of a homelab.

I wanted to remove one thing and the command was going to remove five, and noticing that gap is the entire job. The tool was cheerfully, confidently about to hand me a self-inflicted outage.

If you do need a surgical removal, the escape hatch is to target it:

terraform destroy -target=proxmox_virtual_environment_vm.test_vm

But the better move, the one that's actually idiomatic, is to not reach for destroy at all. Delete the resource block from your config and run terraform apply. Terraform sees that the config no longer describes that VM and removes it, one resource, derived from your desired state rather than from a blast-radius command. -target is for exceptional situations, and Terraform will even print a warning telling you so. Routine removal is a config edit, full stop.

The deeper fix is structural, and it's what I'll do next: stop keeping throwaway experiments in the same state as production. A separate sandbox module with its own state means "destroy everything" in the lab directory can never reach the things I actually care about.

Where this leaves things

One evening in, the homelab has both halves under Terraform. The containers I'd built by hand are imported and version-controlled, and new VMs come from a cloud image and a few lines of HCL. What's still open is the interesting part: a proper cloud-config layer for first-boot provisioning, splitting the sandbox out from production so I stop juggling blast radius by hand, and eventually pointing the same workflow at CML to define lab topologies as code.

None of it is groundbreaking. But it's mine now, it's in a repo, and future-me doesn't have to remember how any of it works. Which was most of the point.