Getting started · tutorial

Build a template from an ISO

10 min read · 2026-09-02 · vmlab 0.9

A template is a sealed base disk in your local store, keyed by architecture, name and version. Every VM in a lab is a linked clone of one. In this tutorial you build a template from the Ubuntu Server 24.04 installer ISO using the example shipped with vmlab, watch the build run, and then clone a VM from it in a lab of your own.

The example does an unattended install. A cloudinit/ folder is packed into a small ISO that Ubuntu's installer picks up as an autoinstall source, and a short wscript answers the one confirmation prompt by reading the screen. The same pattern, installer media plus answer file plus a script that drives the screen, builds every template under examples/templates/, including Windows Server.

Before you start

Read the template definition

Change into the example directory and open its lab file.

sh
cd vmlab/examples/templates/ubuntu-24.04
examples/templates/ubuntu-24.04/vmlab.wclwcl
// Ubuntu Server 24.04 template (PRD §6.1). The installer ISO is downloaded
// and sha256-verified into the artefact cache; cloudinit/ is packed into a
// CIDATA ISO that subiquity picks up as a NoCloud autoinstall source. The
// provision script answers the autoinstall confirmation and waits for the
// installer to power the VM off. Build with:
//
//   vmlab template build        (run from this directory)

import <vmlab.wcl>

template "ubuntu-24.04" {
  arch    = "x86_64"
  version = "24.04.4"
  profile = "linux-modern"
  cpus    = 2
  memory  = 4GiB
  disk    = 20GiB

  source "iso" {
    url    = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
    sha256 = "e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
  }

  media { kind = "iso" from = "./cloudinit/" label = "CIDATA" }
  nic { nat = true }

  provision "scripts/install.ws" { }
}

A template {} block has four parts.

The script is short. It uses the screen, not the agent, because the agent does not exist until the installer has put it there.

examples/templates/ubuntu-24.04/scripts/install.wsrust
// Build provision for the ubuntu-24.04 template (PRD §6.1, §10.4).
// Subiquity finds the autoinstall config on the CIDATA ISO but, without
// `autoinstall` on the kernel command line, asks for confirmation first —
// answer it, then wait for the installer to power the VM off
// (`shutdown: poweroff` in cloudinit/user-data). The sealed image carries
// the vmlab guest agent (installed offline from the VMLAB ISO; the build
// verifies it with one extra boot), so lab clones come up "ready".

use vmlab

fn install(lab: Lab) -> Result[unit, string] {
    let vm = lab.vm("build")?

    // Nudge GRUB past its menu timeout if it is on screen.
    match vm.wait_for_text("(?i)install ubuntu", 180) {
        Ok(_) => {
            vm.send_keys("enter")?
            lab.log("selected the installer GRUB entry")
        }
        Err(e) => lab.log("no GRUB menu seen, continuing: " + e),
    }

    // Subiquity: "Continue with autoinstall? (yes|no)".
    match vm.wait_for_text("(?i)continue with autoinstall", 900) {
        Ok(_) => {
            vm.type_text("yes\n")?
            lab.log("autoinstall confirmed")
        }
        Err(e) => lab.log("no confirmation prompt seen, assuming unattended boot: " + e),
    }

    lab.log("installing (takes several minutes)...")
    vm.wait_shutdown(3600)?
    lab.log("installer powered the VM off; ready to seal")
    Ok(())
}

fn main(lab: Lab) {
    install(lab).expect("ubuntu-24.04 build failed")
}

wait_for_text OCRs the screen until a regular expression matches or the timeout passes. send_keys and type_text inject input. wait_shutdown blocks until the guest powers itself off, which the autoinstall's shutdown: poweroff line does at the end of the install. The agent is installed by the guest itself: a late-commands entry in cloudinit/user-data mounts the bootstrap ISO vmlab attaches under the label VMLAB and runs its install.sh into the target filesystem.

The agent comes from the guest's own install hook

vmlab attaches the bootstrap ISO to every template build and verifies the agent's handshake before sealing. It does not push the agent in itself, because there is no channel into a guest until the agent is running. Each OS has its own hook: a cloud-init late-commands or runcmd, or a Windows FirstLogonCommands entry in autounattend.xml.

Build it

sh
vmlab validate
vmlab template build

template build reads every template {} block in ./vmlab.wcl, or the file named with -f, and builds each in turn. Pass a name to build only one. The build streams its progress, and each lab.log line in the script appears as it runs. To watch the installer, set gui = true on the template block and a viewer opens on the build VM.

The build proceeds through these stages.

  1. Download the ISO into the artefact cache and verify its SHA-256, or reuse the cached copy.
  2. Pack cloudinit/ and the bootstrap ISO. Create the 20 GiB working disk.
  3. Boot the build VM from the installer ISO with the hardware from the profile and the two built ISOs attached.
  4. Run scripts/install.ws: confirm the autoinstall, then wait for the guest to power off.
  5. Boot once more and wait for the agent's handshake. This proves the sealed image will report ready as a clone.
  6. Flatten the working disk and move it with its metadata into the store.

The whole run takes several minutes. A failed build leaves nothing in the store, so you can fix the definition and run it again. `vmlab template stop ubuntu-24.04` cancels a build in progress.

The version the build stamps is the block's version with a build counter appended: the first build of 24.04.4 is 24.04.4.0, the next is 24.04.4.1. Pass --version to pin an exact string instead. A version already in the store is refused rather than overwritten.

sh
vmlab template list

The template is in the store

vmlab template list shows a row for ubuntu-24.04 with arch x86_64, version 24.04.4.0 and its size on disk.

Use it in a lab

In a new directory, declare a VM that names the template by its store reference.

vmlab.wclwcl
import <vmlab.wcl>

lab "ubuntu-lab" {

  segment "lan" {
    subnet = "10.91.0.0/24"
    nat    = true
    forward { host_port = 12222 to = "srv:22" }
  }

  vm "srv" {
    template = "x86_64/ubuntu-24.04"
    memory   = 2GiB
    nic { segment = "lan" ip = "10.91.0.10" }
  }
}

A store reference is <arch>/<name>[@<version>]. With no version the newest build is used. @24.04.4 selects the newest build under that prefix, and @24.04.4.0 pins one exactly. cpus, the firmware and the device models are inherited from the template, which recorded them at build time. Only memory is overridden here.

sh
vmlab validate
vmlab up
vmlab exec srv -- lsb_release -a

validate confirms the template exists in the store before up tries to clone it. The clone boots and reports ready as soon as the baked agent answers. The template's autoinstall created a user vmlab with password vmlab, so the forward on port 12222 gives you SSH into the guest too.

sh
ssh vmlab@localhost -p 12222

A VM runs from your own template

vmlab status shows srv ready at 10.91.0.10, and lsb_release reports Ubuntu 24.04.

Keep the store tidy

Every build adds a version. vmlab template clean prunes superseded builds, keeping the newest per template. It only prints what it would remove until you add --yes, and it skips a build that still backs a clone unless you add --force.

sh
vmlab template clean
vmlab template clean --yes

Next steps