virtualisation-backends¶
Modules
One NixOS module that gates four VM/container back-ends behind a single boolean each:
- VirtualBox host — run VirtualBox VMs on this machine
- VirtualBox guest — guest additions, for when this machine is itself a VirtualBox VM
- virt-manager — QEMU/KVM via
libvirtd, driven by virt-manager - Waydroid — an Android container
The wiring is trivial. The value of this recipe is three traps that each cost a debugging session to find, all preserved in the code with comments.
The traps¶
1. config must be mkMerge, never a //-chain of mkIf blocks¶
It is tempting to write:
// is plain attribute-set update: it keeps only the last operand's
attributes and silently discards every earlier block. The result type-checks,
evaluates, and builds — it is just missing most of your config, with no error to
point at it. // is only safe between plain attrsets that carry no
module-system properties (no mkIf / mkMerge / mkDefault inside).
Use mkMerge, the module-system-aware combinator that actually merges the
branches:
This is the same bug that has silently neutered whole hardening modules elsewhere — worth internalizing once.
2. VirtualBox host needs addNetworkInterface = false¶
With enableKvm = true, the VirtualBox host backend is NAT-only. The host-only
vboxnet0 interface that addNetworkInterface = true (the default) would create
trips a NixOS assertion and refuses to build. Keep it false. If you truly need
host-only networking you have to give up the KVM backend.
3. Drop ceph/glusterfs from QEMU¶
pkgs.qemu_full pulls in ceph and glusterfs storage backends by default. On a
desktop VM host they are dead weight — and on current nixpkgs unstable (gcc15)
ceph fails to compile, which blocks the machine's entire system closure from
building. Override them off:
Bonus gotcha¶
Recent nixpkgs removed the x11 sub-option from
virtualisation.virtualbox.guest. Don't set it; it no longer exists. Its
neighbour dragAndDrop does still exist (and defaults to true), but it was
renamed from the older lowercase draganddrop, so an old config spelling it
that way rides a rename shim rather than the real option. The module here sets
neither and takes the guest defaults.
Usage¶
Import the module and flip the booleans you want. Point user at your login
account — it is added to the relevant groups (vboxusers, libvirtd) and runs
the QEMU processes.
{
imports = [ ./modules/virtualisation-backends ];
modules.virtualisation = {
virtmanager = true; # QEMU/KVM + virt-manager
virtualbox = true; # VirtualBox host
user = "alice";
# group = "users"; # primary group of `user`; default is fine on NixOS
# virtualbox-guest = true; # only inside a VirtualBox VM
# waydroid = true;
};
}
Options¶
| Option | Type | Default | Meaning |
|---|---|---|---|
modules.virtualisation.virtualbox |
bool | false |
VirtualBox host |
modules.virtualisation.virtualbox-guest |
bool | false |
VirtualBox guest additions |
modules.virtualisation.virtmanager |
bool | false |
QEMU/KVM via virt-manager |
modules.virtualisation.waydroid |
bool | false |
Waydroid Android container |
modules.virtualisation.user |
str | "user" |
User granted access to the enabled back-ends |
modules.virtualisation.group |
str | "users" |
Primary group of user, used as the QEMU process group |
Caveats¶
swtpm.enable = trueis on so Windows 11 guests get a virtual TPM; drop it if you don't need it.runAsRoot = trueplus auser/groupinverbatimConfigruns the libvirt helper as root but the QEMU processes as your user. Adjust to taste if your threat model wants the tighter, rootless setup. KeepverbatimConfigto just those two lines: addingnamespaces = []disables libvirt's per-VM mount-namespace isolation machine-wide and hands a guest escape the host's full/dev.virtmanageralso turns onspiceUSBRedirection, so USB devices can be passed through to guests from virt-manager.- Enabling
virtmanageralso enablesprograms.dconf(virt-manager stores its settings there).
Source¶
modules/virtualisation-backends/default.nix
# virtualisation-backends
#
# A NixOS module that gates four VM/container back-ends behind one boolean each:
# - VirtualBox host
# - VirtualBox guest additions
# - QEMU/KVM via virt-manager (libvirtd)
# - Waydroid (Android container)
#
# The value is not the wiring, it is the traps encoded below. See README.md.
{
config,
lib,
pkgs,
...
}:
let
inherit (lib) mkIf mkMerge mkOption types;
cfg = config.modules.virtualisation;
in
{
options.modules.virtualisation = {
virtualbox = mkOption {
description = "Enable VirtualBox host.";
type = types.bool;
default = false;
};
virtualbox-guest = mkOption {
description = "Enable VirtualBox guest additions (use inside a VirtualBox VM).";
type = types.bool;
default = false;
};
virtmanager = mkOption {
description = "Enable QEMU/KVM virtualisation with virt-manager (libvirtd).";
type = types.bool;
default = false;
};
waydroid = mkOption {
description = "Enable the Waydroid Android container.";
type = types.bool;
default = false;
};
# The unprivileged user that should be able to drive these back-ends.
# It is added to the vboxusers / libvirtd groups and runs the QEMU
# processes. Set this to your login user.
user = mkOption {
description = "Login user granted access to the enabled back-ends.";
type = types.str;
example = "alice";
default = "user";
};
# Primary group of `user`, used for the QEMU process group. Defaults to
# "users", which is the usual login group on NixOS.
group = mkOption {
description = "Primary group of `user`, used as the QEMU process group.";
type = types.str;
default = "users";
};
};
# IMPORTANT: this MUST be `mkMerge`, never a `//`-chain of `mkIf` blocks.
# `//` (attrset update) keeps only the LAST attribute and silently drops
# every earlier block — you get a config that type-checks, builds, and is
# wrong, with no error. `mkMerge` is the module-system-aware combinator that
# actually merges the branches. Only ever use `//` between plain attrsets
# that carry no module-system properties (no mkIf/mkMerge/mkDefault inside).
config = mkMerge [
(mkIf cfg.virtualbox {
environment.systemPackages = with pkgs; [ virtualbox ];
virtualisation.virtualbox.host = {
enable = true;
enableExtensionPack = true;
enableKvm = true;
# TRAP: keep this false. The KVM backend is NAT-only; enabling the
# host-only `vboxnet0` interface trips a NixOS assertion that refuses
# the build. If you genuinely need host-only networking, you cannot
# also use `enableKvm = true` above.
addNetworkInterface = false;
};
users.extraGroups.vboxusers.members = [ cfg.user ];
})
(mkIf cfg.virtualbox-guest {
# NOTE: recent nixpkgs removed the `x11` sub-option from
# `virtualbox.guest` — do not try to set it here. `dragAndDrop` DOES
# still exist (default true); only the old lowercase `draganddrop`
# spelling is gone, behind a rename shim.
virtualisation.virtualbox.guest.enable = true;
})
(mkIf cfg.virtmanager {
virtualisation = {
libvirtd = {
enable = true;
qemu = {
# TRAP: the default `qemu_full` pulls in the ceph and glusterfs
# storage backends. They are useless on a desktop VM host and, on
# current unstable (gcc15), ceph fails to COMPILE — which would
# block this host's entire system closure from building. Drop them.
package = pkgs.qemu_full.override {
cephSupport = false;
glusterfsSupport = false;
};
runAsRoot = true;
# Keep libvirt's default per-VM mount-namespace isolation (each
# QEMU sees a minimal, private /dev). Do NOT add `namespaces = []`
# here: that disables it machine-wide and hands a guest-escape the
# host's full /dev view. Only the user/group override is needed.
verbatimConfig = ''
user = "${cfg.user}"
group = "${cfg.group}"
'';
swtpm.enable = true; # software TPM, needed for Windows 11 guests
};
};
spiceUSBRedirection.enable = true;
};
programs.dconf.enable = true; # virt-manager stores settings in dconf
environment.systemPackages = with pkgs; [
virt-manager
qemu
virtiofsd
libvirt
];
users.users.${cfg.user}.extraGroups = [ "libvirtd" ];
})
(mkIf cfg.waydroid {
virtualisation.waydroid.enable = true;
environment.systemPackages = with pkgs; [ waydroid ];
})
];
}