Skip to content
Go back

KVM/QEMU/libvirt: CLI Workflows

By SumGuy 11 min read
KVM/QEMU/libvirt: CLI Workflows

Clicking through virt-manager isn’t scaling

You’ve got a homelab. A single Proxmox box, or maybe just a beefy server running Ubuntu with KVM. You fire up virt-manager, click through five dialogs, forget to pre-allocate disk space, and 45 minutes later you’ve got one VM. Do that three times and your evening is gone.

Here’s the thing: the entire libvirt stack — KVM, QEMU, libvirtd, virsh, virt-install — was built for automation. The GUI is just a convenience. The command line is where the real work happens.

This article is your ticket to provisioning VMs in under a minute, integrating them with Ansible, snapshots, storage pools, network bridging, and GPU passthrough — all without ever opening a GUI. Your 2 AM self will thank you.

The Stack: From Kernel to Command Line

Let’s demystify the acronym soup. KVM/QEMU/libvirt is really four layers:

KVM (Kernel Virtual Machine) — A Linux kernel module that turns your CPU into a hypervisor. It’s hardware-assisted virtualization using Intel VT-x or AMD-V. Freakishly fast.

QEMU — The userspace emulator. It translates VM instructions, handles I/O, manages memory. KVM accelerates the CPU-heavy stuff; QEMU handles everything else.

libvirtd — A daemon that manages all your VMs as a unified pool. It talks to KVM/QEMU, provides networking, storage, snapshots, and clustering. Think of it as your hypervisor control plane.

virsh + virt-install — Command-line tools. virsh manages existing VMs (start, stop, reboot, snapshot). virt-install creates new VMs from scratch, reading templates or cloud images.

Stack it: kernel (KVM) → emulator (QEMU) → management daemon (libvirtd) → CLI tools (virsh, virt-install).

Getting libvirt Running

Assuming you’re on Ubuntu 22.04 LTS or newer (or any modern Linux):

Terminal window
sudo apt install -y qemu-kvm libvirt-daemon-system libvirt-clients \
virtinst cloud-image-utils virt-viewer

That’s it. Six packages:

Add yourself to the libvirt group so you don’t need sudo every time:

Terminal window
sudo usermod -aG libvirt $USER
newgrp libvirt

Start the daemon:

Terminal window
sudo systemctl enable --now libvirtd

Verify:

Terminal window
virsh list --all

If that works without sudo, you’re good.

virsh Basics: Managing VMs Like You Mean It

virsh is your VM control center. Here are the ones you’ll use 99% of the time:

Terminal window
# List all VMs
virsh list --all
# Start/stop/reboot
virsh start ubuntu-vm
virsh shutdown ubuntu-vm # Graceful, waits for OS shutdown
virsh destroy ubuntu-vm # Hard kill (like yanking the power cord)
# Connect to console (Ctrl+] to exit)
virsh console ubuntu-vm
# Undefine a VM (delete its config, but keep disks)
virsh undefine ubuntu-vm
# Undefine and delete disks in one go
virsh undefine ubuntu-vm --remove-all-storage
# Show VM details
virsh dominfo ubuntu-vm
virsh dumpxml ubuntu-vm # Full XML config

That’s 80% of what you’ll do. virsh also handles snapshots, networks, storage, and a hundred other things, but those basics get you through most days.

One-Liner VM Provisioning with virt-install

Here’s where it gets fun. Ubuntu publishes cloud images — minimal, prebuilt OS images designed for clouds. They’re 200 MB, boot in seconds, and include cloud-init for customization.

Grab the latest Ubuntu 24.04 LTS cloud image:

Terminal window
wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img

Now, the one-liner to spin up a fully functional VM:

Terminal window
virt-install \
--name ubuntu-dev \
--memory 2048 \
--vcpus 2 \
--disk path=/var/lib/libvirt/images/ubuntu-dev.qcow2,size=20,bus=virtio \
--import \
--cloud-init user-data=/path/to/user-data,meta-data=/path/to/meta-data

