Terraform + vSphere: Building a Lab Environment You Can Destroy on Purpose
The real test of infrastructure-as-code isn't whether apply works — it's whether destroy does. How I structure Terraform for vSphere so entire lab environments are disposable.
My home lab used to accumulate VMs the way a garage accumulates boxes: each one individually justified, collectively unmanageable. The fix wasn’t discipline — it was making environments disposable. If recreating an environment takes one command, deleting it stops feeling like a loss.
This is the Terraform structure I’ve settled on after two years of iterating.
The layout
lab-infra/
├── modules/
│ ├── vm-linux/ # cloud-init based, one class of machine
│ ├── vm-windows/ # sysprep + autounattend
│ └── network-segment/ # dvPG or NSX segment + IPAM record
├── environments/
│ ├── k8s-playground/ # a whole environment = one root module
│ ├── nsx-lab/
│ └── ad-lab/
└── shared/
└── data.tf # datacenter, cluster, datastore lookups
The rule that makes it work: environments are roots, machines are modules, and nothing
is created outside an environment. When k8s-playground has served its purpose,
terraform destroy in that directory removes every trace — VMs, port groups, DNS records.
The VM module worth copying
The vSphere provider is verbose, so the module’s job is hiding boilerplate behind a small interface:
module "k8s_node" {
source = "../../modules/vm-linux"
for_each = toset(["cp-01", "wk-01", "wk-02"])
name = "k8s-${each.key}"
cpu = each.key == "cp-01" ? 4 : 8
memory_mb = 16384
disk_gb = 80
network = module.segment.portgroup_name
ipv4 = cidrhost(var.node_cidr, index(tolist(var.nodes), each.key) + 10)
ssh_keys = var.ssh_keys
}
Inside the module, the parts that took real trial and error:
resource "vsphere_virtual_machine" "vm" {
name = var.name
# ...
clone {
template_uuid = data.vsphere_virtual_machine.template.id
}
# cloud-init via guestinfo — no customization spec, no VMware Tools timing races
extra_config = {
"guestinfo.metadata" = base64gzip(local.metadata)
"guestinfo.metadata.encoding" = "gzip+base64"
"guestinfo.userdata" = base64gzip(var.user_data)
"guestinfo.userdata.encoding" = "gzip+base64"
}
lifecycle {
ignore_changes = [ept_rvi_mode, hv_mode] # host quirks; not config drift
}
}
Templates are cattle too
Disposable environments need reproducible templates. Packer builds mine monthly:
a minimal Ubuntu LTS and Rocky image with cloud-init, open-vm-tools and current patches,
named by date (tpl-ubuntu2404-2026-03). The Terraform data source pins the template by
name pattern, so environments rebuild on the latest image without editing code.
$ packer build -var-file=lab.pkrvars.hcl ubuntu-2404.pkr.hcl
...
Build 'vsphere-iso.ubuntu' finished after 11 minutes 42 seconds.
==> Builds finished. The artifacts of successful builds are:
--> vsphere-iso.ubuntu: tpl-ubuntu2404-2026-03State, locking and the “it’s just a lab” trap
Local state files in a lab feel fine right up until you’re debugging why two plans disagree. I keep state in an S3-compatible bucket on the NAS (MinIO) with locking — the same shape as a production setup, which is exactly the point of a lab:
terraform {
backend "s3" {
bucket = "tfstate"
key = "k8s-playground/terraform.tfstate"
endpoints = { s3 = "https://minio.lab.internal:9000" }
# ...
}
}
What “destroyable” changed
The unexpected benefit wasn’t tidiness. It was behavioral: I experiment more aggressively because failure is cheap. Broke the NSX lab’s routing beyond comprehension? Destroy, apply, coffee, done. That loop — break it, raze it, rebuild it — has taught me more about how these systems actually behave than any amount of careful reading.
Ten VMs I’m afraid to delete is a liability. One command that rebuilds them is an asset.