Deploy Multiple VMs with Terraform or OpenTofu on Proxmox

Deploying several VMs at once on Proxmox with Terraform or OpenTofu: a comparison of the available methods for scaling your infrastructure.

The real power of tools like Terraform and OpenTofu lies in their ability to scale. Creating a single virtual machine is quick enough, but let’s spin up several machines to get the most out of the tool.

I’ve been using OpenTofu since early 2025 on various Proxmox setups, both online and air-gapped. The code examples below will give you a solid starting point. I’m reusing the base of the .tf files from the article “Using Terraform or OpenTofu to create a VM on Proxmox ”:

Using Terraform or OpenTofu to create a VM on Proxmox. Following an article about using Terraform to create LXCs for Proxmox, I want to dig deeper into the subject, this time covering the creation of virtual machines. Prerequisites: prior knowledge of Terraform, administrative rights on Proxmox, and the ability to make changes without any major constraints. J.HOMMET.NET — Julien HOMMET. Several methods exist for creating a whole batch of VMs at once:

  • duplicating the .tf files;
  • using separate .tfvars files;
  • using a single .tfvars file containing multiple resource blocks;
  • creating and using a module.

I’ll focus on the first three methods, as I haven’t mastered the last one yet.

On top of that, it’s been a while since I last built templates by hand. These days, I use the “cloud” images provided by vendors like Canonical, Debian, or Fedora… Here’s the list of images I use:

*: for the Ubuntu images, I no longer use the minimal versions, which have occasionally caused me stability and dependency issues with podman or docker.

Duplicating the .tf files#

Duplicating .tf files is a simple way to create multiple virtual machines, but it poses problems in terms of maintainability and discipline. The approach consists of copying a vm.tf file, for example, which holds the configuration of the resources you want, then renaming it and adjusting the necessary values for each new instance. Each duplicate has to include a change to the resource name (for example, "proxmox_virtual_environment_vm" "vm2") along with tweaks to the arguments specific to each virtual machine.

While this method may seem effective at first, it can quickly become cumbersome to manage. Each duplicated .tf file has to be updated individually whenever the overall configuration changes or whenever adjustments are needed.

This method can be worth considering for small, heterogeneous infrastructures, where deployment speed matters more than maintainability. However, in a more complex or homogeneous infrastructure, it quickly becomes unmanageable.

Using separate .tfvars files#

In my lab, I’ve often used this method, which combines duplicating Terraform files with the use of .tfvars files. The approach is fairly simple: for each virtual machine, you use a separate .tfvars file while sharing a common base defined in a central .tf file. This main file holds the standardized resources and configurations that all instances can use.

Each .tfvars file then specifies the unique parameters for each virtual machine, making it possible to customize certain configurations while minimizing the duplication of source code in the .tf file. This offers greater flexibility and simplifies some aspects of managing varied configurations.

While this method still requires some maintenance effort, it noticeably reduces the amount of code duplicated directly in the .tf files, compared to simply duplicating entire files. This approach is especially useful for managing a moderate number of instances where each VM needs a few specifics of its own.

If you reuse the files entered on the page “Using Terraform or OpenTofu to create a VM on Proxmox ”, here’s what you should end up with (for this example, there will be two virtual machines):

File “variables.tf”:

hcl
### variables.tf

# The DNS domain used for cloud-init configuration.
variable "cloudinit_dns_domain" {
  type        = string
  default     = "home.arpa"
  description = "The DNS domain to be configured by cloud-init."
}

# A list of DNS servers used for cloud-init configuration.
variable "cloudinit_dns_servers" {
  type        = list(string)
  description = "A list of DNS server addresses for cloud-init configuration. Provide the IP addresses as a list of strings, e.g., [\"9.9.9.9\", \"149.112.112.112\"] (without backslashes)."
}

# SSH keys to be added to the authorized_keys file during cloud-init setup.
variable "cloudinit_ssh_keys" {
  type        = list(string)
  description = "SSH public keys to be included in the user's authorized_keys file."
}

# The username and password for the initial user account set up by cloud-init.
variable "cloudinit_user_account" {
  type        = string
  description = "Username for the initial user account created by cloud-init."
}

