Ask most people how to run a “real” hypervisor at home and you get the same shortlist: VMware, Hyper-V, or at minimum Proxmox. Something with a web UI, a clustering tab, a marketing page full of enterprise features. That mental model is so common that running virtual machines without one of those products feels like cutting corners. We’ve quietly accepted that serious virtualization comes with a vendor attached.

Now flip it. The thing doing the actual work in all of those products is a Linux kernel module that has been production-grade for over a decade. KVM with libvirt gives you live migration, memory ballooning, CPU pinning, GPU passthrough, SR-IOV, nested virtualization. The features the glossy hypervisors advertise are kernel features. The web UI is a wrapper around them.

I run NixOS as my hypervisor. No Proxmox, no web UI, just declarative Nix configs and virsh. Let me show you where that line actually sits, because the gap between “raw KVM” and “enterprise hypervisor” is narrower than the marketing suggests, and the part that remains is worth being honest about.

The Foundation: KVM and QEMU

Before going further, the stack:

  • KVM (Kernel-based Virtual Machine): A Linux kernel module that turns your kernel into a type-1 hypervisor. Hardware-accelerated, near-native performance.
  • QEMU: The userspace emulator that provides virtual hardware (disks, network cards, etc.) to VMs.
  • libvirt: A management layer that provides a consistent API across different virtualization technologies. Includes the virsh CLI.

This is the same stack from your laptop’s VMs up to massive cloud infrastructure. AWS’s Nitro hypervisor is built on KVM. Google Cloud runs KVM. Most of the cloud you pay for is this technology with a billing system bolted on.

NixOS Configuration

Here’s how I set up NixOS as a hypervisor:

{ config, pkgs, ... }:

{
  # Enable virtualization
  virtualisation.libvirtd = {
    enable = true;
    qemu = {
      package = pkgs.qemu_kvm;
      runAsRoot = true;
      swtpm.enable = true;  # TPM emulation
      ovmf = {
        enable = true;  # UEFI support
        packages = [ pkgs.OVMFFull.fd ];
      };
    };
  };

  # Add yourself to the libvirt group
  users.users.tom = {
    extraGroups = [ "libvirtd" ];
  };

  # Useful tools
  environment.systemPackages = with pkgs; [
    virt-manager    # GUI if you want it
    virt-viewer     # For console access
    libguestfs      # VM image manipulation
    win-virtio      # Windows VirtIO drivers
  ];

  # Bridge networking (optional, for bridged VMs)
  networking.bridges.br0.interfaces = [ "enp1s0" ];
  networking.interfaces.br0.useDHCP = true;
}

Apply this config and you have a fully functional hypervisor. No installer, no setup wizard, no first-boot account creation. The config is the install. Roll it back and the hypervisor is gone the same way it arrived.

Creating VMs with virsh

Let’s create a VM. First, allocate a disk:

# Create a 50GB qcow2 disk
qemu-img create -f qcow2 /var/lib/libvirt/images/ubuntu-server.qcow2 50G

Then define the VM with XML (or use virt-install):

virt-install \
  --name ubuntu-server \
  --memory 4096 \
  --vcpus 2 \
  --disk /var/lib/libvirt/images/ubuntu-server.qcow2 \
  --cdrom /var/lib/libvirt/images/ubuntu-24.04-server.iso \
  --os-variant ubuntu24.04 \
  --network bridge=br0 \
  --graphics vnc

Now you have a running VM. Manage it with:

virsh list --all           # List all VMs
virsh start ubuntu-server  # Start VM
virsh shutdown ubuntu-server  # Graceful shutdown
virsh destroy ubuntu-server   # Force stop
virsh console ubuntu-server   # Serial console

Live Migration

This is the feature people are most sure you need a commercial product for. Moving a running VM between hosts with no real downtime sounds like the kind of thing you pay a license for.

Live migration copies the VM’s memory to the target host while it keeps running, then does the final switchover in milliseconds. The guest never notices.

Prerequisites:

  • Shared storage (NFS, Ceph, GlusterFS) or same disk path on both hosts
  • Same CPU architecture (or use CPU model compatibility)
  • libvirt running on both hosts
# From the source host
virsh migrate --live ubuntu-server qemu+ssh://target-host/system

# With options for better performance
virsh migrate --live --persistent --undefinesource \
  --copy-storage-all \
  ubuntu-server qemu+ssh://target-host/system

