LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

KVM/libvirt Without Proxmox

kvmlibvirtvirtualizationlinuxqemuvirshhomelabsystems

Proxmox is a fine piece of software. It takes a heap of excellent Linux virtualization primitives — KVM, QEMU, libvirt, LXC, Corosync, ZFS — and wraps them in a web UI that hides most of the complexity. That’s great when you’re spinning up a lab quickly and terrible when something breaks at 2 a.m. and you’ve never looked at the layers underneath. The web UI turns into a black box, and the first time you need to understand why your VM won’t boot or why virsh shows a state the UI disagrees with, you’re reverse-engineering the abstraction instead of operating the system.

This post is for the engineer who wants to run KVM/libvirt directly. No Proxmox, no oVirt, no Cockpit magic — just the primitives: virsh, XML domain definitions, storage pools, virtual networks. We’ll cover what each of those pieces is, how they compose, and the operational workflows (installs, snapshots, migration, backups, networking) that the GUI is papering over.

The actual stack

When you start a VM on modern Linux, the layers involved are:

  1. KVM — a Linux kernel module that exposes hardware virtualization extensions (Intel VT-x, AMD-V) through /dev/kvm. KVM by itself is not a hypervisor in the “creates VMs” sense; it’s an API that lets a userspace process execute guest code at near-native speed.
  2. QEMU — the userspace process. Emulates the rest of the machine: BIOS/UEFI, PCI bus, disk controllers, network cards, keyboard, mouse. Each running VM is a single qemu-system-x86_64 process. KVM accelerates the CPU execution; QEMU handles device emulation.
  3. libvirt — a management daemon (libvirtd, or the newer modular virt*d services) that provides a stable API for managing QEMU processes. Translates persistent XML definitions into QEMU command lines, handles snapshots, storage, networks, migration, events.
  4. virsh — the primary CLI. A thin client over the libvirt API. Everything the web UIs do is available through virsh.
  5. virt-install, virt-manager, virt-viewer — helper tools. virt-install wraps the “create a new VM” workflow. virt-manager is a desktop GUI (one of the few GUI options that doesn’t get in your way). virt-viewer is a SPICE/VNC client for interacting with VM consoles.

Plus a supporting cast: dnsmasq (for NAT networks), openvswitch or bridge-utils (for bridged networks), libvirt-storage-* (LVM, ZFS, Ceph integration), and qemu-guest-agent (the in-guest agent that exposes lifecycle hooks back to libvirt).

The key insight: Proxmox is essentially libvirt with a different web UI and some custom wrappers. Learning libvirt directly is not a detour; it’s a direct path to understanding every Linux hypervisor stack you will meet.

Getting the bits

On Debian/Ubuntu:

sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients \
                 virtinst bridge-utils virt-manager ovmf dnsmasq
sudo usermod -aG libvirt,kvm $USER

On Fedora/RHEL:

sudo dnf install qemu-kvm libvirt libvirt-daemon-config-network \
                 virt-install virt-manager edk2-ovmf
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt $USER

Log out and back in for the group change. Verify:

$ virsh -c qemu:///system list --all
 Id   Name   State
--------------------

$ virsh net-list --all
 Name      State    Autostart   Persistent
----------------------------------------------
 default   active   yes         yes

qemu:///system is the system-wide libvirt connection (root-owned VMs, shared storage pools). qemu:///session is per-user (each user has their own VMs, their own storage). For anything persistent — servers, shared homelab — use qemu:///system.

The XML domain: what a VM actually is

A VM in libvirt is a domain. The canonical representation is an XML document. When you run virsh define foo.xml, libvirt parses the XML, validates it, and stores it under /etc/libvirt/qemu/foo.xml. When you virsh start foo, libvirt reads that XML, constructs a qemu-system-x86_64 command line, and execs it.

