Harvester is Kubernetes-native HCI: VMs, images and networks are all Kubernetes objects, and there’s a Terraform provider for them. That makes “create a VM on a tagged VLAN, with my SSH key, and the guest agent running” something I can write once and repeat.

This is the module I used: it looks up an image I had already uploaded, creates a VLAN network, and boots an Ubuntu 24.04 VM with cloud-init. The whole thing is a few small files.


What it creates

data   harvester_image          ← existing Ubuntu 24.04 image (already uploaded)
resource harvester_network      ← VLAN network (default: vlan100 on cluster network "mgmt")
resource harvester_virtualmachine
   ├─ NIC on that VLAN, waits for a DHCP lease
   ├─ root disk cloned from the image (auto-deleted with the VM)
   └─ cloud-init: user + SSH key + qemu-guest-agent

Files

providers.tf        provider + version
variables.tf        every knob, with defaults
main.tf             image lookup, VLAN, VM
cloud-config.tpl    cloud-init template
outputs.tf          VM id, name, IP, VLAN id
terraform.tfvars.example

The provider

The provider reads a kubeconfig, which is how you authenticate to Harvester (download it from the Harvester UI):

terraform {
  required_version = ">= 1.3.0"
  required_providers {
    harvester = {
      source  = "harvester/harvester"
      version = "~> 0.6.0"
    }
  }
}

provider "harvester" {
  kubeconfig = var.harvester_kubeconfig_path
}

Variables

Almost everything has a sensible default, so a working run needs just two values:

VariableDefaultNotes
image_name(required)The Harvester resource name of the uploaded image, e.g. image-xxxxx
ssh_public_key(required)Injected through cloud-init
harvester_kubeconfig_path~/.kube/config
vm_name / vm_namespaceubuntu24-vm / default
image_namespaceharvester-publicWhere the image lives
vlan_network_name / vlan_idvlan100 / 100Between 1 and 4094
vlan_cluster_networkmgmtThe physical cluster network
cpu_cores / memory_size / disk_size2 / 4Gi / 20Gi
ssh_usernameubuntuCreated by cloud-init

A terraform.tfvars then looks like the example file, with the image name and your real public key filled in:

vm_name      = "ubuntu24-vm"
cpu_cores    = 2
memory_size  = "4Gi"
disk_size    = "20Gi"

image_name      = "image-xxxxx"       # the real resource name of your Ubuntu 24 image
image_namespace = "harvester-public"

vlan_network_name    = "vlan100"
vlan_id              = 100
vlan_cluster_network = "mgmt"         # usually "mgmt" or your default cluster network

ssh_username   = "ubuntu"
ssh_public_key = "ssh-ed25519 AAAA... you@laptop"

The main file

# Look up the pre-uploaded VM image
data "harvester_image" "ubuntu" {
  name      = var.image_name
  namespace = var.image_namespace
}

# Create the custom VLAN network
resource "harvester_network" "vlan" {
  name                 = var.vlan_network_name
  namespace            = var.vlan_network_namespace
  vlan_id              = var.vlan_id
  cluster_network_name = var.vlan_cluster_network
}

# Create the virtual machine
resource "harvester_virtualmachine" "vm" {
  name                 = var.vm_name
  namespace            = var.vm_namespace
  restart_after_update = true

  cpu    = var.cpu_cores
  memory = var.memory_size

  network_interface {
    name           = "nic-0"
    network_name   = harvester_network.vlan.id
    wait_for_lease = true
  }

  disk {
    name        = "rootdisk"
    type        = "disk"
    size        = var.disk_size
    bus         = "virtio"
    boot_order  = 1
    image       = data.harvester_image.ubuntu.id
    auto_delete = true
  }

  cloudinit {
    user_data = templatefile("${path.module}/cloud-config.tpl", {
      ssh_username   = var.ssh_username
      ssh_public_key = var.ssh_public_key
    })
  }
}

A few details are worth pointing out:

  • The image is a data source, not a resource. Terraform doesn’t upload it; it expects the image to exist already. That keeps a large image upload out of every apply, but it means “upload the image” is a manual prerequisite.
  • wait_for_lease = true makes Terraform wait for the VM’s NIC to get an address, which is why the IP output can be populated once the lease is acquired.
  • auto_delete = true on the root disk means terraform destroy removes the disk with the VM, rather than leaving an orphaned volume.
  • restart_after_update = true restarts the VM after an update, so a change such as CPU or memory doesn’t sit waiting for a manual reboot.

cloud-init

#cloud-config
users:
  - name: ${ssh_username}
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    ssh_authorized_keys:
      - ${ssh_public_key}

package_update: true
packages:
  - qemu-guest-agent

runcmd:
  - systemctl enable --now qemu-guest-agent

The guest agent is not decoration: as I understand it, it’s how Harvester learns the VM’s IP address and can shut it down cleanly. Installing and enabling it at first boot means the VM reports properly from the start.

Outputs

output "vm_ip_address" {
  description = "IP address once the DHCP lease is acquired"
  value       = harvester_virtualmachine.vm.network_interface[0].ip_address
}

The module also outputs the VM id, its name and the VLAN network id.

Running it

terraform init      # downloads the Harvester provider
terraform plan      # shows the network and VM it will create
terraform apply
terraform output vm_ip_address
ssh ubuntu@<that-ip>

The repository has no README of its own, so these are just the standard Terraform steps. If one behaves differently on your Harvester version, trust the provider’s documentation over this post.


Things I’d fix

Don’t commit provider binaries. The repository I pushed has the downloaded provider under .terraform/ checked in — a 66 MB binary. That directory is a local cache that terraform init recreates, so it’s pure weight in git history. Add a .gitignore:

.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars

The state file deserves particular care: it can contain sensitive values, so keep it out of git and, for anything shared, use a remote backend.

Reconsider passwordless sudo. NOPASSWD:ALL is convenient for a lab VM that only accepts an SSH key, but it’s a default you should decide on rather than inherit. For anything beyond a lab, scope it down.

Make the VLAN optional. The module always creates its own network. If the VLAN already exists (or another module owns it), a second apply will conflict. A data lookup with a create_network flag would let it adopt an existing one.

Make it a module. Right now one directory makes one VM. The moment you want three, wrap harvester_virtualmachine in a module with count or for_each, and let the VLAN live once outside it.

The strength of this approach is how little there is to it. A VM, a network and a first-boot script — described once, reviewed in a pull request, and created or destroyed in a minute.