Skip to main content
RunBook Academy

TerraformXXVI · Cloud and Platform OperationsProduction Terraform

Terraform On-Premises

Intermediate⏱ ~16 minbash

What you'll learn

  • Compare the on-prem provider ecosystem: Proxmox, vSphere, libvirt, Nutanix
  • Configure the hashicorp/vsphere provider and provision a vsphere_virtual_machine
  • Use Terraform's libvirt provider for ephemeral developer environments
  • Model the on-prem resource lifecycle: no automatic cleanup, manual decommission
  • Decide when an on-prem target is appropriate and when it is theatre

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

“On-prem” in 2026 covers a wide spectrum: a single Proxmox host in a homelab, a three-host Proxmox cluster at a small business, a vSphere cluster at an enterprise, a Nutanix node at a regional office. The Terraform pattern is the same in each case — describe the desired state, run terraform apply, record the result in state — but the lifecycle is fundamentally different from cloud. There is no automatic cleanup, no pay-per-use teardown, and no “the host is gone, the resource is gone.” This lesson covers the provider ecosystem, the patterns, and the operational reality of Terraform against on-prem virtualisation.

The on-prem provider landscape

        On-prem virtualisation
        ┌──────────────────┐
        │                  │
   ┌────▼─────┐    ┌───────▼──────┐    ┌──────────────┐
   │ Proxmox  │    │   vSphere    │    │   Nutanix    │
   │  VE      │    │  (VMware)    │    │              │
   └────┬─────┘    └───────┬──────┘    └──────┬───────┘
        │                 │                  │
   bpg/proxmox    hashicorp/vsphere    nutanix/nutanix
   telmate/proxmox                       (community)

The four providers you will encounter:

  1. bpg/proxmox — covered in the dedicated Proxmox lesson. The dominant choice for Proxmox VE 0.66+ in 2026.
  2. hashicorp/vsphere — the official provider for VMware vSphere. Mature; covers VM creation, networking, storage, and resource pools. The de-facto choice for VMware environments.
  3. dmacvicar/libvirt — the community provider for libvirt / KVM. Useful for ephemeral developer environments on Linux workstations. Not appropriate for production clusters.
  4. nutanix/nutanix — the community provider for Nutanix Prism. Niche; only relevant if you run Nutanix.

vSphere in practice

The hashicorp/vsphere provider is the most mature of the on-prem providers. The connection model is a vCenter endpoint (not an ESXi endpoint — direct ESXi is supported but discouraged for any environment with more than one host).

terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    vsphere = {
      source  = "hashicorp/vsphere"
      version = "~> 2.13"
    }
  }

  backend "s3" {
    bucket         = "onprem-tfstate"
    key            = "platform/vsphere.tfstate"
    region         = "eu-west-2"
    dynamodb_table = "onprem-tfstate-lock"
    encrypt        = true
  }
}

provider "vsphere" {
  user                 = var.vsphere_user
  password             = var.vsphere_password
  vsphere_server       = "vcenter.example.com"
  allow_unverified_ssl = false  # production; vCenter should have a real cert
}

data "vsphere_datacenter" "dc" {
  name = "dc01"
}

data "vsphere_datastore" "datastore" {
  name          = "datastore1"
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_resource_pool" "pool" {
  name          = "prod"
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_network" "network" {
  name          = "VM Network"
  datacenter_id = data.vsphere_datacenter.dc.id
}

Notice the pattern: read the infrastructure you are going to use as data sources, then create the resources you want against those data sources. This is the same shape as the AWS pattern (data "aws_ami" "ubuntu"), but the data sources are vSphere constructs.

resource "vsphere_virtual_machine" "web" {
  for_each = toset(["01", "02"])

  name             = "web-${each.key}"
  resource_pool_id = data.vsphere_resource_pool.pool.id
  datastore_id     = data.vsphere_datastore.datastore.id

  num_cpus = 2
  memory   = 2048

  guest_id = "ubuntu64Guest"

  network_interface {
    network_id = data.vsphere_network.network.id
  }

  disk {
    label            = "disk0"
    size             = 32
    eagerly_scrub    = false
    thin_provisioned = true
  }

  clone {
    template_uuid = data.vsphere_virtual_machine.ubuntu_template.id
  }
}

The clone block requires that a VM template exists on the vSphere side. The template is built outside Terraform — typically by importing an Ubuntu cloud image, installing cloud-init, and converting to a template. Terraform does not build the template; it only clones from it.

libvirt for developer environments

The dmacvicar/libvirt provider is for Linux developers who run KVM/libvirt locally. It is not appropriate for production clusters — the provider cannot model a libvirt cluster, only a single host. The right use case is “I want to spin up a few VMs on my workstation to test a Terraform configuration.”