# ID of the datastore where the VM will be stored.
variable "vm_datastore_id" {
  type        = string
  description = "ID of the datastore to store the virtual machine disks."
}

# Format for the VM disk file (e.g., qcow2, vmdk).
variable "vm_disk_file_format" {
  type        = string
  description = "File format of the virtual machine's disk image."
}

# Name or identifier for the node.
variable "node_name" {
  type        = string
  description = "Name or identifier of the Proxmox node."
}

# API token for authenticating with the Proxmox Virtual Environment (PVE) host.
variable "pve_api_token" {
  type        = string
  description = "API token used to authenticate with the PVE server. Used too into a PVE cluster"
}

# IP address or hostname of the PVE host.
variable "pve_host_address" {
  type        = string
  description = "IP address or hostname of a PVE host."
}

variable "tags" {
  type        = list(string)
  description = "A list of tags to label the VM."
}

# Directory path for temporary files during VM setup.
variable "tmp_dir" {
  type        = string
  description = "Path to a directory used for storing temporary files like ISO uploads."
}

# Network bridge interface for connecting the VM's LAN.
variable "vm_bridge_lan" {
  type        = string
  description = "Network bridge name for the virtual machine's LAN connection."
}

# Number of CPU cores assigned to the VM.
variable "vm_cpu_cores_number" {
  type        = number
  description = "Number of CPU cores allocated to the virtual machine."
}

# Type of CPUs used in the VM (e.g., host, kvm).
variable "vm_cpu_type" {
  type        = string
  description = "Type of CPU model assigned to the virtual machine."
}

# Description for the VM.
variable "vm_description" {
  type        = string
  description = "Human-readable description or notes about the virtual machine. Markdown compatible."
}

# Size of the VM's disk in GB.
variable "vm_disk_size" {
  type        = number
  description = "Size of the disk for the virtual machine, specified in gigabytes. E.g. If you want a 2 Gb disk, type '1863'."
}

variable "vm_gateway_ipv4" {
  description = "The gateway IP address for the VM's network interface"
  type        = string
}

# Unique identifier for the VM instance (e.g., ID).
variable "vm_id" {
  type        = number
  description = "Unique numerical identifier for the virtual machine. Limited to 9 digits"
}

variable "vm_ipv4_address" {
  description = "The IPv4 address assigned to the VM, without its network mask."
  type        = string
}

# Maximum amount of memory allocated to the VM in MB.
variable "vm_memory_max" {
  type        = number
  description = "Maximum RAM allocation for the virtual machine, specified in megabytes. E.g. for 4 Gb, type '4096'."
}

# Minimum amount of memory allocated to the VM in MB.
variable "vm_memory_min" {
  type        = number
  description = "Minimum RAM allocation for the virtual machine, specified in megabytes. E.g. for 4 Gb, type '4096'."
}

# Name or label for identifying the VM.
variable "vm_name" {
  type        = string
  description = "Name used to identify the virtual machine."
}

# Order to start the VM
variable "vm_startup_order" {
  type        = string
  description = "A number to set the order of the VM starting."
}

# Number of CPU sockets allocated to the VM.
variable "vm_socket_number" {
  type        = number
  description = "Number of CPU sockets assigned to the virtual machine. Don't assign more virtual sockets than you have physically."
}

File “vm.tf”:

hcl
### vm.tf
terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.107"
    }
    random = {
      source = "hashicorp/random"
      version = "3.7.2"
    }
  }
}

provider "proxmox" {
  endpoint  = var.pve_host_address
  api_token = var.pve_api_token
  insecure  = true
  ssh {
    agent    = true
    username = "root"
  }
  tmp_dir = var.tmp_dir
}

# cloud-image Ubuntu 24 avec cloud-init
resource "proxmox_virtual_environment_download_file" "ubuntu24_cloudimg_20260518" {
  checksum           = "53fdde898feed8b027d94baa9cfe8229867f330a1d9c49dc7d84465ee7f229f7"
  checksum_algorithm = "sha256"
  content_type       = "iso"
  datastore_id       = var.vm_datastore_id
  file_name          = "ubuntu-24.04-server-20260518-cloudimg-amd64.img"
  node_name          = var.node_name
  upload_timeout     = 360
  url                = "https://cloud-images.ubuntu.com/noble/20260518/noble-server-cloudimg-amd64.img"
}

