Lesson 06 of 7 · Modules
Terraform for bare metal / libvirt
Terraform beyond the cloud: build free, disposable multi-VM labs on your own Linux machine with KVM/libvirt and cloud images, and see how the same approach extends to bare-metal provisioning systems.
Free, realistic labs on one machine
Cloud labs cost money and need internet. With KVM + libvirt on a Linux machine (a workstation, a home server, or a nested-virtualisation VM), Terraform can build multi-VM environments from standard cloud images in minutes, then destroy them. It's ideal for practising kubeadm clusters, Ansible, Ceph or failure drills.
Instead of renting a whole playground (the cloud), you build a model village on your kitchen table: little houses (VMs) made from the same pre-built kit (a cloud image), each with a name tag (cloud-init). When you're done, you sweep them into the box and build a new village tomorrow.
The building blocks
| Resource | What it is |
|---|---|
libvirt_volume (base) |
The downloaded cloud image (qcow2) |
libvirt_volume (per VM) |
A thin copy-on-write overlay on the base |
libvirt_cloudinit_disk |
An ISO carrying user data (hostname, users, SSH keys) |
libvirt_network (optional) |
An isolated or NAT network for the lab |
libvirt_domain |
The VM itself: CPU, memory, disks, NICs, console |
A three-VM lab
terraform {
required_providers {
libvirt = { source = "dmacvicar/libvirt" } # pin a version you've tested
}
}
provider "libvirt" {
uri = "qemu:///system"
}
resource "libvirt_volume" "base" {
name = "ubuntu-24.04-base.qcow2"
pool = "default"
source = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
format = "qcow2"
}
resource "libvirt_volume" "node" {
count = 3
name = "node-${count.index}.qcow2"
pool = "default"
base_volume_id = libvirt_volume.base.id
size = 21474836480 # 20 GiB (the overlay can grow beyond the base size)
}
resource "libvirt_cloudinit_disk" "init" {
count = 3
name = "init-${count.index}.iso"
pool = "default"
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = "node-${count.index}"
ssh_key = file(pathexpand("~/.ssh/id_ed25519.pub"))
environment = "lab"
})
}
resource "libvirt_domain" "node" {
count = 3
name = "node-${count.index}"
memory = 2048
vcpu = 2
cloudinit = libvirt_cloudinit_disk.init[count.index].id
network_interface {
network_name = "default"
wait_for_lease = true
}
disk {
volume_id = libvirt_volume.node[count.index].id
}
console {
type = "pty"
target_port = "0"
target_type = "serial"
}
}
output "ips" {
value = { for d in libvirt_domain.node : d.name => d.network_interface[0].addresses }
}
(The cloud-init template is the one from lesson 05. The provider's schema has changed between major versions; this example follows the long-standing 0.7-style schema, so check the documentation for the version you pin.)
$ terraform init && terraform apply
$ terraform output ips
{ "node-0" = ["192.168.122.41"], "node-1" = ["192.168.122.42"], "node-2" = ["192.168.122.43"] }
$ ssh ops@192.168.122.41
Now you have three real VMs for a kubeadm lab (see Kubernetes Administration, lesson 09), for about the cost of electricity.
From labs to real bare metal
The same declarative idea extends to physical servers, through providers and APIs for bare-metal provisioning systems (for example MAAS, Tinkerbell/Cluster API integrations, OpenStack Ironic, or vendor Redfish tooling). The workflow is the same (image + cloud-init + declared machines), with BMCs and PXE instead of a hypervisor. That's the heart of Edge Kubernetes & Zero-Touch Provisioning.
Try it: your own lab factory
- Prepare a Linux host with KVM and libvirt; confirm
virsh list --allworks without sudo. - Apply the three-VM configuration; SSH into each node.
- Change
countto 4 and apply: one new VM appears, and the others are untouched. terraform destroyand re-apply; time how long a full rebuild takes.- Turn it into a module with inputs
node_count,memory,vcpuandimage_url, so any lab (kubeadm, Ceph, Ansible) can reuse it.
Going deeper: lab infrastructure
- Create a dedicated libvirt network per lab (isolated or NAT) so labs don't collide, and give VMs static DHCP leases for stable IPs.
- Keep a local mirror of cloud images to avoid repeated downloads (point
sourceat a local path). - Nested virtualisation (a KVM VM running KVM) works for learning, with a performance cost.
- The same module can target cloud-hypervisor or Proxmox providers if that's your environment; the pattern stays the same.
Recap
- KVM + libvirt + cloud images + cloud-init = free, fast, disposable multi-VM labs driven by Terraform.
- Base image + copy-on-write overlays per VM; a cloud-init ISO personalises each one.
- Pin the provider version (its schema has changed over time) and wrap the lab as a module.
- The same pattern scales to bare metal through provisioning systems and BMCs.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.