Wait, you need cloud-init data first. Let me break this down.

Cloud-Init: Automate Everything at Boot

Cloud-init is a tool that runs at first boot, reads user-data and metadata, and configures your VM: hostname, users, SSH keys, packages, services, the works.

Create a user-data file:

user-data
#cloud-config
hostname: ubuntu-dev
users:
- name: ubuntu
sudo: ALL=(ALL) NOPASSWD:ALL
ssh-authorized-keys:
- ssh-rsa AAAA...your-public-key...
packages:
- curl
- git
- htop
- docker.io
runcmd:
- usermod -aG docker ubuntu
- curl -fsSL https://get.docker.com | bash

And a bare meta-data file (can be empty, but virt-install expects it):

meta-data
instance-id: ubuntu-dev
local-hostname: ubuntu-dev

Now, instead of manually configuring every VM, you run virt-install with cloud-init, and 30 seconds later you’ve got a fully provisioned machine with Docker installed and your SSH key baked in.

The Real One-Liner (With Cloud Image)

Here’s the full workflow. Copy the cloud image and let virt-install customize it:

Terminal window
# Copy the base image (virt-install will modify it in-place)
cp ubuntu-24.04-server-cloudimg-amd64.img /var/lib/libvirt/images/ubuntu-dev.qcow2
# Grow the disk to 20 GB
qemu-img resize /var/lib/libvirt/images/ubuntu-dev.qcow2 20G
# virt-install with cloud-init
virt-install \
--name ubuntu-dev \
--memory 2048 \
--vcpus 2 \
--disk path=/var/lib/libvirt/images/ubuntu-dev.qcow2,bus=virtio \
--import \
--cloud-init user-data=/tmp/user-data \
--cloud-init meta-data=/tmp/meta-data \
--network network=default \
--nographics \
--noautoconsole

That’s it. In under 60 seconds, the VM is defined, booted, cloud-init has run, Docker is installed, and you can SSH in as the ubuntu user.

Terminal window
# Get the VM's IP from libvirt's DHCP
virsh domifaddr ubuntu-dev
# SSH in
ssh ubuntu@<ip-from-above>

No clicking. No waiting through a GUI installer. No typos in hostnames.

Storage Pools: Organize Your Disks

By default, libvirt stores disk images in /var/lib/libvirt/images/. But you can define storage pools — named directories (or LVM volumes, or NFS shares) that libvirt manages.

List pools:

Terminal window
virsh pool-list

Define a new pool (e.g., for NAS storage):

Terminal window
virsh pool-define-as nas-pool dir --target /mnt/nas/libvirt
virsh pool-start nas-pool
virsh pool-autostart nas-pool
virsh pool-info nas-pool

Now, reference that pool in virt-install:

Terminal window
virt-install \
--name ubuntu-dev \
--memory 2048 \
--vcpus 2 \
--disk pool=nas-pool,size=20,bus=virtio \
--import \
--cloud-init user-data=/tmp/user-data,meta-data=/tmp/meta-data \
--network network=default

Pools keep your storage organized. You can have fast-ssd for production VMs, slow-nas for backups, whatever.

Networks: Bridging, NAT, Isolation

By default, libvirt creates a default network that uses NAT. Your host is a gateway; VMs get IPs from libvirt’s DHCP pool (usually 192.168.122.0/24).

That’s fine for most homelabs. But if you want your VMs on your LAN so other machines can reach them, you need a bridge.

Bridged Network (VMs on Your LAN)

First, create the bridge on your host. Edit /etc/netplan/01-netcfg.yaml:

/etc/netplan/01-netcfg.yaml
network:
version: 2
ethernets:
eth0:
dhcp4: true
bridges:
br0:
dhcp4: true
interfaces: [eth0]

Apply it:

Terminal window
sudo netplan apply