resource "proxmox_virtual_environment_vm" "vm1_example" {
  depends_on = [proxmox_virtual_environment_download_file.ubuntu24_cloudimg_20260518]

  description         = var.vm_description
  keyboard_layout     = "fr"
  machine             = "q35"
  migrate             = true
  name                = var.vm_name
  node_name           = var.node_name
  scsi_hardware       = "virtio-scsi-single"
  started             = true
  stop_on_destroy     = true # Force stop when destroying
  tablet_device       = false # VM without GUI
  tags                = var.tags
  timeout_create      = 180
  timeout_shutdown_vm = 30
  timeout_stop_vm     = 30
  vm_id               = var.vm_id

  agent {
    enabled = true
    timeout = "5m"
    trim    = true
  }

  cpu {
    cores   = var.vm_cpu_cores_number
    flags   = []
    numa    = true
    sockets = var.vm_socket_number
    type    = var.vm_cpu_type
  }

  disk {
    aio          = "native"
    cache        = "none"
    datastore_id = var.vm_datastore_id
    discard      = "on"
    file_format  = var.vm_disk_file_format
    file_id      = "${proxmox_virtual_environment_download_file.ubuntu24_cloudimg_20260518.datastore_id}:iso/${proxmox_virtual_environment_download_file.ubuntu24_cloudimg_20260518.file_name}"
    interface    = "scsi0"
    iothread     = true
    replicate    = false
    size         = var.vm_disk_size
  }

  efi_disk {
    datastore_id      = var.vm_datastore_id
    pre_enrolled_keys = false
    type              = "4m"
  }

  initialization {
    datastore_id = var.vm_datastore_id
    dns {
      domain  = var.cloudinit_dns_domain
      servers = var.cloudinit_dns_servers
    }
    ip_config {
      ipv4 {
        address = "${var.vm_ipv4_address}/16"
        gateway = var.vm_gateway_ipv4
      }
    }
    user_account {
      keys     = var.cloudinit_ssh_keys
      username = var.cloudinit_user_account
    }
  }

  memory {
    dedicated = var.vm_memory_max
    floating  = var.vm_memory_min
  }

  network_device {
    bridge = var.vm_bridge_lan
    model  = "virtio"
  }

  operating_system {
    type = "l26"
  }

  startup {
    order      = var.vm_startup_order
    up_delay   = 15
    down_delay = 30
  }

  serial_device {}

  tpm_state {
    datastore_id = var.vm_datastore_id
    version      = "v2.0"
  }

  vga {
    type = "virtio"
  }
}

File “vm1.tfvars”:

hcl
### vm1.tfvars
cloudinit_dns_domain   = "your.domain.net"
cloudinit_dns_servers  = ["9.9.9.9"]
cloudinit_ssh_keys     = ["ssh-ed25519 changeme"]
cloudinit_user_account = "jho"
node_name              = "pve"
pve_api_token          = "terrabot@pve!token_name=token_secret"
pve_host_address       = "https://pve:8006"
tags                   = ["linux", "opentofu"]
tmp_dir                = "/tmp"
vm_bridge_lan          = "vmbr0"
vm_cpu_cores_number    = 2
vm_cpu_type            = "x86-64-v2-AES"
vm_datastore_id        = "local"
vm_description         = "VM1 Managed by terraform."
vm_disk_file_format    = "raw"
vm_disk_size           = 64
vm_gateway_ipv4        = "172.16.0.254"
vm_id                  = 9992
vm_ipv4_address        = "172.16.241.11"
vm_memory_max          = 8192
vm_memory_min          = 4096
vm_name                = "vm1"
vm_startup_order       = "1"
vm_socket_number       = 1

File “vm2.tfvars”:

hcl
### vm2.tfvars
cloudinit_dns_domain   = "your.domain.net"
cloudinit_dns_servers  = ["9.9.9.9"]
cloudinit_ssh_keys     = ["ssh-ed25519 changeme"]
cloudinit_user_account = "jho"
node_name              = "pve"
pve_api_token          = "terrabot@pve!token_name=token_secret"
pve_host_address       = "https://pve:8006"
tags                   = ["linux", "opentofu"]
tmp_dir                = "/tmp"
vm_bridge_lan          = "vmbr0"
vm_cpu_cores_number    = 2
vm_cpu_type            = "x86-64-v2-AES"
vm_datastore_id        = "local"
vm_description         = "VM2 Managed by terraform."
vm_disk_file_format    = "raw"
vm_disk_size           = 64
vm_gateway_ipv4        = "172.16.0.254"
vm_id                  = 9993
vm_ipv4_address        = "172.16.241.12"
vm_memory_max          = 8192
vm_memory_min          = 4096
vm_name                = "vm2"
vm_startup_order       = "2"
vm_socket_number       = 1

Once the files are created, you’ll need to add an argument to the tofu command line to plan, apply, or destroy the changes.

bash
# VM 1
tofu plan -var-file=vm1.tfvars -out vm1plan
tofu apply vm1plan
tofu destroy -var-file=vm1.tfvars

# VM 2
tofu plan -var-file=vm2.tfvars -out vm2plan
tofu apply vm2plan
tofu destroy -var-file=vm2.tfvars

Using a single .tfvars file with multiple resource blocks#

Right now, this is probably my favorite method. It relies on using the for_each loop within the resource blocks, offering an efficient and clean approach to managing multiple virtual machines. The main advantage lies in sharing the configurations, which makes maintenance much easier. With a single VM-creation block, you can theoretically manage an unlimited number of instances depending on your needs.

This method also requires a file containing the general variables along with another file defining the essential resources. On top of that, the variables.auto.tfvars file plays a crucial role, since it contains the specific blocks for each virtual machine you want.

File “variables.tf” — there’s one big variable that holds a subset of different arguments, each with its own values.

hcl
variable "vm" {
  type = map(object({
    bridge             = string
    cpu_cores          = number
    cpu_type           = string
    description        = string
    disk_efi_datastore  = string
    disk_size          = number
    disk_vm_datastore  = string
    dns_servers        = list(string)
    domain             = string
    firewall_enabled    = bool
    gw                 = string
    hostname           = string
    ipv4               = string
    net_mac_address    = string
    net_rate_limit     = number
    node               = string
    ram_max            = number
    ram_min            = number
    start_on_boot      = bool
    started            = bool
    startup_order      = string
    tags               = list(string)
    vm_id              = number
  }))
}

variable "iso_datastore_id" {
  type        = string
  description = "ID of the datastore to store the ISO and IMG disks."
}
variable "iso_node" {
  type        = string
  description = "Name of the node which is used to connect to the ISO and IMG store."
}

File “vm.tf”:

hcl
resource "proxmox_virtual_environment_download_file" "ubuntu24_cloudimg_20260518" {
  checksum           = "53fdde898feed8b027d94baa9cfe8229867f330a1d9c49dc7d84465ee7f229f7"
  checksum_algorithm = "sha256"
  content_type       = "iso"
  datastore_id       = var.iso_vm_datastore
  file_name          = "ubuntu-24.04-server-20260518-cloudimg-amd64.img"
  node_name          = var.iso_node
  upload_timeout     = 180
  url                = "https://cloud-images.ubuntu.com/noble/20260518/noble-server-cloudimg-amd64.img"
}

