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):
sudo apt install -y qemu-kvm libvirt-daemon-system libvirt-clients \ virtinst cloud-image-utils virt-viewerThat’s it. Six packages:
qemu-kvm— QEMU + KVM kernel modulelibvirt-daemon-system— The management daemonlibvirt-clients— virsh, virt-manager, virt-viewervirtinst— virt-install (the provisioning magic)cloud-image-utils— Tools for cloud image customizationvirt-viewer— Console/VNC viewer for VM displays
Add yourself to the libvirt group so you don’t need sudo every time:
sudo usermod -aG libvirt $USERnewgrp libvirtStart the daemon:
sudo systemctl enable --now libvirtdVerify:
virsh list --allIf 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:
# List all VMsvirsh list --all
# Start/stop/rebootvirsh start ubuntu-vmvirsh shutdown ubuntu-vm # Graceful, waits for OS shutdownvirsh 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 govirsh undefine ubuntu-vm --remove-all-storage
# Show VM detailsvirsh dominfo ubuntu-vmvirsh dumpxml ubuntu-vm # Full XML configThat’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:
wget https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.imgNow, the one-liner to spin up a fully functional VM:
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-dataWait, 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:
#cloud-confighostname: ubuntu-devusers: - name: ubuntu sudo: ALL=(ALL) NOPASSWD:ALL ssh-authorized-keys: - ssh-rsa AAAA...your-public-key...packages: - curl - git - htop - docker.ioruncmd: - usermod -aG docker ubuntu - curl -fsSL https://get.docker.com | bashAnd a bare meta-data file (can be empty, but virt-install expects it):
instance-id: ubuntu-devlocal-hostname: ubuntu-devNow, 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:
# 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 GBqemu-img resize /var/lib/libvirt/images/ubuntu-dev.qcow2 20G
# virt-install with cloud-initvirt-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 \ --noautoconsoleThat’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.
# Get the VM's IP from libvirt's DHCPvirsh domifaddr ubuntu-dev
# SSH inssh 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:
virsh pool-listDefine a new pool (e.g., for NAS storage):
virsh pool-define-as nas-pool dir --target /mnt/nas/libvirtvirsh pool-start nas-poolvirsh pool-autostart nas-poolvirsh pool-info nas-poolNow, reference that pool in virt-install:
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=defaultPools 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:
network: version: 2 ethernets: eth0: dhcp4: true bridges: br0: dhcp4: true interfaces: [eth0]Apply it:
sudo netplan applyNow define a libvirt network that uses that bridge:
<network> <name>host-bridge</name> <forward mode="bridge"/> <bridge name="br0"/></network>Define it:
virsh net-define bridged-network.xmlvirsh net-start host-bridgevirsh net-autostart host-bridgeUse it in virt-install:
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-bridgeYour 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:
virsh snapshot-create-as ubuntu-dev snap1 "Before blowing everything up"List snapshots:
virsh snapshot-list ubuntu-devRevert to a snapshot:
virsh snapshot-revert ubuntu-dev snap1Delete a snapshot:
virsh snapshot-delete ubuntu-dev snap1Snapshots 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:
GRUB_CMDLINE_LINUX="intel_iommu=on" # Intel CPUs# orGRUB_CMDLINE_LINUX="amd_iommu=on" # AMD CPUsUpdate grub and reboot:
sudo update-grubsudo rebootFind your GPU’s PCI ID:
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):
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:
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.0The 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:
ansible-galaxy collection install community.libvirtAn Ansible playbook:
- 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:
ansible-playbook provision-vms.ymlFive 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:
- Disable SELinux/AppArmor for libvirt (not recommended for production)
- Use the default
/var/lib/libvirt/images/(easiest) - Properly label your directory with
chcon -R -t virt_image_t /custom/path(SELinux)
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:
sudo systemctl restart libvirtdsystemd-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:
- Use
cloud-initwith agrowpartmodule (automatically resizes the partition) - Manually run
sudo growpart /dev/vda 1 && sudo resize2fs /dev/vda1inside the VM
Snapshots don’t work with raw disk images — Only QCOW2 supports snapshots. If you’re using raw images and need snapshots, convert to QCOW2:
qemu-img convert -f raw -O qcow2 vm.img vm.qcow2Exporting and Importing VMs
Need to move a VM to another host? Export it:
virsh dumpxml ubuntu-dev > ubuntu-dev.xmlcp /var/lib/libvirt/images/ubuntu-dev.qcow2 /tmp/ubuntu-dev.qcow2On the new host, adjust the disk path in the XML and import:
# Edit ubuntu-dev.xml, change disk path to match new hostvirsh define ubuntu-dev.xmlcp /tmp/ubuntu-dev.qcow2 /var/lib/libvirt/images/ubuntu-dev.qcow2virsh start ubuntu-devDone. 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.