Here’s a minimal but realistic domain XML for a Linux VM:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<domain type='kvm'>
  <name>web-01</name>
  <uuid>12345678-abcd-1234-abcd-1234567890ab</uuid>
  <memory unit='GiB'>4</memory>
  <currentMemory unit='GiB'>4</currentMemory>
  <vcpu placement='static'>2</vcpu>
  <os>
    <type arch='x86_64' machine='q35'>hvm</type>
    <loader readonly='yes' secure='no' type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader>
    <nvram template='/usr/share/OVMF/OVMF_VARS.fd'>/var/lib/libvirt/qemu/nvram/web-01_VARS.fd</nvram>
    <boot dev='hd'/>
  </os>
  <features>
    <acpi/>
    <apic/>
    <vmport state='off'/>
  </features>
  <cpu mode='host-passthrough' check='none' migratable='on'/>
  <clock offset='utc'>
    <timer name='rtc' tickpolicy='catchup'/>
    <timer name='pit' tickpolicy='delay'/>
    <timer name='hpet' present='no'/>
  </clock>
  <devices>
    <emulator>/usr/bin/qemu-system-x86_64</emulator>
    <disk type='file' device='disk'>
      <driver name='qemu' type='qcow2' discard='unmap' cache='none' io='native'/>
      <source file='/var/lib/libvirt/images/web-01.qcow2'/>
      <target dev='vda' bus='virtio'/>
    </disk>
    <interface type='network'>
      <source network='default'/>
      <model type='virtio'/>
    </interface>
    <serial type='pty'><target type='isa-serial' port='0'/></serial>
    <console type='pty'><target type='serial' port='0'/></console>
    <channel type='unix'>
      <target type='virtio' name='org.qemu.guest_agent.0'/>
    </channel>
    <graphics type='spice' autoport='yes' listen='127.0.0.1'/>
    <video>
      <model type='virtio' heads='1'/>
    </video>
    <memballoon model='virtio'/>
    <rng model='virtio'>
      <backend model='random'>/dev/urandom</backend>
    </rng>
  </devices>
</domain>

Things worth noticing:

  • <domain type='kvm'> — use KVM acceleration. If this was type='qemu', you’d get software emulation and your VM would run 30x slower.
  • machine='q35' — the modern QEMU machine type. Supports PCIe, UEFI, better device topology. The older pc (i440fx) is still widely used but q35 is what you want for new VMs.
  • <loader> and <nvram> — pointing to OVMF means UEFI firmware, not legacy BIOS. Required for Secure Boot guests and for passing through modern GPUs.
  • <cpu mode='host-passthrough'> — expose the host CPU directly to the guest. Best performance, but the VM cannot migrate to a host with a different CPU generation. For migration-friendly VMs, use mode='host-model' or name a specific baseline (mode='custom' with <model>Haswell-noTSX-IBRS</model>).
  • <disk> with bus='virtio', cache='none', io='native' — virtio drivers (paravirtualized disk, much faster than emulated SATA/IDE), cache='none' (direct I/O, safer for crash consistency), io='native' (use Linux AIO, better throughput than the userspace default).
  • <interface> with model='virtio' — paravirtualized NIC. Again, much faster than emulated e1000.
  • <channel> with org.qemu.guest_agent.0 — the socket through which the host talks to qemu-guest-agent in the VM. Enables filesystem-consistent snapshots (virsh snapshot-create-as --quiesce), IP address discovery (virsh domifaddr), graceful shutdown, and more.

You will hand-edit this XML. You will learn which fields map to what. That’s the cost of not having a web UI pick defaults for you; the reward is that you understand what your VM actually looks like.

Creating a VM: virt-install

Writing XML by hand is tedious. virt-install generates it for you and kicks off the installation in one step:

virt-install \
  --name web-01 \
  --memory 4096 \
  --vcpus 2 \
  --cpu host-passthrough \
  --os-variant ubuntu24.04 \
  --disk size=40,format=qcow2,bus=virtio,cache=none,discard=unmap \
  --network network=default,model=virtio \
  --graphics spice,listen=127.0.0.1 \
  --boot uefi \
  --cdrom /var/lib/libvirt/images/ubuntu-24.04-live-server-amd64.iso \
  --noautoconsole

Then watch the install over SPICE with virt-viewer web-01 or over the serial console (if cloud-init is doing the work) with virsh console web-01.

For cloud images (which is how anyone with taste provisions at scale), skip the ISO and use cloud-init:

virt-install \
  --name web-01 \
  --memory 4096 --vcpus 2 --cpu host-passthrough \
  --os-variant ubuntu24.04 \
  --disk /var/lib/libvirt/images/web-01.qcow2,backing_store=/var/lib/libvirt/images/ubuntu-24.04-server-cloudimg-amd64.img \
  --cloud-init user-data=user-data.yaml \
  --network network=default,model=virtio \
  --import --noautoconsole

The backing_store trick creates a copy-on-write overlay on top of the base cloud image — instant provisioning, minimal disk usage.

user-data.yaml is standard cloud-init:

1
2
3
4
5
6
7
8
9
#cloud-config
hostname: web-01
users:
  - name: alice
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ssh-ed25519 AAAA...
packages:
  - nginx

Once you internalize this, provisioning a new VM is a 30-second job.

Storage pools

Libvirt abstracts disk storage as pools (somewhere disk images live) and volumes (individual images inside a pool). The default pool is a directory at /var/lib/libvirt/images. That’s fine for dozens of VMs. For serious deployments, you want real pools on real storage.

Pool types libvirt understands out of the box:

  • dir — a directory of files. qcow2, raw, whatever.
  • fs — a filesystem on a block device. Same as dir but libvirt mounts/unmounts it.
  • logical — LVM volume group. Each VM gets an LV.
  • disk — raw partitions on a disk.
  • iscsi — iSCSI LUN.
  • rbd — Ceph RBD. Each VM disk is an RBD image.
  • zfs — ZFS pool with zvols.
  • netfs — NFS mount.

For a homelab with SSDs, dir on ext4 or zfs is usually the right call. For production clusters, rbd (Ceph) is the standard because it’s shared across every hypervisor — live migration “just works” because both sides see the same image.

Create an LVM-backed pool:

virsh pool-define-as vg-vms logical --source-name vg_vms --target /dev/vg_vms
virsh pool-build vg-vms
virsh pool-start vg-vms
virsh pool-autostart vg-vms

Now a new VM’s disk can be --disk pool=vg-vms,size=40, and libvirt creates an LV for it automatically. Snapshots, resize, all work through virsh vol-* commands.

qcow2 vs raw vs LVM: qcow2 gets you thin provisioning, snapshots, compression, encryption — but adds some overhead. Raw is slightly faster but fixed-size. LVM thin pools give you qcow2-like features at the block layer with no per-VM overhead. For serious IOPS workloads, LVM-thin or raw on NVMe outperforms qcow2; for homelab and most workloads, qcow2 is fine.

Networking: the layer most people copy-paste

libvirt ships with a NAT network called default that uses dnsmasq to hand out DHCP. VMs on default get a 192.168.122.x/24 IP, reach the outside world via NAT, and are reachable from the host only. That’s fine for isolated test VMs.

Real deployments need one of:

Bridged networking

You want VMs to appear on the same LAN as the host — same subnet as your other devices, each VM reachable from the rest of the network. Create a Linux bridge on the host:

# /etc/systemd/network/20-br0.netdev
[NetDev]
Name=br0
Kind=bridge
# /etc/systemd/network/21-br0.network
[Match]
Name=br0

[Network]
DHCP=yes
# /etc/systemd/network/22-eno1.network
[Match]
Name=eno1

[Network]
Bridge=br0

Restart systemd-networkd. eno1 becomes a slave of br0, and the host’s IP now lives on br0.

Then define the libvirt network:

1
2
3
4
5
<network>
  <name>br0</name>
  <forward mode='bridge'/>
  <bridge name='br0'/>
</network>
virsh net-define br0.xml
virsh net-start br0
virsh net-autostart br0

Now --network network=br0,model=virtio gives VMs a NIC on the LAN. This is the most common “real” setup.

macvtap

An alternative to bridging: macvtap attaches directly to a physical NIC, each VM getting its own MAC. Simpler than a bridge (no br0 to manage) but has a nasty limitation: the host and a macvtap VM cannot communicate with each other by default, because packets from the VM bypass the host’s network stack. Fine for servers, annoying for workstations.

OVS (Open vSwitch)

When you need VLANs, port mirroring, complex flow rules, or SDN features, replace the Linux bridge with OVS. Libvirt integrates: <virtualport type='openvswitch'> in the interface definition, and OVS handles the rest. Overkill for small deployments; essential at scale.

Snapshots: the piece you need to get right

libvirt snapshots have two flavors, and understanding the difference is critical:

Internal snapshots (qcow2)

virsh snapshot-create-as web-01 before-upgrade creates a qcow2 internal snapshot. Fast, atomic, stored inside the same qcow2 file. Simple to roll back with virsh snapshot-revert. The downsides: only work with qcow2, can slow down disk performance as the snapshot tree grows, and are not well-suited to backup workflows because you can’t easily “grab the snapshot and copy it off.”

External snapshots

virsh snapshot-create-as web-01 --name backup-1 \
  --disk-only --atomic --quiesce \
  --diskspec vda,file=/var/lib/libvirt/images/web-01.backup-1.qcow2