terraform {
  required_providers {
    libvirt = {
      source = "dmacvicar/libvirt"
      version = "~> 0.8"
    }
  }
}

provider "libvirt" {
  uri = "qemu:///system"
}

resource "libvirt_volume" "base" {
  name   = "ubuntu-base.qcow2"
  source = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
  pool   = "default"
  format = "qcow2"
}

resource "libvirt_domain" "dev" {
  name   = "dev-01"
  memory = 2048
  vcpu   = 2

  cloudinit = libvirt_cloudinit_disk.commoninit.id

  disk {
    volume_id = libvirt_volume.base.id
  }

  console {
    type        = "pty"
    target_type = "serial"
    target_port = "0"
  }

  graphics {
    type        = "vnc"
    listen_type = "address"
  }
}

A few honest constraints with libvirt:

  • There is no concept of a cluster. Each libvirt_domain is a VM on the local host. If you want a multi-host setup, you need multiple provider "libvirt" blocks with different uri values — and the credentials for each.
  • State management is local. The remote backend story for libvirt is weaker than for the cloud providers; many users run libvirt Terraform with a local backend and accept the loss of a workstation as the cost.
  • Network management is fragile. The provider can model bridges, but the underlying libvirt networking depends on the host’s network configuration. A misconfigured bridge on the host cannot be recovered by Terraform.

The lifecycle difference

The single most important fact about Terraform on premises:

Cloud lifecycle                 On-prem lifecycle
─────────────────               ─────────────────
Resource created by Terraform   Resource created by Terraform
  ↓                              ↓
Resource exists in the cloud    Resource exists on the host
  ↓                              ↓
terraform destroy               terraform destroy
  ↓                              ↓
Cloud provider deletes the      Terraform removes the resource
resource from the cloud         from state. The VM file remains
                                on the datastore. The host
                                must be cleaned up by hand.

The cloud providers charge by resource and have an economic incentive to actually delete the resource when you ask. On-prem hypervisors do not. terraform destroy on a vSphere VM marks the VM as removed from inventory; the underlying .vmdk file remains on the datastore until you delete it manually.

This is the operational fact that catches teams new to on-prem Terraform:

# READ-ONLY: confirm the resource is no longer in state
terraform state list | grep web-01
# (empty)

# READ-ONLY: confirm the resource is gone from vSphere
govc vm.info web-01
# vm not found
# READ-ONLY: check the datastore for orphan files
govc datastore.ls / | grep web-01
# web-01/web-01.vmdk

The .vmdk is still there. The disk is still consuming space. A year later, the datastore is full of orphaned disks from VMs that were “destroyed” but never cleaned up. The fix is a separate cleanup process — a script, a scheduled job, an operator checklist — that compares the inventory to the datastore and removes the orphans.

Inventory files and dynamic blocks

On-prem environments rarely have the API richness of a cloud. The inventory of hosts, networks, and storage exists somewhere — vCenter, NetBox, a wiki — and Terraform needs to consume it. The right pattern is to read the inventory from a data source or a file, then iterate with for_each and dynamic blocks.

locals {
  hosts = yamldecode(file("${path.module}/inventory/hosts.yml"))
}

resource "proxmox_vm_qemu" "web" {
  for_each = local.hosts.web

  name        = each.key
  target_node = each.value.node

  clone {
    template_id = data.proxmox_virtual_environment_vm.ubuntu_template.id
    full        = true
  }

  cpu {
    cores = each.value.cores
  }

  memory {
    dedicated = each.value.memory
  }

  dynamic "disk" {
    for_each = each.value.disks
    content {
      slot    = "scsi${disk.key}"
      size    = disk.value.size
      storage = disk.value.storage
    }
  }

  dynamic "network" {
    for_each = each.value.networks
    content {
      model  = "virtio"
      bridge = network.value.bridge
      tag    = lookup(network.value, "vlan", null)
    }
  }

  initialization {
    ip_config {
      ipv4 {
        address = "${each.value.address}/24"
        gateway = each.value.gateway
      }
    }
  }
}

The inventory/hosts.yml file looks like:

web:
  web-01:
    node: pve-01
    cores: 2
    memory: 2048
    address: 10.20.30.101
    gateway: 10.20.30.1
    disks:
      - size: 32G
        storage: local-zfs
    networks:
      - bridge: vmbr0
        vlan: 100
  web-02:
    node: pve-02
    cores: 2
    memory: 2048
    address: 10.20.30.102
    gateway: 10.20.30.1
    disks:
      - size: 32G
        storage: local-zfs
    networks:
      - bridge: vmbr0
        vlan: 100

