During the hot Ferragosto weekend, I decided to take a break and try out Libvirt. It had been on my to-do list for a long time, but I never had the motivation (or maybe just laziness?) to try it as a replacement for VirtualBox.
The stack I’m going to use will be, KVM (Kernel-based Virtual Machine) built directly into the Linux kernel to handle CPU and memory virtualization, while QEMU emulates the remaining hardware components like storage and graphics. Libvirt acts as the management layer bridging everything together, allowing me to control my VMs through both a graphical interface (virt-manager) and the command line (virsh).
Installation & first steps
-
Installation is straitforward and following packages are requrired:
yay -S libvirt qemu-desktop virt-manager dnsmasq -
Enable all the Libvirt deamons (btw just learnead that at time of writing maitaners are moving from a single monolithic daemon to separate modular daemons)
sudo systemctl enable --now libvirtd.service -
Adds my user to the VM admin group so applications like
virt-managercan run without asking for a root password:sudo usermod -aG libvirt $USER -
To use NAT in the VMs, I need to start the
defaultlibvirt network bridge and set it to automatically:sudo virsh net-start default sudo virsh net-autostart default
Networking
Instead of using the default network that comes out of the box with libvirt, I decided to create my own one that includes dual-stack networking and a custom domain, vm.local, that I can use to refer to my VMs instead of their IP addresses. That last part comes from the network’s <domain> setting, which tells libvirt’s dnsmasq (already serving DHCP on my dedicated bridge) that it’s authoritative for vm.local. Since each VM’s hostname is sent along with its own DHCP request, dnsmasq learns the hostname-to-IP mapping automatically.
<network>
<name>vm-net</name>
<bridge name="virbr-vm" stp="on" delay="0"/>
<forward mode="nat"/>
<domain name="vm.local" localOnly="yes"/>
<ip address="172.16.100.1" netmask="255.255.255.0">
<dhcp>
<range start="172.16.100.2" end="172.16.100.254"/>
</dhcp>
</ip>
<ip family="ipv6" address="fd2f:8c3c:e710:1::1" prefix="64">
</ip>
</network>
An extra thing for IPv6: libvirt won’t NAT it by default, it only routes (since IPv6 wasn’t designed to need NAT). Getting real routable addresses for the VMs was an option (I do have prefix delegation available), but it added more moving parts than it was worth for my usecase. So I kept it simple: a ULA range on the bridge, plus my own masquerade rule in nftables doing for IPv6 what libvirt already does automatically for IPv4.
Firewall
For the VMs to work, some additional firewall rules are needed, so I extended my nftables.conf accordingly.
Debian auto provisioning
Arch Linux is my daily driver on my laptop, but for everything else, I use Debian. Some VMs are persistent, while others have a short lifespan, just enough time to experiment with something fancy and trash them. For this reason, I decided to automate VM provisioning with libvirt-debian-latest. It’s nothing more than a Bash script that uses Packer under the hood to provision a Debian VM in an unattended way. It includes a wizard where I can customize settings based on my needs:
- Hostname & username
- TPM 2.0
- Secure boot
- Storage adapter
Security features
As I said, it could happen that I need a super secure VM with all the most modern security features, but for them to work, some packages are required to be installed on the host (my laptop):
yay -S edk2-ovmf swtpm virt-firmware
More in detail, they are needed for:
- edk2-ovmf: Provides the UEFI firmware itself (OVMF), since QEMU has no built-in UEFI implementation. Includes both the plain firmware and the Secure Boot-capable variant.
- swtpm: Software TPM 2.0 emulator. QEMU only exposes the virtual TPM interface to the guest; swtpm provides the actual chip logic (key storage, PCR measurement, sealing) running as a companion process.
- virt-firmware: Provides
virt-fw-vars, used to enroll Microsoft’s trusted Secure Boot keys into the VM’s UEFI variable store offline. Without it, Secure Boot would be enabled but the key database would be empty, causing shim to fail validation and refuse to boot.
SSH access
On headless VMs, it’s better to work over SSH rather than using the Libvirt console. Before you can SSH into one, you need to find the VM’s IP:
virsh --connect qemu:///system domifaddr <vm-name>
Then connect with:
ssh -i ~/.ssh/libvirt-<vm-name>_ed25519 <vm-user>@<ip>
Troubleshooting
While developing (Claude not me) the provisioning automation we had some issues with Secure Boot, where the Debian installer was failing. To be able to get the real error using the QEMU Console I had to:
-
Click View $\rightarrow$ compatmonitor0.
-
Type the following command into the prompt and press Enter:
sendkey ctrl-alt-f4 -
Click View $\rightarrow$ VGA to switch your display back to the VM screen and view TTY4.
Future improvement
A future improvement (or an additional layer of things that can break, especially because it’s DNS related) is to use the VM name rather than the IP when connecting via SSH. The street is almost done, because I’ll leverage my Secure DNS Networkd Resolved concept by only extending my existing NetworkManager dispatcher script. The same one that routes schwitzd.me to my router’s DNS when I’m on my home SSID, to also route a dedicated domain (say, vm.local) to libvirt’s own dnsmasq on virbr0. What’s left is one small piece:
- A matching block in the script
/etc/NetworkManager/dispatcher.d/50-local-dns-routingthe same up/down pair I already have for my home Wi-Fi, just retargeted atvirbr0and libvirt’s own dnsmasq instead of my router. The network side needs one restart to pick up a<domain>entry. Next VM I spin up, sshmyvminstead of chasing an IP through virsh domifaddr.
Closing words
Overall, VirtualBox is more user-friendly, with tons of options backed directly into the VM settings page, compared to Libvirt, which has only the essential settings. But in the end, my goal is to provision VMs quickly by automating as much as possible using libvirt-debian-latest.
One last point to mention is that GNOME Boxes is even simpler, but the big limitation from my point of view is that it can only access qemu:///session (though the UI is perfectly integrated into GNOME, which is what I miss in Libvirt).