How To

How to Create KVM Virtual Machines with virt-builder

The template index that ships with virt-builder still lists Ubuntu 20.04 as its newest Ubuntu, a release that left standard support two years ago. That single fact decides how you use the tool: it is excellent at turning a signed Debian, Fedora or CentOS Stream template into a fully configured qcow2 in about a minute, and useless for the distro most people actually deploy. Both halves of that are worth knowing before you wire it into a provisioning script.

Original content from computingforgeeks.com - post 90158

This guide covers the whole path: installing the libguestfs tooling, listing and reading the OS templates, building a customized image with packages, a hostname, a timezone, an injected SSH key and an uploaded file, inspecting the result offline, importing it as a KVM guest with virt-install, and the two failures that stop a freshly built guest from ever answering on the network. It also covers virt-customize, which applies the same customization options to a current cloud image when no template exists. Everything below was run on Ubuntu 26.04 with virt-builder 1.54.0, libvirt 12.0.0 and QEMU 10.2.1 in August 2026.

Prerequisites

  • A working KVM host with libvirt running. If you are starting from nothing, set the hypervisor up first on Ubuntu or Debian.
  • Around 3 GB of free space per template. Downloads are cached under ~/.cache/virt-builder and reused, so only the first build of a given template pays the download cost.
  • Outbound HTTP to builder.libguestfs.org, plus access to the guest distro’s package mirrors if you intend to use --install.
  • A user in the libvirt and kvm groups. Builds themselves run unprivileged, but importing the finished image needs libvirt access.

Install the guestfs tools

The virt-builder binary lives in the guestfs tools package, alongside virt-customize, virt-sysprep, virt-cat and guestfish. On Debian and Ubuntu:

sudo apt update
sudo apt install -y libguestfs-tools guestfs-tools

RHEL, Rocky Linux, AlmaLinux and Fedora split the same tools slightly differently:

sudo dnf install -y guestfs-tools libguestfs-tools

On openSUSE and Arch:

sudo zypper -n install guestfs-tools
sudo pacman -S --noconfirm libguestfs guestfs-tools

Confirm the binary is present and note the version, because the template index and several flags changed shape across releases:

virt-builder --version

On the Ubuntu host used here that prints:

virt-builder 1.54.0

Fix the kernel permission that blocks unprivileged builds

Every guide repeats that virt-builder needs no root privileges. That is true of the design and false on a stock Debian or Ubuntu host, because libguestfs boots a small appliance built from your host kernel, and Debian packaging ships /boot/vmlinuz-* as mode 0600 owned by root. The first build as a normal user dies before it writes anything.

Error: “supermin exited with error status 1”

The bare message names supermin and tells you nothing useful. Re-run with debugging to see the real cause:

LIBGUESTFS_DEBUG=1 virt-builder debian-13 --output /tmp/test.qcow2

Buried in the trace is the line that matters:

supermin: kernel: picked vmlinuz /boot/vmlinuz-7.0.0-29-generic
cp: cannot open '/boot/vmlinuz-7.0.0-29-generic' for reading: Permission denied
supermin: cp -p '/boot/vmlinuz-7.0.0-29-generic' '/var/tmp/.guestfs-1000/appliance.d.q3fbs1xh/kernel': command failed
virt-builder: error: libguestfs error: /usr/bin/supermin exited with error status 1.

Make the kernel images world readable. There is nothing secret in a distribution kernel that is already published in the archive:

sudo chmod 0644 /boot/vmlinuz-*

That fixes today and breaks again the next time a kernel lands, because dpkg installs the new file at 0600. Register a statoverride so the permission survives upgrades:

sudo dpkg-statoverride --update --add root root 0644 /boot/vmlinuz-$(uname -r)

RHEL-family hosts install kernels at 0600 too, but SELinux and the packaging differ enough that running builds under sudo is the common answer there. The rest of this guide works either way.

Check what the template index actually offers

Two repositories are configured by default: the libguestfs project index and an openSUSE one. List everything they publish:

virt-builder --list

That returns 102 entries here, of which 60 are x86_64 and the rest are aarch64, i686, armv7l, ppc64, ppc64le and one sparc64. Filter to the distro you care about:

virt-builder --list | grep debian

The Debian line-up runs current:

debian-10                x86_64     Debian 10 (buster)
debian-11                x86_64     Debian 11 (bullseye)
debian-12                x86_64     Debian 12 (bookworm)
debian-13                x86_64     Debian 13 (trixie)

The rest of the index does not. This is the newest template per distro family, and it is the single most important thing to check before you plan around the tool:

FamilyNewest templatePractical verdict
Debiandebian-13Current. Use it.
Fedorafedora-43Current. Use it.
CentOS Streamcentosstream-9One release behind.
AlmaLinuxalma-8.5Long stale, no Rocky at all.
Ubuntuubuntu-20.04Past standard support. Do not build on it.
openSUSEopensuse-tumbleweedRolling, so effectively current.
FreeBSDfreebsd-11.1Ancient. Ignore.

So virt-builder is a Debian and Fedora tool in practice. For anything else, skip to the virt-customize section near the end, which applies the identical customization flags to an image you supply yourself.

Read the template notes before you build

Each template carries notes describing what was stripped out of it. Skipping them is how people end up with an image that boots and then refuses SSH:

virt-builder --notes debian-13

The Debian notes spell out the trap and hand you the fix:

Debian 13 (trixie)

This is a minimal Debian install.

This image is so very minimal that it only includes an ssh server
This image does not contain SSH host keys.  To regenerate them use:

    --firstboot-command "dpkg-reconfigure openssh-server"

No host keys means sshd cannot start. Carry that --firstboot-command into every Debian build you make.

Build a customized image in one command

A handful of values repeat across the build, the import and the verification steps, so set them once:

export VB_TEMPLATE="debian-13"
export VB_IMAGE="web-tpl.qcow2"
export VB_FQDN="web01.example.com"
export VM_NAME="web01"

Put the root password in a file rather than on the command line. A --root-password password:secret argument lands in your shell history and is visible in ps output to every user on the box for the duration of the build:

echo 'StrongPassword' > rootpw.txt
chmod 600 rootpw.txt
echo '<?php echo "<h1>Built by virt-builder</h1>"; ?>' > index.php

Now the build. Every customization happens offline inside the libguestfs appliance, so the guest never boots during this:

virt-builder "${VB_TEMPLATE}" \
  --format qcow2 \
  --size 20G \
  --output "${VB_IMAGE}" \
  --hostname "${VB_FQDN}" \
  --root-password file:rootpw.txt \
  --timezone Africa/Nairobi \
  --install nginx,php-fpm,curl,vim \
  --upload index.php:/var/www/html/index.php \
  --ssh-inject root:file:${HOME}/.ssh/id_ed25519.pub \
  --firstboot-command 'dpkg-reconfigure openssh-server'

The bracketed numbers are elapsed seconds, which makes the output a free profiler:

[   4.5] Planning how to build this image
[   4.5] Uncompressing
[   8.2] Resizing (using virt-resize) to expand the disk to 20.0G
[  36.1] Opening the new disk
[  48.1] Setting the hostname: web01.example.com
[  48.9] Setting the timezone: Africa/Nairobi
[  49.0] Installing packages: nginx php-fpm curl vim
[  73.2] Uploading: index.php to /var/www/html/index.php
[  73.2] SSH key inject: root
[  74.0] Installing firstboot command: dpkg-reconfigure openssh-server
[  74.9] Finishing off
                   Output file: web-tpl.qcow2
                   Output size: 20.0G
                 Output format: qcow2
            Total usable space: 19.6G
                    Free space: 18.4G (94%)

Seventy six seconds of wall clock for a fully provisioned 20 GB image. Note where the time went: expanding the disk cost 27.9 seconds and installing four packages cost 24.2 seconds, while setting the hostname, timezone, upload and SSH key together cost under a second. Disk resizing, not customization, is what makes a virt-builder run slow.

virt-builder build output showing phase timings for a customized Debian 13 qcow2 image

Why your .qcow2 file is not qcow2

Drop --format qcow2 and virt-builder writes a raw image regardless of what you called the file. The extension is decoration; the format flag is the only thing that matters:

virt-builder debian-13 --output debian-13-plain.qcow2
qemu-img info debian-13-plain.qcow2

The file has a qcow2 name and a raw body:

image: debian-13-plain.qcow2
file format: raw
virtual size: 6 GiB (6442450944 bytes)
disk size: 1.22 GiB

Sparse allocation saves you from an immediate 6 GB write, but a raw file cannot take snapshots and cannot use a backing chain, so anything that later builds thin clones from this image will fail in a confusing way. Always pass the format explicitly. If you find yourself holding one of these, qemu-img convert will straighten it out.

Inspect the image before you boot it

Booting a guest to find out whether the build worked wastes minutes and teaches you nothing when it fails. The same libguestfs machinery reads files straight out of the image:

virt-cat -a "${VB_IMAGE}" /etc/hostname
virt-cat -a "${VB_IMAGE}" /var/www/html/index.php
virt-ls -a "${VB_IMAGE}" /usr/lib/virt-sysprep/scripts

The hostname, the uploaded payload and the queued firstboot job all come back without a hypervisor involved:

web01.example.com
<?php echo "<h1>Built by virt-builder</h1>"; ?>
5000-0001-dpkg-reconfigure-openssh-server

Checking the timezone the obvious way fails on current Debian, which no longer ships /etc/timezone and keeps only the /etc/localtime symlink. Ask guestfish for the link target instead:

guestfish --ro -a "${VB_IMAGE}" -i ll /etc/localtime

For a full picture of what libguestfs thinks it is looking at, including the OS variant string you will need in a moment:

virt-inspector -a "${VB_IMAGE}" | grep -E '<product_name>|<osinfo>'

One detail the inspection exposes that the build output hides: --upload copies the file with its numeric owner from the host, not with a sensible guest owner. The PHP file above landed as uid 1000 rather than root or www-data, because that is the uid of the account that ran the build. Fix ownership in the same run with a --run-command, or you will chase a permission bug in the guest later.

Import the image with virt-install

Move the finished image where libvirt can reach it. Under a home directory QEMU drops to the libvirt-qemu account, cannot traverse a 0750 home, and refuses to open the disk:

sudo mv "${VB_IMAGE}" /var/lib/libvirt/images/
sudo chown libvirt-qemu:kvm /var/lib/libvirt/images/"${VB_IMAGE}"

A directory storage pool is the tidier home for these if you build more than a handful, and it gives you thin clones from a single base image. That setup is covered in the directory pool guide. Define the domain with --import, which tells virt-install the disk is already installed and needs no boot media:

sudo virt-install \
  --name "${VM_NAME}" \
  --memory 2048 --vcpus 2 \
  --disk path=/var/lib/libvirt/images/"${VB_IMAGE}",format=qcow2,bus=virtio \
  --os-variant debian13 \
  --network network=default,model=virtio \
  --graphics none --import --noautoconsole

Error: “Unknown OS name ‘ubuntu26.04′”

The --os-variant value is validated against osinfo-db, which is packaged separately and lags the distros it describes. On this Ubuntu 26.04 host, osinfo-db 0.20250606 knows Ubuntu up to 25.10 and rejects the release it is running on:

ERROR    Unknown OS name 'ubuntu26.04'. See `--osinfo list` for valid values.

Confusingly, virt-inspector reports ubuntu26.04 for the same image, because libguestfs derives that string itself instead of looking it up. Let virt-install detect the guest and stop treating a miss as fatal:

--osinfo detect=on,require=off

Then check for a DHCP lease, which is the fastest proof the guest reached userspace:

sudo virsh net-dhcp-leases default

Fix the interface name that keeps the guest off the network

On the first import the lease table stays empty. The guest is running, the disk is fine, and nothing is wrong with libvirt. The Debian template hardcodes an interface name in /etc/network/interfaces, and it is not the name your guest will get:

virt-cat -a /var/lib/libvirt/images/"${VB_IMAGE}" /etc/network/interfaces

The stanza at the bottom is the whole problem:

# The primary network interface
allow-hotplug ens2
iface ens2 inet dhcp

A virtio NIC on a q35 machine presents as enp1s0. On the older i440fx machine type the same NIC presents as ens2, which is presumably what the template was generated against. Since virt-install picks the machine type from the OS variant, the name depends on a decision you did not make. Rewrite it during the build with --edit, or afterwards with virt-customize:

sudo virt-customize -a /var/lib/libvirt/images/"${VB_IMAGE}" \
  --edit '/etc/network/interfaces: s/ens2/enp1s0/'

Start the guest again and the lease appears within a few seconds:

 Expiry Time           MAC address         Protocol   IP address          Hostname
-----------------------------------------------------------------------------------
 2026-08-09 22:00:28   52:54:00:a5:6a:e0   ipv4       192.168.122.50/24   web01

Log in with the key that was injected at build time and every customization is already in place, including the SSH host keys the firstboot command regenerated:

ssh [email protected]

Hostname, timezone, the four requested packages, nginx already enabled, and a root filesystem grown to the full 20 GB by the build-time resize:

Terminal showing hostname, timezone, packages and disk size inside a virt-builder Debian 13 guest

From here the guest is an ordinary libvirt domain. The virsh command reference covers day to day management.

Choose between --run and --firstboot

Both options take a shell script. --run executes it inside the build appliance, so the work is finished before the image exists. --firstboot stores it and runs it the first time the guest boots. The same script through both paths gives a clean comparison:

virt-builder debian-13 --format qcow2 --size 10G \
  --output run-test.qcow2 --upload setup.sh:/root/setup.sh --run setup.sh

virt-builder debian-13 --format qcow2 --size 10G \
  --output firstboot-test.qcow2 --firstboot setup.sh

The script installed three packages and wrote a marker file. Measured back to back on the same host:

--run--firstboot
Build wall clock57.60 s47.88 s
Image on disk1.6 GB1.2 GB
Marker file in imagepresentabsent
Packages in imageinstallednot yet
Cost paid at first bootnonefull script runtime

Ten seconds and 400 MB buys you a guest that is ready the moment it boots. Pay that once if you clone the image repeatedly, because the --firstboot cost is paid by every clone, on every first boot, forever. Reserve --firstboot for work that genuinely cannot happen offline: regenerating host keys, anything that needs the guest’s real hostname or MAC, anything that must contact a service the appliance cannot reach.

Queued firstboot scripts are visible in the image, numbered in execution order, which is handy when a guest does something unexpected on its first boot:

virt-ls -a firstboot-test.qcow2 /usr/lib/virt-sysprep/scripts

Build current Ubuntu images virt-builder does not ship

Since the newest Ubuntu template predates two LTS releases, the answer for Ubuntu is to supply the image yourself and drive it with virt-customize, which accepts the same customization flags as virt-builder minus the template machinery. Download the official cloud image, give the container room to grow, then customize:

qemu-img resize ubuntu-web.qcow2 20G

virt-customize -a ubuntu-web.qcow2 \
  --hostname web-ubu.example.com \
  --root-password file:rootpw.txt \
  --timezone Africa/Nairobi \
  --install nginx,php-fpm,curl \
  --upload index.php:/var/www/html/index.php \
  --ssh-inject root:file:${HOME}/.ssh/id_ed25519.pub \
  --run-command 'ssh-keygen -A' \
  --run-command 'systemctl enable nginx'

That took 121 seconds against Ubuntu 26.04, of which 101 went on the package install. Cloud images carry far more preinstalled than the minimal Debian template, so apt has more dependency work to do. Everything else behaves identically, and virt-inspector confirms what was customized.

Error: “Connection refused” on port 22 after a clean build

The ssh-keygen -A above is not optional, and this is the trap that catches everyone moving from templates to cloud images. Cloud images ship with no SSH host keys and expect cloud-init to generate them at first boot. Import one bare, with no seed disk and no metadata service on the network, and cloud-init finds no datasource and disables itself. The guest boots, takes a DHCP lease, and answers ping while sshd refuses every connection:

nc: connect to 192.168.122.113 port 22 (tcp) failed: Connection refused

Confirm it by listing the directory rather than guessing:

virt-ls -a ubuntu-web.qcow2 /etc/ssh | grep host_key