The inventory is the source of truth. Terraform consumes it. If a new host is needed, the inventory file is updated and the plan proposes a single new VM. No code change required.

When on-prem Terraform is appropriate

Honest accounting:

Appropriate:

  • A small-business environment where Proxmox or vSphere runs the existing workloads.
  • An enterprise vSphere cluster where the alternative is manual VM provisioning.
  • A development cluster on premises that mirrors a production cloud environment.
  • An edge deployment with no reliable cloud connectivity.

Theatre:

  • A single Proxmox host labelled “production” with no cluster, no shared storage, and no failover. This is not “cloud on premises”; it is a single point of failure with extra steps.
  • An on-prem deployment chosen because “we don’t want cloud vendor lock-in” when the alternative is also a single-vendor lock-in (vSphere to VMware, Proxmox to Proxmox).
  • An on-prem deployment chosen because the team believes it is cheaper without doing the TCO calculation. Compute, power, cooling, hardware refresh, and operator time are not free.

How to validate

# READ-ONLY: confirm the provider can reach the cluster
terraform providers
terraform validate

# READ-ONLY: list existing VMs in the cluster
pvesh get /cluster/resources --type vm   # Proxmox
govc find / -type m                      # vSphere

# CONFIGURATION: plan
terraform plan -out=tfplan

# READ-ONLY: confirm the plan matches expectation
terraform show tfplan

For post-apply validation:

# READ-ONLY: confirm the live state matches recorded state
terraform plan -detailed-exitcode

# READ-ONLY: check for orphan VM files on the datastore
VM_NAME=REPLACE_WITH_VM_NAME
govc datastore.ls / | grep "$VM_NAME"

Production failure modes

  1. No cluster, single host. One physical host, one virtualisation layer, one failure domain. The fix is at least two hosts in a cluster; without that, the “production on-prem” claim is false.
  2. Datastore fills with orphan .vmdk files. terraform destroy does not free disk space. A cleanup script must run on a schedule and remove files whose VM is no longer in inventory.
  3. Template deleted, clone fails. vsphere_virtual_machine with clone { template_uuid = ... } references a template. If the template is deleted, the next apply fails with “template not found.” Template lifecycle must be managed outside Terraform.
  4. Network bridge misconfigured, cluster unreachable. A Terraform change that removes vmbr0 from a Proxmox host takes the cluster out of reach. The fix is a precondition that asserts the bridge exists before any change to VMs on that bridge.
  5. DHCP scope exhausted. On-prem DHCP is finite. A Terraform apply that provisions 50 new VMs from a /24 DHCP scope exhausts the pool. The fix is to assign static IPs from a known range, not to rely on DHCP for production hosts.
  6. vCenter certificate expired. allow_unverified_ssl = false with an expired vCenter certificate means every apply fails. Renew the certificate. A real one, not a self-signed extension.

What to do in production

  • Use a remote backend with locking for on-prem Terraform, the same as for cloud. State on a laptop is one house fire away from being lost.
  • Build a separate cleanup process that finds and removes orphaned VM files after terraform destroy.
  • Manage template lifecycles outside Terraform. The provider references templates; it does not own them.
  • Scope the API credentials to the smallest viable vCenter / Proxmox role. The same principle as AWS: do not give the automation account Administrator on the cluster if Contributor on a resource pool is enough.
  • Have an honest TCO conversation before going on-prem to avoid cloud “lock-in.” The on-prem choice is its own lock-in; the cloud exit is a different cost than the on-prem upgrade cycle.

Verification

After working through this lesson, confirm the following:

  • You can name the three dominant on-prem Terraform providers (Proxmox, vSphere, libvirt) and one appropriate use case for each.
  • You can describe the lifecycle difference between cloud and on-prem resources: on-prem destroy updates state, but the underlying disk is not freed.
  • You can write a dynamic block over an inventory file.
  • You can name two reasons on-prem Terraform is theatre rather than a production substitute for cloud.

Knowledge check · 7 questions

  1. Q1. What is the fundamental lifecycle difference between cloud and on-prem Terraform resources?

  2. Q2. Which of the following are appropriate use cases for on-prem Terraform? (Select all that apply.)

  3. Q3. A single Proxmox host is a single point of failure, so no amount of Terraform on it delivers high availability for production workloads.

  4. Q4. Why does the dmacvicar/libvirt provider have a narrower production scope than the vSphere provider?

  5. Q5. What is the role of an inventory file in an on-prem Terraform configuration?

  6. Q6. A team runs Terraform against a vSphere cluster. They run terraform destroy on a stack that includes 20 VMs. A month later, the datastore is full. What is the most likely cause?

  7. Q7. Why is a remote state backend still appropriate for a homelab Proxmox setup?

Passing score: 75%. Answers are checked in this browser.