Now define a libvirt network that uses that bridge:

bridged-network.xml
<network>
<name>host-bridge</name>
<forward mode="bridge"/>
<bridge name="br0"/>
</network>

Define it:

Terminal window
virsh net-define bridged-network.xml
virsh net-start host-bridge
virsh net-autostart host-bridge

Use it in virt-install:

Terminal window
virt-install \
--name ubuntu-dev \
--memory 2048 \
--vcpus 2 \
--disk pool=default,size=20,bus=virtio \
--import \
--cloud-init user-data=/tmp/user-data,meta-data=/tmp/meta-data \
--network network=host-bridge

Your VM gets an IP from your router’s DHCP, lives on your LAN, and all your machines can reach it.

Snapshots: Rewind Time

Before you sudo rm -rf / in a test VM, take a snapshot:

Terminal window
virsh snapshot-create-as ubuntu-dev snap1 "Before blowing everything up"

List snapshots:

Terminal window
virsh snapshot-list ubuntu-dev

Revert to a snapshot:

Terminal window
virsh snapshot-revert ubuntu-dev snap1

Delete a snapshot:

Terminal window
virsh snapshot-delete ubuntu-dev snap1

Snapshots are quick, space-efficient (QCOW2 only), and let you experiment without fear. Your test machine is now risk-free.

GPU Passthrough: When You Need Raw Compute

If you’re running a gaming VM, ML workload, or video transcoding, you need to pass the GPU through directly (not emulated). This requires IOMMU on your CPU and some kernel config.

First, enable IOMMU in BIOS/UEFI (search your motherboard’s manual; it’s usually under “Virtualization” settings).

Then, enable it on the kernel. Edit /etc/default/grub:

Terminal window
GRUB_CMDLINE_LINUX="intel_iommu=on" # Intel CPUs
# or
GRUB_CMDLINE_LINUX="amd_iommu=on" # AMD CPUs

Update grub and reboot:

Terminal window
sudo update-grub
sudo reboot

Find your GPU’s PCI ID:

Terminal window
lspci | grep -i nvidia
# Output: 01:00.0 VGA compatible controller: NVIDIA Corporation GA104 [GeForce RTX 3080]

That 01:00.0 is your PCI address.

Unbind it from the host driver and bind it to vfio (the passthrough driver):

Terminal window
sudo bash -c 'echo 0000:01:00.0 > /sys/bus/pci/drivers/nvidia/unbind'
sudo bash -c 'echo 0000:01:00.0 > /sys/bus/pci/drivers/vfio-pci/bind'

(In practice, you’d automate this in a systemd service or modprobe rule. Google “vfio passthrough ubuntu” for the full setup.)

Then pass it to a VM:

Terminal window
virt-install \
--name gaming-vm \
--memory 8192 \
--vcpus 6 \
--disk pool=default,size=50,bus=virtio \
--import \
--cloud-init user-data=/tmp/user-data,meta-data=/tmp/meta-data \
--network network=default \
--hostdev 0000:01:00.0

The VM boots with direct GPU access. No emulation, full performance.

Ansible Integration: Provisioning at Scale

If you’re spinning up 10 VMs, you want Ansible automating the whole thing. The community.libvirt collection does exactly that.

Install it:

Terminal window
ansible-galaxy collection install community.libvirt

An Ansible playbook:

provision-vms.yml
- hosts: localhost
gather_facts: no
tasks:
- name: Create cloud-init files
copy:
content: |
#cloud-config
hostname: {{ item.name }}
users:
- name: ubuntu
sudo: ALL=(ALL) NOPASSWD:ALL
ssh-authorized-keys:
- {{ lookup('file', '~/.ssh/id_rsa.pub') }}
packages:
- curl
- git
- docker.io
runcmd:
- usermod -aG docker ubuntu
dest: /tmp/{{ item.name }}-user-data
loop:
- { name: "web-01", cpu: 2, mem: 2048 }
- { name: "db-01", cpu: 4, mem: 4096 }
- name: Define VMs
community.libvirt.virt:
name: "{{ item.name }}"
command: define
xml: |
<domain type='kvm'>
<name>{{ item.name }}</name>
<memory unit='MiB'>{{ item.mem }}</memory>
<vcpu>{{ item.cpu }}</vcpu>
<os>
<type arch='x86_64'>hvm</type>
</os>
<devices>
<disk type='file' device='disk'>
<source file='/var/lib/libvirt/images/{{ item.name }}.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<interface type='network'>
<source network='default'/>
<model type='virtio'/>
</interface>
<console type='pty'>
<target type='virtio'/>
</console>
</devices>
</domain>
loop:
- { name: "web-01", cpu: 2, mem: 2048 }
- { name: "db-01", cpu: 4, mem: 4096 }
- name: Start VMs
community.libvirt.virt:
name: "{{ item.name }}"
state: running
loop:
- { name: "web-01", cpu: 2, mem: 2048 }
- { name: "db-01", cpu: 4, mem: 4096 }

Run it:

Terminal window
ansible-playbook provision-vms.yml

Five minutes later, you’ve got a lab with ten fully configured VMs, SSH keys in place, packages installed, and everything ready to deploy.

Common Gotchas and How to Avoid Them

SELinux or AppArmor blocking disk paths — If you define a storage pool in a custom location (like /home/youruser/libvirt/), SELinux or AppArmor might deny libvirtd access. Either:

Networking breaks after rebooting — If you use bridging, the bridge might not come up before libvirtd starts. Set network.service to depend on the bridge being active, or restart libvirtd after networking:

Terminal window
sudo systemctl restart libvirtd

systemd-resolved conflicts with libvirt’s dnsmasq — If your host uses systemd-resolved and libvirt’s default network for DNS, you might get conflicts. Add dns = dnsmasq to /etc/libvirt/qemu.conf and restart libvirtd.

Cloud images don’t grow automatically — After you resize a cloud image with qemu-img resize, the OS doesn’t know the disk is bigger. You need to either:

Snapshots don’t work with raw disk images — Only QCOW2 supports snapshots. If you’re using raw images and need snapshots, convert to QCOW2:

Terminal window
qemu-img convert -f raw -O qcow2 vm.img vm.qcow2

Exporting and Importing VMs

Need to move a VM to another host? Export it:

Terminal window
virsh dumpxml ubuntu-dev > ubuntu-dev.xml
cp /var/lib/libvirt/images/ubuntu-dev.qcow2 /tmp/ubuntu-dev.qcow2

On the new host, adjust the disk path in the XML and import:

Terminal window
# Edit ubuntu-dev.xml, change disk path to match new host
virsh define ubuntu-dev.xml
cp /tmp/ubuntu-dev.qcow2 /var/lib/libvirt/images/ubuntu-dev.qcow2
virsh start ubuntu-dev

Done. The VM and all its state move to the new box.

Why This Matters

virt-manager is fine for clicking around. But once you’ve got a homelab with more than two VMs, you’re not clicking anymore — you’re writing scripts, Ansible playbooks, CI/CD pipelines. The CLI is where all that lives.

KVM/QEMU/libvirt is free, open source, stable, and fast. It runs on any Linux box. You can provision a VM in under a minute, snapshot it before risky experiments, pass hardware through for games or ML, and orchestrate the whole lab with Ansible. Proxmox is great if you want a polished UI, but you’re paying for that convenience. Here, you get the power, you just trade the clicks for keystrokes.

Your 2 AM self — the one debugging a broken service at an ungodly hour — will appreciate not having to hunt through a GUI. A one-liner to spin up a test VM, a snapshot before you nuke the config, and you’re back to bed.

That’s the homelab way.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Owntracks + Home Assistant: Private Location Tracking
Next Post
BirdNET-Pi for Self-Hosted Bird Identification

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts