Terraform & Infrastructure as Code›06 · Terraform for bare metal / libvirt
Learning Hub / Delivery & Infrastructure as Code / Terraform & Infrastructure as Code

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.

Practitioner
Key wordslibvirtKVMcloud imagesqcow2 backing filescloud-init disklocal labsbare-metal providers

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

  1. Prepare a Linux host with KVM and libvirt; confirm virsh list --all works without sudo.
  2. Apply the three-VM configuration; SSH into each node.
  3. Change count to 4 and apply: one new VM appears, and the others are untouched.
  4. terraform destroy and re-apply; time how long a full rebuild takes.
  5. Turn it into a module with inputs node_count, memory, vcpu and image_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 source at 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.