This creates a new qcow2 file that becomes the active disk, with the original file as a read-only backing store. Writes go to the new file; the original is frozen. You can then back up the frozen file freely.

Restoring is the opposite:

virsh blockcommit web-01 vda --active --pivot

This rolls the changes from the external snapshot back into the base and pivots the VM to use the base. For backup purposes, this is the flow you want.

--quiesce requires qemu-guest-agent running in the guest. It calls fsfreeze inside the guest so the snapshot is filesystem-consistent. Without it, you’re snapshotting a running filesystem — usually recoverable, occasionally corrupted.

Live migration

libvirt supports live migration — moving a running VM from one host to another without downtime. Requirements:

  1. Both hosts can see the same disk image (shared storage: NFS, Ceph RBD, iSCSI). Otherwise, you need --copy-storage-all to copy the disk as part of migration, which extends the process significantly.
  2. Both hosts have compatible CPUs. host-passthrough is usually fine if both are the same CPU family; otherwise use a compatible baseline model.
  3. Network connectivity on port 49152-49215 (migration traffic).
virsh migrate --live --persistent --undefinesource \
  web-01 qemu+ssh://hv02.internal/system

Flags:

  • --live — don’t pause the VM during migration. Memory is copied in iterations while the VM runs.
  • --persistent — define the VM on the destination.
  • --undefinesource — remove from the source.
  • --copy-storage-all — if storage is not shared.
  • --tunnelled — send migration traffic through the libvirt connection (simpler firewalling).

The copy algorithm iterates: copy all memory, then copy the pages that changed, then pages that changed during that copy, and so on until the dirty-page rate is small enough that a brief pause finishes the job. Usually under a second of downtime.

Nested virtualization

Running KVM inside a KVM guest is possible and usually works. Required on the host:

# Intel
echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm.conf
sudo modprobe -r kvm_intel
sudo modprobe kvm_intel

# AMD
echo "options kvm_amd nested=1" | sudo tee /etc/modprobe.d/kvm.conf
sudo modprobe -r kvm_amd
sudo modprobe kvm_amd

In the guest’s XML:

1
<cpu mode='host-passthrough'/>

Now the guest’s /dev/kvm is available. Use cases: testing Kubernetes, development of hypervisor stacks, running Vagrant boxes inside a VM. Performance takes a hit, but for dev workloads it’s fine.

Operational virsh recipes

A handful of commands you’ll use constantly:

virsh list --all                  # all VMs, running or not
virsh start web-01
virsh shutdown web-01             # graceful (requires ACPI or guest agent)
virsh destroy web-01              # pull the power cord
virsh reboot web-01

virsh console web-01              # serial console (ctrl-] to exit)
virsh domifaddr web-01            # list IPs (guest agent or DHCP lease)

virsh dumpxml web-01 > web-01.xml # export definition
virsh edit web-01                 # open XML in $EDITOR, validates on save

virsh setmem web-01 8G --live     # hot-resize memory (guest must support it)
virsh setvcpus web-01 4 --live    # hot-add CPUs
virsh attach-disk web-01 /path/to/extra.qcow2 vdb --persistent
virsh detach-disk web-01 vdb --persistent

virsh snapshot-list web-01
virsh snapshot-revert web-01 before-upgrade
virsh snapshot-delete web-01 before-upgrade

virsh vol-list default            # list disk images in the 'default' pool
virsh vol-resize web-01.qcow2 60G --pool default

And virt-sparsify, virt-resize, virt-customize, virt-sysprep (all from libguestfs-tools) are invaluable for offline image manipulation — growing a filesystem inside a qcow2, burning a cloud-init seed in, stripping machine-ids for templating.

Hot-resize and memory balloon

The memballoon device in the XML is how libvirt reclaims memory from a running guest. When the host wants memory back, it tells the balloon driver in the guest to “inflate” — the driver allocates pages from the guest kernel, pinning them and effectively handing them back to the host. Works on Linux and Windows (with the virtio drivers installed).

virsh setmem within the --maxmem cap is live and driven by the balloon. If you want to grow past the maxmem, you need <maxMemory> in the XML and a VM restart.

UEFI and Secure Boot

For modern guests (Windows 11, any recent Linux with Secure Boot enabled), you need UEFI firmware. virt-install --boot uefi handles it, or manually:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<os>
  <type arch='x86_64' machine='q35'>hvm</type>
  <loader readonly='yes' secure='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.secboot.fd</loader>
  <nvram template='/usr/share/OVMF/OVMF_VARS.secboot.fd'>/var/lib/libvirt/qemu/nvram/web-01_VARS.fd</nvram>