One command. The VM is now running on the other host. The --copy-storage-all flag even copies the disk if you don’t have shared storage.

For scripted environments, you can do this programmatically:

#!/bin/bash
# Simple load balancer - migrate VM to least loaded host

HOSTS=("host1" "host2" "host3")
VM="ubuntu-server"

# Find host with lowest load
TARGET=$(for h in "${HOSTS[@]}"; do
  echo "$(ssh $h 'cat /proc/loadavg | cut -d" " -f1') $h"
done | sort -n | head -1 | cut -d" " -f2)

virsh migrate --live $VM qemu+ssh://$TARGET/system

A few lines of bash and you have the load balancer that lives behind a paid HA dashboard.

Memory Ballooning

Memory ballooning lets you adjust VM memory at runtime without a reboot. The guest’s balloon driver hands memory back to the host or asks for more.

Enable it in your VM definition:

<memballoon model='virtio'>
  <address type='pci' domain='0x0000' bus='0x00' slot='0x08' function='0x0'/>
</memballoon>

Then adjust at runtime:

# Set current memory to 2GB (VM has 4GB max)
virsh setmem ubuntu-server 2G --live

# Give it back 4GB
virsh setmem ubuntu-server 4G --live

This is what makes overcommitting memory safe. Allocate 64GB across VMs on a 32GB host and let the balloon driver balance actual usage between them.

GPU Passthrough

This is the one homelab people obsess over: handing a physical GPU straight to a VM for near-native graphics. Gaming VMs, machine learning workloads, hardware transcoding, all of it.

First, configure IOMMU in your NixOS config:

{
  boot.kernelParams = [
    "intel_iommu=on"  # or amd_iommu=on for AMD
    "iommu=pt"
  ];

  # Isolate the GPU from the host
  boot.extraModprobeConfig = ''
    options vfio-pci ids=10de:2204,10de:1aef
  '';

  boot.initrd.kernelModules = [
    "vfio_pci"
    "vfio"
    "vfio_iommu_type1"
  ];
}

Find your GPU’s PCI IDs:

lspci -nn | grep -i nvidia
# 01:00.0 VGA compatible controller [0300]: NVIDIA Corporation ... [10de:2204]
# 01:00.1 Audio device [0403]: NVIDIA Corporation ... [10de:1aef]

Then add the GPU to your VM:

<hostdev mode='subsystem' type='pci' managed='yes'>
  <source>
    <address domain='0x0000' bus='0x01' slot='0x00' function='0x0'/>
  </source>
</hostdev>
<hostdev mode='subsystem' type='pci' managed='yes'>
  <source>
    <address domain='0x0000' bus='0x01' slot='0x00' function='0x1'/>
  </source>
</hostdev>

The VM now talks to the GPU directly. Install the native drivers in the guest and you get full hardware acceleration, no host in the middle.

Network Card Passthrough and SR-IOV

The same principle works for network cards. For 10GbE or 25GbE cards, you might want direct passthrough:

# Find the NIC
lspci -nn | grep -i ethernet

# Pass it through like the GPU

Where it gets interesting is SR-IOV (Single Root I/O Virtualization). It splits one physical NIC into multiple virtual functions, each assignable to a different VM:

# Enable 4 virtual functions on the NIC
echo 4 > /sys/class/net/enp5s0f0/device/sriov_numvfs

# List them
lspci | grep "Virtual Function"

Each VF gets its own PCI address and can be passed to a VM. Hardware-accelerated networking without the overhead of virtio.

CPU Pinning

For latency-sensitive workloads, you want VMs nailed to specific host cores so the scheduler stops shuffling them around:

<vcpu placement='static'>4</vcpu>
<cputune>
  <vcpupin vcpu='0' cpuset='4'/>
  <vcpupin vcpu='1' cpuset='5'/>
  <vcpupin vcpu='2' cpuset='6'/>
  <vcpupin vcpu='3' cpuset='7'/>
  <emulatorpin cpuset='0-1'/>
</cputune>

This pins the VM’s 4 vCPUs to host cores 4-7, with emulator threads on cores 0-1. Combine that with isolcpus in your kernel parameters and the latency gets close to bare metal.

Declarative VM Management on NixOS

