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:
| Variable | Default | Notes |
|---|---|---|
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_namespace | ubuntu24-vm / default | |
image_namespace | harvester-public | Where the image lives |
vlan_network_name / vlan_id | vlan100 / 100 | Between 1 and 4094 |
vlan_cluster_network | mgmt | The physical cluster network |
cpu_cores / memory_size / disk_size | 2 / 4Gi / 20Gi | |
ssh_username | ubuntu | Created 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
datasource, not a resource. Terraform doesn’t upload it; it expects the image to exist already. That keeps a large image upload out of everyapply, but it means “upload the image” is a manual prerequisite. wait_for_lease = truemakes 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 = trueon the root disk meansterraform destroyremoves the disk with the VM, rather than leaving an orphaned volume.restart_after_update = truerestarts 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.
Leave a Comment / Ask a Question
Have thoughts or questions on this article? Drop a message below and Faiez will receive an instant notification.