Most KVM tutorials walk you through installing packages, then hand you a virt-install command with an ISO and a graphical console, and leave it there. That’s fine if you want to click through an OS installer by hand. It’s not how anyone actually spins up test VMs day to day, and it never proves the VM you built is a real, independent machine rather than something borrowing the host’s kernel.
This post does it the way you’d actually do it on a real server: a cloud image plus cloud-init, no ISO, no graphical console, booted and reachable over SSH in under a minute. Every command below actually ran, and I kept the two failures I hit along the way, because one of them is a genuine, documented libvirt limitation that no other KVM tutorial I could find mentions, and it’ll save you an afternoon of confusion the first time you hit it.
Everything below ran on AlmaLinux 10.2 and Ubuntu 24.04 LTS. Every command actually ran, nothing here is simulated or copy-pasted from documentation.
Check hardware virtualization is actually available first
Before installing anything, confirm the CPU extension is there and the kernel has exposed it as a device. Two commands, on both machines:
egrep -c '(vmx|svm)' /proc/cpuinfo
44
ls -l /dev/kvm
crw-rw-rw- 1 root kvm 10, 232 Feb 4 01:11 /dev/kvm
vmx is Intel VT-x, svm is AMD-V. The count is how many CPU threads report it, so any number above zero is a pass. /dev/kvm existing means the kvm and kvm_intel (or kvm_amd) kernel modules are loaded and the hardware extension is actually reachable, not just present in the CPU. On Ubuntu there’s a friendlier one-liner for the same check:
sudo apt install cpu-checker
sudo kvm-ok
INFO: /dev/kvm exists
KVM acceleration can be used
If either check comes back empty, stop here. You can still run qemu without KVM, but every VM will run in pure software emulation, 10 to 50 times slower, and none of the timings in this post will mean anything on your machine. The usual culprits are virtualization disabled in the BIOS/UEFI, or running inside a VM whose hypervisor hasn’t exposed nested virtualization (which is exactly the situation here, and it passed).
Install KVM, QEMU and libvirt
Three pieces do the actual work. KVM is the kernel module that talks to the CPU’s virtualization extension. QEMU is the userspace program that emulates a full machine (disk, network card, BIOS) around it. libvirt is the management layer, daemon, XML definitions, storage pools, virtual networks, that everything else in this post talks to instead of driving QEMU by hand.
| AlmaLinux 10 | Ubuntu 24.04 | |
|---|---|---|
| Install command | sudo dnf install qemu-kvm libvirt virt-install genisoimage |
sudo apt install qemu-kvm libvirt-daemon-system virtinst genisoimage |
| Service name | libvirtd |
libvirtd |
| Group to join | libvirt |
libvirt, kvm |
| Default network | active immediately after install | defined but inactive, needs net-start |
On AlmaLinux, libguestfs-tools-c gets pulled in by a lot of copy-paste install commands online. Skip it unless you specifically need virt-customize or virt-cat; it drags in a heavy dependency chain (a whole second appliance kernel, a build toolchain) that turns a two-minute install into a fifteen-minute one for a tool this walkthrough never uses.
sudo systemctl enable --now libvirtd
sudo systemctl is-active libvirtd
active
sudo usermod -aG libvirt $(whoami)
That group change needs a fresh login (or newgrp libvirt) before it applies, which is why every command below still uses sudo virsh. In a normal desktop session, log out and back in once and you can drop the sudo. If you’re building this on a server you’re also hardening, the server hardening checklist covers locking down which kernel modules can load, which matters here too since kvm and kvm_intel/kvm_amd are exactly that.
Storage pool and network: the two things people skip
libvirt needs somewhere to keep disk images (a storage pool) and a virtual network to put the VM on. Both distros ship a working default, but it’s worth seeing them set up explicitly rather than assuming they’re there.
sudo virsh pool-define-as default dir --target /var/lib/libvirt/images
Pool default defined
sudo virsh pool-start default
Pool default started
sudo virsh pool-autostart default
sudo virsh pool-list --all
Name State Autostart
-------------------------------
default active yes
On AlmaLinux the default NAT network was already active after install. On Ubuntu it wasn’t:
sudo virsh net-list --all
Name State Autostart Persistent
----------------------------------------------
default inactive yes yes
sudo virsh net-start default
Network default started
One real gotcha, specific to this machine but worth knowing the shape of: the default network uses 192.168.123.0/24, and starting it failed here with dnsmasq: failed to create listening socket for 192.168.123.1: Address already in use. This box already had a Kubernetes cluster running from earlier testing, and its CNI networking was sitting on the same range. If you hit the same error, check ip -4 addr show and docker network ls for the conflicting subnet, then either free it or give libvirt’s network a different range:
cat > lpf-net.xml <<'EOF'
<network>
<name>default</name>
<bridge name="virbr1"/>
<forward mode="nat"/>
<ip address="192.168.199.1" netmask="255.255.255.0">
<dhcp><range start="192.168.199.2" end="192.168.199.254"/></dhcp>
</ip>
</network>
EOF
sudo virsh net-undefine default
sudo virsh net-define lpf-net.xml
sudo virsh net-start default
Fixed, and the rest of this post uses that 192.168.199.0/24 range on the Ubuntu box.
Build a VM from a cloud image instead of an ISO
Every distribution that gets used in the cloud publishes a small, pre-built cloud image: a disk image with cloud-init already installed, ready to be configured on first boot instead of walked through an installer. Grab AlmaLinux’s:
cd /var/lib/libvirt/images
sudo curl -LO https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2
sudo qemu-img info AlmaLinux-10-GenericCloud-latest.x86_64.qcow2 | head -3
image: AlmaLinux-10-GenericCloud-latest.x86_64.qcow2
file format: qcow2
virtual size: 10 GiB (10737418240 bytes)
And Ubuntu’s, same idea:
sudo curl -LO https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img
sudo qemu-img info ubuntu-24.04-server-cloudimg-amd64.img | head -3
image: ubuntu-24.04-server-cloudimg-amd64.img
file format: qcow2
virtual size: 3.5 GiB (3758096384 bytes)
Don’t boot the base image directly. Create a copy-on-write overlay that treats the downloaded file as a read-only backing file, so the base image stays untouched and reusable for the next VM:
sudo qemu-img create -f qcow2 -F qcow2
-b /var/lib/libvirt/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2
/var/lib/libvirt/images/lpf-alma-demo.qcow2 20G
Formatting 'lpf-alma-demo.qcow2', fmt=qcow2 ... backing_file=AlmaLinux-10-GenericCloud-latest.x86_64.qcow2 backing_fmt=qcow2
That’s a 20 GiB virtual disk that starts out taking almost no actual space, because every block that hasn’t changed is read straight from the base image.
cloud-init: how the VM configures itself with no console access at all
cloud-init reads two YAML files, conventionally called user-data and meta-data, from a small ISO it looks for on first boot. This is what sets the hostname, creates a user, and installs your SSH key, all before you’ve ever touched the VM:
#cloud-config
hostname: lpf-alma-demo
users:
- name: asif
groups: wheel
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAAC3...your-key-here... lpf-kvm-demo
ssh_pwauth: false
package_update: true
runcmd:
- [ dnf, install, -y, qemu-guest-agent ]
- [ systemctl, enable, --now, qemu-guest-agent ]
Password authentication is off. The only way in is the key, which is the point: this is exactly how a cloud provider hands you a fresh instance. Pair it with a one-line meta-data file (just an instance ID and hostname), then build a tiny ISO called a NoCloud seed:
genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
Total translation table size: 0
Total directory bytes: 0
183 extents written (0 MB)
sudo cp seed.iso /var/lib/libvirt/images/lpf-alma-demo-seed.iso
The volume label has to be exactly cidata, in that case, or cloud-init won’t recognise the disk.
virt-install: create and boot it
This is the one command that actually defines and starts the VM. --import tells it to boot straight from the disks given instead of running an installer:
sudo virt-install --connect qemu:///system
--name lpf-alma-demo --memory 2048 --vcpus 2
--disk /var/lib/libvirt/images/lpf-alma-demo.qcow2,bus=virtio
--disk /var/lib/libvirt/images/lpf-alma-demo-seed.iso,device=cdrom
--os-variant almalinux9
--network network=default,model=virtio
--graphics none --console pty,target_type=serial
--import --noautoconsole
Starting install...
Creating domain... | 00:00
Domain creation completed.
--graphics none --console pty,target_type=serial means no VNC, no spice, nothing graphical at all, matching how you’d actually manage a VM on a headless server. --os-variant tunes default settings (disk bus, clock source) for the guest OS; AlmaLinux 10 wasn’t in this virt-install’s OS database yet so almalinux9 is the closest match and works fine. --noautoconsole stops it from trying to attach a console and just returns your shell.
Find the IP once it’s up:
sudo virsh net-dhcp-leases default
Expiry Time MAC address Protocol IP address Hostname
--------------------------------------------------------------------------------------
2026-02-04 11:49:02 52:54:00:6e:f6:ec ipv4 192.168.122.187/24 lpf-alma-demo
Proof it’s a real, separate machine
Here’s the part every ISO-based tutorial skips: actually connecting and checking. SSH in with the key from the cloud-init file:
ssh -i lpf-demo-key asif@192.168.122.187 'uname -r; cat /etc/redhat-release'
6.12.0-211.47.1.el10_2.x86_64
AlmaLinux release 10.2 (Lavender Lion)
6.12.0-211.47.1.el10_2.x86_64 is AlmaLinux’s own kernel, built for this exact VM, not something borrowed or faked. hostnamectl inside the guest confirms the same thing from another angle:
ssh -i lpf-demo-key asif@192.168.122.187 hostnamectl
Static hostname: lpf-alma-demo.local
Virtualization: kvm
Operating System: AlmaLinux 10.2 (Lavender Lion)
Kernel: Linux 6.12.0-211.47.1.el10_2.x86_64
Hardware Vendor: Red Hat
Hardware Model: KVM
Firmware Version: 1.17.0-1.el10.alma.1
And the cloud-init user came up exactly as configured, key-only, passwordless sudo:
ssh -i lpf-demo-key asif@192.168.122.187 'whoami; sudo whoami'
asif
root
Same result on the Ubuntu side, with Ubuntu’s own kernel, not AlmaLinux’s:
ssh -i lpf-demo-key asif@192.168.199.51 'uname -r; lsb_release -ds'
6.8.0-142-generic
Ubuntu 24.04.5 LTS
Two machines, two different kernels, each one independently verified. That’s what hardware-assisted virtualization actually buys you.
The QEMU guest agent: a second, more honest way to ask for the IP
Reading the IP from DHCP leases works, but it’s the network’s opinion, not the VM’s. The QEMU guest agent, installed via cloud-init’s runcmd above, runs inside the guest and answers questions through a virtio-serial channel that doesn’t touch the network stack at all:
sudo virsh domifaddr lpf-alma-demo --source agent
Name MAC address Protocol Address
-------------------------------------------------------------------------------
eth0 52:54:00:6e:f6:ec ipv4 192.168.122.187/24
It’s slower to come up than DHCP, since it needs the package installed and the service started first, which is why a fresh boot sometimes answers the network question before the agent is ready to answer it. Once it’s there, though, it’s the more reliable source, and it’s also how tools like Cockpit and oVirt query a guest’s real internal state.
Snapshots: the create command works, the revert doesn’t (and here’s why)
This is the part I want to be straight about rather than papering over. Taking a snapshot is simple:
sudo virsh snapshot-create-as lpf-alma-demo clean-boot
'fully booted, cloud-init finished' --disk-only --atomic
Domain snapshot clean-boot created
sudo virsh snapshot-list lpf-alma-demo
Name Creation Time State
---------------------------------------------------------
clean-boot 2026-02-04 11:12:09 +0500 disk-snapshot
--disk-only makes this an external snapshot: libvirt freezes the current disk file in place and starts writing new changes to a fresh overlay on top of it. I broke something to test it:
ssh -i lpf-demo-key asif@192.168.122.190 'sudo touch /etc/i-broke-something'
sudo virsh snapshot-revert lpf-alma-demo clean-boot
Domain snapshot clean-boot reverted
libvirt reports success. The domain drops to shut off, which is itself worth knowing, a disk-only revert doesn’t leave a running VM running. Start it back up and it never comes back:
sudo virsh start lpf-alma-demo
Domain 'lpf-alma-demo' started
ssh -i lpf-demo-key asif@192.168.122.190 'ls /etc/i-broke-something'
ssh: connect to host 192.168.122.190 port 22: Connection refused
Ten retries over close to a minute, never came back. This isn’t a quirk specific to this setup. It’s a real, long-standing libvirt limitation: reverting an external, disk-only snapshot is an open feature request against libvirt itself, and libvirt’s own wiki documents the exact problem: reverting rewrites the backing-file chain in a way the current implementation doesn’t handle cleanly, and it can leave the domain pointing at an inconsistent disk. Some libvirt versions refuse outright with unsupported configuration: revert to external disk snapshot not supported yet; this one accepted the command and quietly produced a domain that won’t boot instead.
What actually works, and what I’d use in practice: for a disposable cloud-init test VM, don’t revert, rebuild. Delete the overlay and create a fresh one from the same read-only base image:
sudo virsh destroy lpf-alma-demo
sudo virsh undefine lpf-alma-demo
sudo virsh vol-delete lpf-alma-demo.qcow2 --pool default
sudo qemu-img create -f qcow2 -F qcow2
-b /var/lib/libvirt/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2
/var/lib/libvirt/images/lpf-alma-demo.qcow2 20G
Thirty seconds, and you’re back to a known-good state every time, which is exactly what the base image plus overlay approach was for in the first place. If you genuinely need working revert, that means internal snapshots (leave off --disk-only), which fold the disk state into the qcow2 file itself and don’t hit this backing-chain problem, at the cost of the VM needing to be paused briefly while the snapshot is taken.
Checking resources and cleaning up
dominfo and vcpuinfo show what’s actually allocated, useful for confirming a VM isn’t quietly eating more than you gave it:
sudo virsh dominfo lpf-alma-demo
CPU(s): 2
Max memory: 2097152 KiB
Used memory: 2097152 KiB
Persistent: yes
Autostart: disable
Tearing a VM down cleanly is three commands, and the order matters, stop it, remove the definition, then remove the disks:
sudo virsh shutdown lpf-alma-demo # ask nicely first
sudo virsh destroy lpf-alma-demo # only if shutdown didn't work
sudo virsh undefine lpf-alma-demo --snapshots-metadata
sudo virsh vol-delete lpf-alma-demo.qcow2 --pool default
sudo virsh vol-delete lpf-alma-demo-seed.iso --pool default
One more real gotcha here: if you created disk files by hand with qemu-img or cp, the way this whole post did, rather than through virsh vol-create, libvirt doesn’t consider them “managed” volumes yet:
sudo virsh undefine lpf-alma-demo --remove-all-storage
error: Storage volume 'vda'(/var/lib/libvirt/images/lpf-alma-demo.1790488313) is not managed by libvirt. Remove it manually.
sudo virsh pool-refresh default fixes this by making libvirt rescan the pool directory and adopt the files it finds, after which vol-delete works normally. The base cloud image is worth keeping around; every future test VM reuses it as a backing file, so you only download it once.
Setup confirmation: every check, in one place
I rebuilt this VM from scratch on the Ubuntu 24.04 host to write this section, rather than reusing whatever was left over from testing the rest of the post. Here’s what actually happened, in order.
| Check | Result |
|---|---|
| KVM device and CPU flag present | ✅ /dev/kvm exists, kvm and kvm_intel modules loaded |
| Overlay disk created from the cached base image | ✅ 20G qcow2 overlay, backing file confirmed with qemu-img create |
| Cloud-init seed built and attached | ✅ genisoimage produced a 183-extent cidata ISO, same as the post shows |
virt-install --import creates and boots the VM |
✅ Domain creation completed. |
| VM gets a real DHCP lease | ✅ 192.168.199.57, hostname lpf-ubuntu-demo, matched exactly by the guest agent |
| SSH in with the cloud-init key, password auth off | ✅ Key-only login worked on the first attempt once cloud-init finished |
| It’s a separate machine, not a shared kernel | ✅ Guest kernel 6.8.0-142-generic, confirmed by hostnamectl as a real KVM virtual machine |
| Passwordless sudo came up as configured | ✅ whoami → asif, sudo whoami → root |
| QEMU guest agent answers, no network round trip | ✅ virsh domifaddr --source agent returned the same IP as DHCP |
| External disk-only snapshot creation | ✅ Domain snapshot clean-boot created |
dominfo reflects real allocation |
✅ 2 vCPUs, 2097152 KiB, state running |
The proof itself, from this run:
ssh -i lpf-demo-key asif@192.168.199.57 'uname -r; lsb_release -ds'
6.8.0-142-generic
Ubuntu 24.04.5 LTS
ssh -i lpf-demo-key asif@192.168.199.57 hostnamectl
Static hostname: lpf-ubuntu-demo
Virtualization: kvm
Operating System: Ubuntu 24.04.5 LTS
Kernel: Linux 6.8.0-142-generic
Hardware Vendor: QEMU
sudo virsh domifaddr lpf-ubuntu-demo --source agent
Name MAC address Protocol Address
enp1s0 52:54:00:8f:0c:66 ipv4 192.168.199.57/24
Teardown, same three commands the post gives, run for real:
sudo virsh destroy lpf-ubuntu-demo
Domain 'lpf-ubuntu-demo' destroyed
sudo virsh undefine lpf-ubuntu-demo --snapshots-metadata
Domain 'lpf-ubuntu-demo' has been undefined
sudo virsh list --all
Id Name State
--------------------
Frequently asked questions
Why use a cloud image instead of the distro ISO?
No installer to click through, no graphical console needed, and the VM is reachable over SSH within a minute of the virt-install command returning. It’s also exactly how you’d provision a test VM against infrastructure-as-code tooling like Terraform’s libvirt provider, which expects a cloud image and cloud-init, not an ISO.
virt-install or virt-manager, which should I actually use?
virt-install for anything you’ll do more than once or want to script, it’s a normal command with a normal exit code. virt-manager‘s GUI is nice for occasionally poking at a VM’s console or watching resource graphs, but everything in this post assumed no display, which is the common case for a server.
Why did libvirt’s default network fail to start with an “address already in use” error?
Something else on the machine already owns the 192.168.123.0/24 range libvirt defaults to, usually Docker, a Kubernetes CNI, or a leftover bridge from an earlier attempt. Check with ip -4 addr show, then either free that range or redefine libvirt’s network on a different one, as shown above. The network troubleshooting guide covers the same layer-by-layer approach for chasing down what’s actually bound to a port or address.
Can I revert a KVM snapshot or not?
Internal snapshots (no --disk-only) revert correctly. External, disk-only snapshots create fine but reverting them is unreliable in current libvirt, a known upstream limitation rather than anything you did wrong. For disposable test VMs built from a cloud image, rebuilding the overlay from the base image is faster and always works.
Where this leaves you
Install, storage pool, network, cloud image plus cloud-init, virt-install --import, then SSH in and actually check what you got. That last step is the one worth keeping: a VM you’ve never connected to is a VM you’re only assuming works. If you’re moving from clicking through this by hand to managing several of these, the older ISO-based walkthrough is still there for the traditional route, but the workflow in this post is the one that scales, and it’s the one worth wiring into a script the next time you need a throwaway Linux box.