resource "proxmox_virtual_environment_vm" "vm" {
  depends_on = [proxmox_virtual_environment_download_file.ubuntu24_cloudimg_20260518]

  for_each = var.vm

  bios                = "seabios"
  description         = each.value.description
  keyboard_layout     = "fr"
  machine             = "q35"
  migrate             = true
  name                = each.value.hostname
  node_name           = each.value.node
  on_boot             = each.value.start_on_boot
  scsi_hardware       = "virtio-scsi-single"
  started             = each.value.started
  stop_on_destroy     = true
  tablet_device       = false
  tags                = each.value.tags
  timeout_create      = 180
  timeout_shutdown_vm = 30
  timeout_stop_vm     = 30
  vm_id               = each.value.vm_id

  agent {
    enabled = true
    timeout = "5m"
    trim    = true
  }

  cpu {
    cores   = each.value.cpu_cores
    numa    = true
    sockets = 1
    type    = each.value.cpu_type
  }

  disk {
    aio          = "native"
    cache        = "none"
    datastore_id = each.value.disk_vm_datastore
    discard      = "on"
    file_id      = proxmox_virtual_environment_download_file.ubuntu24_cloudimg_20260518.id
    iothread     = true
    interface    = "scsi0"
    replicate    = false
    size         = each.value.disk_size
  }

  efi_disk {
    datastore_id      = each.value.disk_efi_datastore
    pre_enrolled_keys = false
    type              = "4m"
  }

  initialization {
    datastore_id = each.value.disk_vm_datastore

    dns {
      domain  = each.value.domain
      servers = each.value.dns_servers
    }

    ip_config {
      ipv4 {
        address = each.value.ipv4
        gateway = each.value.gw
      }
    }
  }

  memory {
    dedicated = each.value.ram_max
    floating  = each.value.ram_min
  }

  network_device {
    bridge      = each.value.bridge
    firewall     = each.value.firewall_enabled
    mac_address = each.value.net_mac_address
    rate_limit  = each.value.net_rate_limit
  }

  operating_system {
    type = "l26"
  }

  serial_device {}

  startup {
    order      = each.value.startup_order
    up_delay   = 15
    down_delay = 60
  }

  tpm_state {
    datastore_id = each.value.disk_vm_datastore
    version      = "v2.0"
  }

  vga {
    type = "virtio"
  }
}

And finally, the “parameters.auto.tfvars” file (you can name it something else, but it must keep the .auto.tfvars extension):

hcl
vm = {
  "vm01" = {
    bridge             = "vmbr0"
    cpu_cores          = 1
    cpu_type           = ""
    description        = "Managed by OpenTofu. VM1"
    disk_efi_datastore = "zfsvm"
    disk_size          = 32
    disk_vm_datastore  = "zfsvm"
    dns_servers        = ["9.9.9.9", "1.1.1.1", "1.0.0.1"]
    domain             = "test.home.arpa"
    firewall_enabled   = false
    gw                 = "192.168.1.254"
    hostname           = "vm01"
    ipv4               = "192.168.1.21/24"
    net_mac_address    = "BC:24:11:D0:D0:21"
    net_rate_limit     = 0
    node               = "pve"
    ram_max            = 4096
    ram_min            = 4096
    start_on_boot      = true
    started            = true
    startup_order      = "1"
    tags               = ["opentofu", "ubuntu24"]
    vm_id              = 555501
  }

  "vm02" = {
    bridge             = "vmbr0"
    cpu_cores          = 1
    cpu_type           = ""
    description        = "Managed by OpenTofu. VM2"
    disk_efi_datastore = "zfsvm"
    disk_size          = 32
    disk_vm_datastore  = "zfsvm"
    dns_servers        = ["9.9.9.9", "1.1.1.1", "1.0.0.1"]
    domain             = "test.home.arpa"
    firewall_enabled   = false
    gw                 = "192.168.1.254"
    hostname           = "vm02"
    ipv4               = "192.168.1.22/24"
    net_mac_address    = "BC:24:11:D0:D0:22"
    net_rate_limit     = 0
    node               = "pve"
    ram_max            = 4096
    ram_min            = 4096
    start_on_boot      = true
    started            = true
    startup_order      = "1"
    tags               = ["opentofu", "ubuntu24"]
    vm_id              = 555502
  }
}

iso_datastore_id = "local"
iso_node         = "pve"

With the usual tofu plan -out vmplan and tofu apply vmplan commands, you’ll be able to deploy all the VMs defined by the blocks in the “parameters.auto.tfvars” file. You can target a specific VM by typing (for example) tofu plan -target=proxmox_virtual_environment_vm.vm["vm01"] and its counterpart tofu apply -target=proxmox_virtual_environment_vm.vm["vm01"].

Creating and using a module#

These days this is one of the most popular methods, thanks to its modularity and portability. Modules can be shared and reused easily. So far, I haven’t yet taken the time to try out and implement modules — it’s on the to-do list.

Related articles

#tuto No image
#linux

Create Proxmox LXC containers with Terraform/OpenTofu

These days, task automation and the creation of immutable infrastructures are essential. With this in mind, the use of tools such as Terraform or its open-source fork OpenTofu becomes particularly interesting for the creation of LXC, automated and in mass.

Read more

Latest in #linux