A cloud image plus a 366 KB seed ISO replaces the entire interactive installer. On the test box a fresh Ubuntu guest came up with its hostname set, a sudo user created, SSH reachable and a web server already running, and nothing was typed at a console. The whole definition of that machine fits in two small text files.
This guide covers KVM cloud-init provisioning end to end with virt-install: pulling an official cloud image, writing user-data and meta-data, packing them into a seed ISO with cloud-localds, importing the disk, and confirming the config actually applied inside the guest. Both current Ubuntu LTS releases are covered, and there is a static-address variant for hosts that cannot rely on DHCP. If the hypervisor is not up yet, start with the KVM hypervisor setup and come back. Every command was run on Ubuntu 26.04 with libvirt 12.0, then repeated against the 24.04 image, in August 2026.
Prerequisites
The host needs hardware virtualization exposed to the OS. On a physical machine that means VT-x or AMD-V enabled in firmware; on a nested setup it means the outer hypervisor passes the CPU flags through. Everything below was run on a nested guest with 8 vCPU and 24 GB RAM, which is more than enough headroom to hold several test guests at once.
Sizing depends on what the guests do, not on the host tooling. libvirt and its daemons are negligible. Budget roughly the sum of the guest memory plus 1 GB for the host, and enough disk for each qcow2 to grow to its declared virtual size. A lab that runs three 20 GB guests wants 8 GB RAM and 80 GB of free space as a floor, not a target.
Install the KVM stack and confirm acceleration
One package name changed and it breaks copy-pasted commands from older guides. The qemu-kvm transitional package no longer exists on Ubuntu 26.04, so apt refuses the install outright:
E: Package 'qemu-kvm' has no installation candidate
The emulator now comes from qemu-system-x86. Install that, libvirt, the provisioning tools and the cloud-image helpers together:
sudo apt update
sudo apt install -y qemu-system-x86 libvirt-daemon-system libvirt-clients \
bridge-utils virtinst cpu-checker cloud-image-utils libosinfo-bin
Add the current user to the libvirt and kvm groups so virsh talks to the system daemon without sudo on every call. Log out and back in for the membership to take effect:
sudo usermod -aG libvirt,kvm $USER
Check that the kernel module is loaded and the device node is present before going further. A missing /dev/kvm means the guests will fall back to software emulation and run roughly an order of magnitude slower:
kvm-ok
sudo virt-host-validate qemu
Acceleration is available when the first command reports the device and every QEMU line from the validator passes:
INFO: /dev/kvm exists
KVM acceleration can be used
QEMU: Checking for hardware virtualization : PASS (VMX)
QEMU: Checking if device '/dev/kvm' exists : PASS
QEMU: Checking if device '/dev/kvm' is accessible : PASS
QEMU: Checking if device '/dev/vhost-net' exists : PASS
QEMU: Checking if device '/dev/net/tun' exists : PASS
The default NAT network must be running, since the guest picks up its address from it. Start it and mark it to come back after a reboot:
sudo virsh net-start default
sudo virsh net-autostart default
Pull the cloud image and set the working variables
Cloud images are pre-installed disks with cloud-init baked in and no user account set. They are small because nothing is provisioned yet: the 26.04 image is 821 MB compressed into a 3.5 GB virtual disk. The guest name and paths repeat throughout the rest of this guide, so pin them once:
export VM_NAME="web01"
export VM_DISK="/var/lib/libvirt/images/${VM_NAME}.qcow2"
export VM_SEED="/var/lib/libvirt/images/${VM_NAME}-seed.iso"
export CI_DIR="$HOME/kvmlab/ci"
mkdir -p "$CI_DIR" "$HOME/kvmlab/images"
Fetch the release image for the Ubuntu version the guest should run. The 26.04 codename is resolute and 24.04 is noble:
cd ~/kvmlab/images
wget https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-amd64.img
# For 24.04:
# wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img
Copy the image into the libvirt pool rather than booting the download directly, because the guest writes to this disk and the pristine copy stays reusable. Grow it in the same pass, since the root filesystem expands to fill whatever it is given on first boot:
sudo cp ubuntu-26.04-server-cloudimg-amd64.img "$VM_DISK"
sudo qemu-img resize "$VM_DISK" 20G
The declared virtual size jumps to 20 GB while the file on disk stays under 1 GB until the guest actually writes:
virtual size: 20 GiB (21474836480 bytes)
disk size: 595 MiB
cluster_size: 65536
Write the user-data file
This file is the machine definition. It sets the hostname, creates accounts, drops in SSH keys and runs commands on first boot. Open it:
sudo vim ~/kvmlab/ci/user-data
The #cloud-config line on top is required. cloud-init reads it as the format marker and silently ignores the file without it, which is the most common reason a seed appears to do nothing:
#cloud-config
hostname: web01
fqdn: web01.example.com
manage_etc_hosts: true
users:
- name: cfgadmin
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo
shell: /bin/bash
lock_passwd: false
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... admin@workstation
package_update: true
packages:
- nginx
runcmd:
- systemctl enable --now nginx
Password login is off in that version because only a key is supplied, which is the right default for anything reachable. For a throwaway lab guest where console access matters more, swap the key block for a password and enable it explicitly:
lock_passwd: false
plain_text_passwd: 'StrongPassword'
ssh_pwauth: true
Add meta-data and build the seed ISO
The meta-data file carries the instance identity. cloud-init keys its “have I run before” check on instance-id, so changing that value is what forces a re-run on an existing disk:
sudo vim ~/kvmlab/ci/meta-data
Two lines are enough for the NoCloud datasource:
instance-id: web01
local-hostname: web01
Pack both files into an ISO that the guest mounts as a CD-ROM. cloud-localds ships in cloud-image-utils and handles the volume label that cloud-init looks for:
cd ~/kvmlab
cloud-localds ci/seed.iso ci/user-data ci/meta-data
sudo cp ci/seed.iso "$VM_SEED"
sudo chown libvirt-qemu:kvm "$VM_DISK" "$VM_SEED"
The resulting image is tiny, which is the point. It carries text, not an installer:
-rw-rw-r-- 1 jmutai jmutai 366K Aug 8 23:18 ci/seed.iso
Create the guest with virt-install
The --import flag is what separates this from an ISO install. It tells virt-install to boot the existing disk instead of starting an installer, so the command defines the machine and hands control straight to the cloud image:
sudo virt-install \
--name "$VM_NAME" \
--memory 3072 \
--vcpus 2 \
--disk path="$VM_DISK",format=qcow2,bus=virtio \
--disk path="$VM_SEED",device=cdrom \
--os-variant ubuntu24.04 \
--virt-type kvm \
--graphics none \
--network network=default,model=virtio \
--import \
--noautoconsole
Creation returns in about a second because nothing is being installed:
Starting install...
Creating domain... | 00:00
Domain creation completed.
Query osinfo-query os for the right --os-variant value when targeting a different distribution. An unknown variant is not fatal but costs the tuned defaults for disk and network models. The wider set of virsh subcommands for managing the guest afterwards lives in the virsh command reference.
Confirm cloud-init actually ran
libvirt reports the lease the guest picked up from the NAT network:
sudo virsh list
sudo virsh domifaddr "$VM_NAME"
An address appears within a few seconds of the domain starting:
Name MAC address Protocol Address
-------------------------------------------------------------------------------
vnet0 52:54:00:5f:02:69 ipv4 192.168.122.59/24
SSH in as the account the seed created and check the status. Anything other than done means cloud-init is still working or hit an error:
ssh [email protected]
cloud-init status
hostnamectl --static
systemctl is-active nginx
Every directive from the seed shows up applied: the hostname, the FQDN, the sudo account, and the package that was requested plus the service enabled by runcmd:
status: done
web01
web01.example.com
uid=1000(cfgadmin) gid=1000(cfgadmin) groups=1000(cfgadmin),27(sudo)
active
HTTP/1.1 200 OK
The same run on the 24.04 image produced identical results, so the seed files are portable across both current LTS releases without edits.

