TerraformXXVI · Cloud and Platform OperationsProduction Terraform
Terraform On-Premises
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
“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:
bpg/proxmox— covered in the dedicated Proxmox lesson. The dominant choice for Proxmox VE 0.66+ in 2026.hashicorp/vsphere— the official provider for VMware vSphere. Mature; covers VM creation, networking, storage, and resource pools. The de-facto choice for VMware environments.dmacvicar/libvirt— the community provider for libvirt / KVM. Useful for ephemeral developer environments on Linux workstations. Not appropriate for production clusters.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_domainis a VM on the local host. If you want a multi-host setup, you need multipleprovider "libvirt"blocks with differenturivalues — 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
- 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.
- Datastore fills with orphan
.vmdkfiles.terraform destroydoes not free disk space. A cleanup script must run on a schedule and remove files whose VM is no longer in inventory. - Template deleted, clone fails.
vsphere_virtual_machinewithclone { 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. - Network bridge misconfigured, cluster unreachable. A
Terraform change that removes
vmbr0from a Proxmox host takes the cluster out of reach. The fix is apreconditionthat asserts the bridge exists before any change to VMs on that bridge. - 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.
- vCenter certificate expired.
allow_unverified_ssl = falsewith 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
destroyupdates state, but the underlying disk is not freed. - You can write a
dynamicblock 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
Q1. What is the fundamental lifecycle difference between cloud and on-prem Terraform resources?
Q2. Which of the following are appropriate use cases for on-prem Terraform? (Select all that apply.)
Q3. A single Proxmox host is a single point of failure, so no amount of Terraform on it delivers high availability for production workloads.
Q4. Why does the dmacvicar/libvirt provider have a narrower production scope than the vSphere provider?
Q5. What is the role of an inventory file in an on-prem Terraform configuration?
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?
Q7. Why is a remote state backend still appropriate for a homelab Proxmox setup?
Passing score: 75%. Answers are checked in this browser.