When Terraform creates vSphere VMs, NetBox should not be an after-the-fact documentation step.
Use NetBox as an ownership gate before vSphere VM creation, then verify Terraform removes the NetBox records when the managed VM is destroyed.
Desired Order
The safe order is:
1. Resolve required NetBox objects
2. Create NetBox VM record
3. Create NetBox interface record
4. Create NetBox IP address record
5. Set NetBox primary IPv4
6. Create vSphere VM
The vSphere VM should depend on the NetBox primary IP relationship, not just the VM record.
That proves the source-of-truth object graph exists before the hypervisor receives the create request.
The destroy path should remove the same object graph:
1. Remove vSphere VM
2. Remove NetBox primary IPv4 relationship
3. Remove NetBox IP address record
4. Remove NetBox interface record
5. Remove NetBox VM record
Exact ordering is provider-dependent, but the final state should be clean in both Terraform and NetBox.
Terraform Pattern
The shape is:
data "netbox_cluster" "cluster" {
count = var.netbox_enabled ? 1 : 0
name = var.netbox_cluster_name
}
resource "netbox_virtual_machine" "vm" {
for_each = var.netbox_enabled ? var.vms : {}
name = each.value.name
cluster_id = data.netbox_cluster.cluster[0].id
status = "active"
}
resource "netbox_interface" "mgmt" {
for_each = var.netbox_enabled ? var.vms : {}
virtual_machine_id = netbox_virtual_machine.vm[each.key].id
name = "mgmt0"
enabled = true
}
resource "netbox_ip_address" "mgmt" {
for_each = var.netbox_enabled ? var.vms : {}
ip_address = "${each.value.ipv4_address}/${each.value.ipv4_prefix_length}"
status = "active"
dns_name = "${each.value.name}.example.com"
interface_id = netbox_interface.mgmt[each.key].id
}
resource "netbox_primary_ip" "vm" {
for_each = var.netbox_enabled ? var.vms : {}
virtual_machine_id = netbox_virtual_machine.vm[each.key].id
ip_address_id = netbox_ip_address.mgmt[each.key].id
}
resource "vsphere_virtual_machine" "vm" {
for_each = var.vms
name = each.value.name
depends_on = [
netbox_primary_ip.vm,
]
}
Adapt resource arguments to the provider version in use. The important part is the dependency boundary.
What This Protects
This pattern protects against:
- creating a VM when NetBox cannot be reached.
- creating a VM when the NetBox cluster lookup fails.
- creating a VM when the NetBox VM name already exists.
- creating a VM when the NetBox IP address already exists.
- creating a VM without a primary IP relationship in source of truth.
- leaving Terraform-managed NetBox records behind after destroying a disposable VM.
What This Does Not Protect
NetBox-first ownership does not prove:
- the VM name is absent from vCenter.
- the IP is quiet on the network.
- forward DNS is clear.
- reverse DNS is clear.
govcis configured correctly.
Those need a separate preflight check.
Fail-Closed Tests
Run these before trusting the pattern.
Missing NetBox credentials:
unset NETBOX_SERVER_URL
unset NETBOX_API_TOKEN
terraform plan -out=/tmp/missing-netbox-creds.tfplan
Expected result:
Error: Missing required argument
The argument "server_url" is required
Error: Missing required argument
The argument "api_token" is required
Missing NetBox cluster:
terraform plan -out=tfplan \
-var='netbox_cluster_name=missing-cluster-test'
Expected result:
Error: no result
with module.vm_group.data.netbox_cluster.cluster[0]
Unreachable NetBox:
NETBOX_SERVER_URL="https://netbox-unreachable.invalid" \
NETBOX_API_TOKEN="dummy" \
NETBOX_SKIP_VERSION_CHECK=true \
terraform plan -out=/tmp/netbox-unreachable-test.tfplan
Expected result:
Planning failed.
Error: Get "https://netbox-unreachable.invalid/api/..."
The desired behavior is:
NetBox fails -> Terraform plan fails -> vSphere VM is not created
Teardown Hygiene Test
Use a disposable test VM that Terraform already created and owns.
Confirm current state:
terraform state list
Expected resources:
module.vm_group.netbox_interface.vm["test-1"]
module.vm_group.netbox_ip_address.vm["test-1"]
module.vm_group.netbox_primary_ip.vm["test-1"]
module.vm_group.netbox_virtual_machine.vm["test-1"]
module.vm_group.vsphere_virtual_machine.vm["test-1"]
Generate a destroy plan:
terraform plan -destroy -out=destroy.tfplan
Inspect planned deletes before applying:
terraform show -json destroy.tfplan \
| jq -r '.resource_changes[]? | [.address, .type, (.change.actions | join(","))] | @tsv'
Expected action shape:
module.vm_group.netbox_primary_ip.vm["test-1"] netbox_primary_ip delete
module.vm_group.netbox_ip_address.vm["test-1"] netbox_ip_address delete
module.vm_group.netbox_interface.vm["test-1"] netbox_interface delete
module.vm_group.netbox_virtual_machine.vm["test-1"] netbox_virtual_machine delete
module.vm_group.vsphere_virtual_machine.vm["test-1"] vsphere_virtual_machine delete
Apply the destroy plan:
terraform apply destroy.tfplan
Verify Terraform state no longer lists the managed resources:
terraform state list
Verify NetBox VM cleanup:
curl -s \
-H "Authorization: Token $NETBOX_API_TOKEN" \
-H "Accept: application/json" \
"$NETBOX_SERVER_URL/api/virtualization/virtual-machines/?name=cluster-a-test-01" \
| jq '.count'
Expected:
0
Verify NetBox IP cleanup:
curl -s \
-H "Authorization: Token $NETBOX_API_TOKEN" \
-H "Accept: application/json" \
"$NETBOX_SERVER_URL/api/ipam/ip-addresses/?q=192.0.2.10" \
| jq '.count'
Expected:
0
Verify vCenter cleanup:
govc find / -type m -name 'cluster-a-test-01'
Expected: no output.
The successful lifecycle result is:
create -> manage -> verify idempotency -> destroy -> cleanup NetBox and vSphere
Operating Rule
If NetBox is the source of truth, make VM creation depend on NetBox ownership being established first.
Do not let documentation happen after provisioning when the documentation system is supposed to prevent collisions. Do not skip teardown checks when the same documentation system needs to stay clean after destroy.