Empty output means sshd has nothing to present and will not start. Generating the keys offline fixed it here, with port 22 open about 25 seconds after boot. The alternative is to keep cloud-init and give it a seed, which is the approach in the cloud-init and virt-install guide. Pick one. Half-disabling cloud-init is what produces unreachable guests.

Growing a cloud image is not a qemu-img resize

The second trap follows from the first. qemu-img resize grows the container and nothing inside it, and with cloud-init out of the picture nothing grows the partition either. The guest boots into a full disk:

/dev/vda1       2.3G  2.2G   77M  97% /

The documented answer is virt-resize, which copies into a larger target and expands the filesystem. It does exactly that, and it also rewrites the partition table:

qemu-img create -f qcow2 resized.qcow2 20G
virt-resize --expand /dev/sda1 ubuntu-web.qcow2 resized.qcow2

The root filesystem grows from 2.4 GB to 18.9 GB as promised. It also moves from /dev/sda1 to /dev/sda4, because Ubuntu’s cloud image puts root at partition 1 with the boot and UEFI partitions at 13, 14 and 15, and virt-resize repacks them in physical order. In a controlled test on this host, with the DHCP lease table flushed between runs, the unresized image took a lease 25 seconds after start and the resized copy never took one in two minutes. That is why virt-resize prints its own warning:

Resize operation completed with no errors.  Before deleting the old disk,
carefully check that the resized disk boots and works correctly.

Take that literally on any cloud image with a multi-partition UEFI layout. Sizing the disk at build time, the way virt-builder’s --size does against a simple single-partition template, is the path that survived every test here. When you must grow a cloud image, keep cloud-init and let its growpart module do it on first boot instead.

Remove the VM and its storage

Build-and-throw-away is the normal rhythm with this tool, so the teardown matters as much as the build. Stop the domain first, gracefully if it will cooperate:

sudo virsh shutdown "${VM_NAME}"
sudo virsh destroy "${VM_NAME}"

Then remove the definition together with its disks. Without --remove-all-storage the qcow2 files stay behind and quietly fill the host, which is how a build loop eats a partition over a weekend:

sudo virsh undefine "${VM_NAME}" --remove-all-storage

The downloaded templates are separate and survive all of this, which is what you want. Clear them only if you need the space back:

rm -f ~/.cache/virt-builder/*

What each build actually costs

Every number below was measured on the same host, an 8 vCPU KVM guest with nested virtualization, so treat them as relative rather than absolute. The shape is what transfers:

OperationWall clockWhat dominates it
First build of a template+30 sDownloading 447 MB, once per template
Plain build, warm cache35 sUncompressing and opening the disk
Same build with --size 20G+28 svirt-resize expanding the partition
Four packages via --install+24 sapt inside the appliance
Hostname, timezone, upload, SSH key<1 sNothing. These are free.
Full customized build76 sResize plus packages
virt-customize on a cloud image121 sapt, on a much fatter base image

The practical reading: resizing and package installation are the only expensive operations, and both are avoidable. Build one base image at the size you actually need, install the packages once, then produce per-host variants with a second virt-customize pass that only sets hostnames and keys. Those passes finish in ten to fifteen seconds. That is the workflow that makes virt-builder worth wiring into automation, and it is a different tool from PXE provisioning, which earns its complexity when the machines are physical.

Keep reading

Install KVM and Virt-Manager on Arch Linux Virtualization Install KVM and Virt-Manager on Arch Linux Virsh Commands Cheatsheet for KVM Virtual Machine Management KVM Virsh Commands Cheatsheet for KVM Virtual Machine Management Install KVM on Debian 13 / Debian 12: Complete Guide KVM Install KVM on Debian 13 / Debian 12: Complete Guide macOS 27 Golden Gate for Developers: What Changed and What Broke Containers macOS 27 Golden Gate for Developers: What Changed and What Broke Run Docker (OCI) Images as LXC Containers on Proxmox VE Containers Run Docker (OCI) Images as LXC Containers on Proxmox VE VMware vSphere and vCenter Central Logs Management with Rsyslog Virtualization VMware vSphere and vCenter Central Logs Management with Rsyslog

Leave a Comment

Press ESC to close