How To

Linux VM Installation on KVM using cloud-init and virt-install

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.

Original content from computingforgeeks.com - post 90070

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.

Terminal showing kvm-ok, cloud-localds seed build, virsh list and cloud-init status done on an Ubuntu 26.04 KVM guest

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 moduleMinimal seedWith package_update + 1 package
package_update_upgrade_installnot run28.066s
config-growpart2.103s3.991s
config-resizefs0.318s3.940s
config-users_groups0.397s1.255s
config-apt_configure0.682s1.133s
config-ssh0.368snot 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.

cloud-init analyze blame comparing a minimal cloud-config against one that installs packages on a KVM guest

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.

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 Run Docker (OCI) Images as LXC Containers on Proxmox VE Containers Run Docker (OCI) Images as LXC Containers on Proxmox VE PC Build Guides for Homelabs, AI, and Self-Hosting Proxmox PC Build Guides for Homelabs, AI, and Self-Hosting Creating Ubuntu & Debian Virtual Machines on OpenNebula Cloud Creating Ubuntu & Debian Virtual Machines on OpenNebula

2 thoughts on “Linux VM Installation on KVM using cloud-init and virt-install”

    • 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.

      Reply

Leave a Comment

Press ESC to close