Home Lab 3 min read

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.

lab — packer build
$ 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-03

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