Should cloud-init report error, the log names the failing module directly. Read it with sudo cloud-init analyze blame or open /var/log/cloud-init.log, which records every directive it parsed from the seed.
Error: “Cannot access storage file … Permission denied”
Keeping the qcow2 and the seed ISO in a home directory produces this at creation time, and the domain never starts:
WARNING /home/jmutai/kvmlab/images/web01.qcow2 may not be accessible by the hypervisor.
You will need to grant the 'libvirt-qemu' user search permissions for the following
directories: ['/home/jmutai']
ERROR Cannot access storage file '/home/jmutai/kvmlab/images/web01.qcow2'
(as uid:64055, gid:991): Permission denied
QEMU drops to the unprivileged libvirt-qemu user, which cannot traverse a 750 home directory. Loosening permissions on $HOME works but trades away the protection that mode exists for. Put the disks in the libvirt pool instead, which is what the $VM_DISK and $VM_SEED paths already do:
sudo virsh undefine "$VM_NAME"
sudo cp ~/kvmlab/images/ubuntu-26.04-server-cloudimg-amd64.img "$VM_DISK"
sudo cp ~/kvmlab/ci/seed.iso "$VM_SEED"
sudo chown libvirt-qemu:kvm "$VM_DISK" "$VM_SEED"
Assign a static address with network-config
DHCP is fine on the NAT network. Bridged guests that need a fixed address take a third seed file, passed to cloud-localds with its own flag:
sudo vim ~/kvmlab/ci/network-config
This is netplan version 2 syntax, and the default route belongs under routes. Most guides still show gateway4 here, which is worth avoiding for a reason covered below:
version: 2
ethernets:
enp1s0:
dhcp4: false
addresses:
- 192.168.122.241/24
routes:
- to: default
via: 192.168.122.1
nameservers:
addresses: [1.1.1.1, 8.8.8.8]
Rebuild the seed with the network file included, then create the guest with the same virt-install command as before:
cloud-localds --network-config=ci/network-config ci/seed.iso ci/user-data ci/meta-data
The address is configured before the login prompt appears. On the test box the guest answered ping 30 seconds after virt-install returned, with the route installed as static rather than from DHCP:
inet 192.168.122.241/24 brd 192.168.122.255 scope global enp1s0
default via 192.168.122.1 dev enp1s0 proto static
Interface names come from the guest, not the host. Cloud images on the virtio bus land on enp1s0; a name that does not match leaves the interface unconfigured and the guest unreachable, so confirm it on the console before trusting a static seed in a batch.
Why gateway4 should be replaced
The older gateway4: 192.168.122.1 key still applies correctly and produces the same routing table. It is deprecated, and netplan says so every time it regenerates config:
** (configure:2065): WARNING **: `gateway4` has been deprecated, use default routes instead.
See the 'Default routes' section of the documentation for more details.
The routes form above generates no warning at all. Both were run through the same image on the same host to compare, and the routing table came out identical, so this costs nothing to switch and removes noise from the guest logs.
What the package list costs at boot
cloud-init reports its own timings, so the cost of each directive is measurable rather than guessed. Two guests were built from the same 26.04 image on the same host, one with a minimal seed and one that set package_update and installed a single package.
| cloud-init module | Minimal seed | With package_update + 1 package |
|---|---|---|
| package_update_upgrade_install | not run | 28.066s |
| config-growpart | 2.103s | 3.991s |
| config-resizefs | 0.318s | 3.940s |
| config-users_groups | 0.397s | 1.255s |
| config-apt_configure | 0.682s | 1.133s |
| config-ssh | 0.368s | not in top 5 |
The number that matters is 28.066s. Installing one package through cloud-init cost more than thirteen times the next slowest module, because it pulls the whole apt index first. Kernel boot itself held at 10 to 11 seconds across both guests and was never the bottleneck.

That gap is the argument for baking packages into the image once instead of installing them on every boot. For a handful of guests the 28 seconds is irrelevant; across a fleet rebuilt often it is the entire provisioning budget. Keep packages for the few things that genuinely differ per machine and let the base image carry the rest.
Where this approach stops fitting is bare metal and images that predate cloud-init. Machines that have to build from installation media are better served by PXE boot or an automated kickstart install, both of which drive the installer rather than skipping it. For desktop work the same guest is easier to define through Virt-Manager, and once seeds start getting copy-pasted between machines it is worth moving the whole definition into Terraform. The same seed pattern also works unchanged against a Debian qcow2 cloud image.
Where is the actual article? lol
Fair hit. The body never imported when this post was created, so the page was an empty shell. The full guide is live now, covering the cloud image, user-data and meta-data, the seed ISO built with cloud-localds, and the virt-install import, tested on Ubuntu 26.04 and 24.04.