</os>
<features>
  <acpi/>
  <apic/>
  <smm state='on'/>
</features>

smm state='on' is required for Secure Boot. Windows 11 also wants <tpm> emulated:

1
2
3
<tpm model='tpm-crb'>
  <backend type='emulator' version='2.0'/>
</tpm>

Performance tuning

A few levers that make a real difference:

  • CPU pinning<vcpupin vcpu='0' cpuset='4'/> binds a guest vCPU to a specific host pCPU. Reduces context-switch overhead and improves cache locality. Essential for latency-sensitive guests.
  • Hugepages<memoryBacking><hugepages/></memoryBacking>. Reduces TLB pressure. Major win for memory-heavy guests. Requires hugepages=1G or similar on the host cmdline.
  • NUMA awareness — on multi-socket hosts, pin the VM’s vCPUs to a single NUMA node and allocate memory from the same node. The libvirt <cputune> and <numatune> blocks express this.
  • io_uring — newer QEMU supports io='io_uring' in the disk driver spec. Better than native AIO for high-queue-depth workloads.
  • Multi-queue virtio-net<driver name='vhost' queues='4'/> on the NIC. Lets multiple vCPUs handle network packets concurrently.
  • Discard/trimdiscard='unmap' on disks. When the guest TRIMs, the qcow2 file actually shrinks. Without this, qcow2 images grow monotonically.

Monitoring

libvirt exposes metrics through a Unix socket. Tools:

  • virt-top — top-like display of running VMs.
  • libvirt-exporter — Prometheus exporter for libvirt. Exposes CPU, memory, disk I/O, network I/O per-VM. Scrape with Prometheus and graph in Grafana.
  • virsh domstats — raw dump of all VM metrics in a machine-readable form.

Events (state changes, migration progress) are available via virsh event --all --loop or the C/Python APIs — useful for automation.

Where libvirt is weaker than the GUIs

Let’s be honest about the tradeoffs:

  • No high-availability cluster out of the box. Proxmox has HA/Corosync built in. With plain libvirt, you build HA yourself: shared storage, some orchestration (Pacemaker, a custom script watching VM health, or a product like oVirt on top).
  • No built-in backup scheduling. virsh snapshot-create + qemu-img convert + rsync — you’re stitching it yourself. Or using virtnbdbackup, which is a good third-party option.
  • No multi-tenant user management. libvirt has ACLs but they’re coarse. If you want “this user can manage these VMs,” you wrap it at a higher layer.
  • No built-in web UI. cockpit-machines or kimchi fill the gap if you want one, or you run virt-manager remotely via SSH X forwarding.

These are not reasons to avoid libvirt — they’re reasons to know what you’re building.

A workflow that scales

After running libvirt in anger for a while, an opinionated setup looks like this:

  1. Shared storage via Ceph RBD. Every hypervisor in the cluster sees the same RBD pool. Live migration is a feature, not a project.
  2. Bridged networking on OVS with VLANs. VMs get tagged into the right VLAN per service.
  3. Cloud images + cloud-init for provisioning. Terraform with the libvirt provider makes this declarative.
  4. Prometheus + libvirt-exporter for per-VM metrics.
  5. virtnbdbackup or Bareos for scheduled backups; snapshots for ad-hoc rollback.
  6. Ansible for guest config management, not libvirt.
  7. Host kernel + qemu pinned to stable versions; upgrades done one hypervisor at a time with virsh migrate draining the host first.

That stack runs hundreds of VMs comfortably on a handful of hypervisors and gives you complete visibility into every layer. If anything breaks, you know where to look because nothing is hidden from you. That’s the core reason to skip Proxmox and live in libvirt directly: the system has no black boxes.

Closing

Proxmox and its cousins are great conveniences. When they work, you save time. When they break, you pay for the abstraction — you now need to understand both the GUI’s behaviors and the stack underneath. Running libvirt directly is more typing up front but less magic to debug, and once you’re comfortable the iteration loop is actually faster (virsh + a text editor beats clicking through a web UI for most daily tasks).

And every layer you learn here — KVM, QEMU, libvirt, virsh, domain XML — is portable. It’s the stack OpenStack runs on, the stack oVirt runs on, the stack cloud-hypervisor borrows from. Learn it once, carry it everywhere.

Comments