Basic VM deployment on vSphere with Terraform. Single-file approach covering provider, data, resource, and output blocks.

“I’ll just click through the vSphere UI one more time…” - Said before automating everything

XKCD Will It Work - Code reliability by source xkcd 1742: Will It Work - CC BY-NC 2.5


Problem

I deployed 6 VMs by clicking through the vSphere UI. Each one took 15 minutes. Half the time was waiting for pages to load. The other half was double-checking I didn’t fat-finger an IP address.

Terraform lets you define a VM once and deploy it repeatedly. No clicking. No waiting for web UIs. No typos. “Replicants are like any other machine.” - Blade Runner. Define the template once, deploy copies. Infrastructure as code.

“Infrastructure as code means you can diff, review, and roll back. Clicking through a UI leaves no audit trail.” - DevOps principle

Prerequisites

  • A vSphere environment with a template VM to clone from
  • Terraform installed
  • Basic familiarity with vSphere concepts (datacenter, cluster, datastore, network)

Source: vsphere-vm-mk1
Provider docs: hashicorp/vsphere


Terraform Block

Pin the provider version. Terraform providers get updated. Sometimes those updates break your config. Pinning means your code keeps working even when the provider changes.

“Explicit version pinning is a contract. It says: ‘this config worked with this version.’ When it breaks, you know why.” - Terraform best practice

💡 Tip

Always run terraform plan before apply. Read the output. I once destroyed 3 VMs because I didn’t notice the plan said “3 to destroy.” There’s no undo button in infrastructure.

terraform {
  required_providers {
    vsphere = {
      source  = "hashicorp/vsphere"
      version = "2.0.2"
    }
  }
}

Provider

Configure the connection to vCenter.

provider "vsphere" {
  user           = "tf_admin@vsphere.local"
  password       = "your-password-here"  # Use Vault or env vars - never hardcode!
  vsphere_server = "tpa-vmw-vc-01.foggyclouds.net"

  # If you have a self-signed cert
  allow_unverified_ssl = true
}

“I’ll just commit this and change the password later.” - Every leaked credential ever

⚠️ Warning

Hardcoding passwords in provider blocks is not secure. Use environment variables (TF_VAR_vsphere_password), a secrets engine like Vault, or .tfvars files that are git-ignored.


Data Sources

Data sources look up existing vSphere objects by name and return their IDs for use in resource blocks.

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

data "vsphere_datastore" "datastore" {
  name          = "tpa-lab-01-ds-01"
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_compute_cluster" "cluster" {
  name          = "lab"
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_network" "network" {
  name          = "pg-v110"
  datacenter_id = data.vsphere_datacenter.dc.id
}

data "vsphere_virtual_machine" "template" {
  name          = "templates/packer/ubuntu/linux-ubuntu-20"
  datacenter_id = data.vsphere_datacenter.dc.id
}

Resource

The VM resource defines hardware specs and clone settings.

Hardware:

resource "vsphere_virtual_machine" "vm" {
  name             = "test-vm-01"
  resource_pool_id = data.vsphere_compute_cluster.cluster.resource_pool_id
  datastore_id     = data.vsphere_datastore.datastore.id

  num_cpus = 2
  memory   = 2048
  guest_id = data.vsphere_virtual_machine.template.guest_id

  scsi_type = data.vsphere_virtual_machine.template.scsi_type

  network_interface {
    network_id   = data.vsphere_network.network.id
    adapter_type = data.vsphere_virtual_machine.template.network_interface_types[0]
  }

  disk {
    label            = "disk0"
    size             = data.vsphere_virtual_machine.template.disks.0.size
    eagerly_scrub    = data.vsphere_virtual_machine.template.disks.0.eagerly_scrub
    thin_provisioned = data.vsphere_virtual_machine.template.disks.0.thin_provisioned
  }

Clone + customization:

  clone {
    template_uuid = data.vsphere_virtual_machine.template.id

    customize {
      network_interface {
        ipv4_address = "10.1.110.185"
        ipv4_netmask = 24
      }

      ipv4_gateway    = "10.1.110.254"
      dns_server_list = ["10.1.110.11"]
      dns_suffix_list = ["foggyclouds.net"]

      linux_options {
        host_name = "test-vm-01"
        domain    = "foggyclouds.net"
      }
    }
  }
  lifecycle {
    ignore_changes = [
      annotation,
      clone[0].template_uuid,
      clone[0].customize[0].dns_server_list,
      clone[0].customize[0].network_interface[0]
    ]
  }
}

The lifecycle block prevents Terraform from trying to recreate the VM if the template or customization settings change after initial deployment.

“State is the source of truth. Drift is the enemy. Lifecycle blocks tame the chaos.” - Terraform operators

💡 Tip

The customize block requires VMware Tools (or open-vm-tools) installed in the template. Without it, guest customization silently fails and the VM boots with default network settings.


Outputs

output "vm_ip_address" {
  value = vsphere_virtual_machine.vm.default_ip_address
}

output "vm_hostname" {
  value = vsphere_virtual_machine.vm.name
}

“State is truth. Lose your state file, lose your sanity.” - Terraform User, learned the hard way

“It’s a trap!” - Admiral Ackbar. Without Terraform state, you’re guessing. With it, you know exactly what exists. No surprises.

💡 Tip

Terraform stores all managed resource state in terraform.tfstate. This file is critical - losing it means Terraform doesn’t know what it manages. For solo projects, the local file is fine. Once you’re collaborating or running CI, move to remote state (S3, GCS, Terraform Cloud) with locking enabled to prevent concurrent writes.

💡 Tip

Run terraform fmt -recursive before committing. It auto-formats HCL to the canonical style. Add it as a pre-commit hook so your repo stays consistently formatted:

terraform fmt -check -recursive  # Exits non-zero if files need formatting

What’s Next

This works, but everything is hardcoded. mk2 introduces variables to make the deployment reusable.


References