Here’s where NixOS pulls ahead of a generic Linux box running libvirt. You can define VMs declaratively:

{
  virtualisation.libvirtd.enable = true;

  # Define VMs as Nix expressions
  systemd.services."libvirt-guest-ubuntu" = {
    after = [ "libvirtd.service" ];
    requires = [ "libvirtd.service" ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      Type = "oneshot";
      RemainAfterExit = "yes";
    };
    script = ''
      ${pkgs.libvirt}/bin/virsh define /etc/libvirt/qemu/ubuntu-server.xml || true
      ${pkgs.libvirt}/bin/virsh start ubuntu-server || true
    '';
    preStop = ''
      ${pkgs.libvirt}/bin/virsh shutdown ubuntu-server || true
      sleep 10
      ${pkgs.libvirt}/bin/virsh destroy ubuntu-server || true
    '';
  };
}

Or use NixVirt for a cleaner approach:

{
  virtualisation.libvirt.connections."qemu:///system" = {
    domains = [{
      definition = ./vms/ubuntu-server.xml;
      active = true;
    }];
  };
}

Your VMs are now part of the same config that defines the host. Version controlled, reproducible, and gone again the moment you remove them from the file. No Proxmox cluster keeps a stale VM definition around that nobody remembers creating.

Where the CLI Actually Hurts

So far this reads like the simpler stack wins outright. I’ve run it for years and I’m not going to pretend that’s the whole picture. The features are all there. The day-to-day around them is rougher than a polished UI, and pretending otherwise would be dishonest.

What gets tedious:

  • Creating VMs: virt-install has a flag for everything, which means you’re reading the man page every time. I keep wrapper scripts, and they work, but they’re held together with my own assumptions.
  • Monitoring: You end up writing scripts to check VM status, resource usage, and health. Or you stand up a Grafana dashboard with a libvirt exporter, which is its own little project.
  • Storage management: Pools, volumes, snapshots are all doable with virsh, but the commands are long and easy to fat-finger.
  • Network visualization: Which VM is on which bridge? Which has which IP? That answer lives in your scripts or your memory, not on a screen.

And some things scripts handle badly:

  • A quick visual overview: When you just want to know how everything is doing, virsh list plus virsh domstats plus a parser is no match for a dashboard you can glance at.
  • Console access: virt-viewer works fine locally. A web console is genuinely nicer when you’re remote and half-awake.
  • Delegation: Letting someone manage two specific VMs without handing them the whole host is awkward with raw libvirt.

Why Proxmox Exists

Which brings me to Proxmox. Same engine underneath, KVM and QEMU and libvirt, wrapped in a web UI that makes all of the above accessible without a single script.

I won’t tell you that you need it. For a homelab where you’re the only admin, NixOS plus virsh plus a few scripts is genuinely fine. I’ve run it that way for years and it kept working.

The UI earns its place in specific situations:

  • Onboarding: a new person can read the infrastructure off the screen instead of off your scripts
  • Emergency response: at 3 AM, clicking is more reliable than recalling virsh syntax
  • Visibility: every VM, its status, resource usage, and network topology in one view
  • Integrated features: backup scheduling, HA clustering, and firewall rules configured in one place

Proxmox also bundles clustering, Ceph integration, and backup management that would be real work to rebuild from scripts. The value isn’t a different hypervisor. It’s the operational layer you’d otherwise hand-roll.

Closing the Gap

So here’s where the two sides meet. The features were never the dividing line. KVM hands you all of them, and NixOS adds declarative configs and version control on top, which is more than Proxmox gives you on that particular axis. What Proxmox sells is the operations layer: the dashboard, the delegation, the 3 AM ergonomics.

Pick based on which gap you’d rather close yourself. If you want to understand virtualization down to the kernel module, start with NixOS, KVM, and virsh. Nothing is hidden, and every “enterprise” feature is a command you typed on purpose. As I argued in Sovereign Infrastructure, depending on a black box you can’t inspect is its own kind of risk, and this stack has no black box in it.

If you’re running production or working with a team, the Proxmox UI is buying you operational sanity, not a better hypervisor, and that’s a fair thing to buy. For my homelab, where understanding the machine is half the point, NixOS as a hypervisor is the choice I keep making. Declarative configs, full control, no vendor between me and the kernel. Just Linux, doing the thing it’s quietly been doing in every cloud you’ve ever used.