Stop Hand-Writing QEMU Flags: Practical systemd-vmspawn VMs on Linux
systemd-nspawn is great when a namespace container is enough. Sometimes it is not.
You need a real kernel, a TPM, Secure Boot firmware, or a guest that must look like bare metal. That is when people fall back to a pile of qemu-system-x86_64 flags, a one-off libvirt XML, or a desktop hypervisor UI.
systemd-vmspawn is systemd's answer: the same "spawn an OS image" ergonomics as systemd-nspawn, but it launches a full virtual machine (typically QEMU/KVM) instead of namespaces.
This guide is operational. Every flag and flow below comes from the current systemd-vmspawn(1) man page and related systemd docs.
What vmspawn is (and is not)
| Tool | Isolation | Kernel | Typical use |
|---|---|---|---|
systemd-nspawn |
namespaces / cgroups | host kernel | OS trees, chroot-like labs |
systemd-vmspawn |
full VM (KVM) | guest kernel | real OS images, firmware, TPM |
| libvirt / virt-manager | full VM | guest kernel | long-lived multi-VM farms |
| Podman / Docker | OCI app containers | host kernel | single apps |
vmspawn is intentionally nspawn-shaped:
- start from
--image=or--directory= - optional
--machine=name - optional registration with
systemd-machined/machinectl - credentials, bind mounts, journal forwarding
- scope unit properties for CPU/memory caps
It is not a replacement for a full multi-tenant hypervisor control plane. Think: "I have a disk image and I want a correct KVM boot now."
Added in systemd 255, with substantial options through 256–262.
Prerequisites
On a typical Debian/Ubuntu host:
# packages (names vary slightly by distro)
sudo apt install systemd-container qemu-system-x86 ovmf
# KVM access for your user (needed for vsock on Debian/Ubuntu)
sudo usermod -aG kvm "$USER"
# re-login after this
# optional: software TPM for guests
sudo apt install swtpm swtpm-tools
# optional: build disk images the systemd way
sudo apt install mkosi
Sanity checks:
systemd-vmspawn --version
ls -l /dev/kvm
# firmware discovery (lists JSON firmware definitions)
systemd-vmspawn --firmware=list
On systems where /dev/kvm is missing (nested virt disabled, cloud instance without KVM), vmspawn can still run with --kvm=no, but expect pure TCG emulation and slower boots.
Lab 1 — Build a tiny image and boot it
The man page's first example is still the cleanest path. With mkosi:
mkdir -p ~/vmspawn-lab && cd ~/vmspawn-lab
# Arch example from systemd-vmspawn(1); swap -d for your distro if preferred
mkosi -d arch -p systemd -p linux --autologin -o image.raw -f build
systemd-vmspawn --image=image.raw
That drops you on an interactive guest console (--console=interactive is the default).
Import a cloud image instead
If you already use machinectl / importctl:
# Example pattern from the man page family: pull a cloud image, then boot the .raw
sudo machinectl pull-raw \
https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images/ \
Fedora-Cloud # adjust to a concrete image URL on your host
# After import, boot the raw under /var/lib/machines (path varies)
sudo systemd-vmspawn \
--image=/var/lib/machines/Fedora-Cloud.raw \
--machine=fedora-cloud \
--cpus=2 \
--ram=2G \
--network-user-mode
Prefer an explicit full URL to a known cloud image on your distro's download site. The point is the boot command, not chasing a moving Fedora filename.
Core knobs you will actually use
Image vs directory
# Disk image (raw by default; qcow2 via --image-format=)
systemd-vmspawn --image=./disk.raw
systemd-vmspawn --image=./disk.qcow2 --image-format=qcow2
# Directory tree as root (virtiofs under the hood)
systemd-vmspawn --directory=./rootfs
Rules from the man page:
- One of
--directory=or--image=is required; if neither is given,--directory=.is assumed. -
--ephemeral(-x) takes a temporary snapshot of the image and deletes it when the VM exits. Works with--image=only. Does not combine with--extra-drive=. -
--grow-image=20G/-G 20Gexpands a too-small image file (rounded up to 4K). With--ephemeral, growth applies to the snapshot copy.
CPU, RAM, KVM, vsock, TPM
systemd-vmspawn \
--image=./disk.raw \
--machine=lab1 \
--cpus=4 \
--ram=4G \
--kvm=yes \
--vsock=yes \
--tpm=yes
Defaults worth remembering:
| Option | Default | Notes |
|---|---|---|
--cpus= |
1 | |
--ram= |
2G | newer systemd: BYTES[:MAXBYTES[:SLOTS]] for memory hotplug |
--kvm= |
auto | detect /dev/kvm
|
--vsock= |
auto | guest AF_VSOCK; Debian/Ubuntu need kvm group |
--tpm= |
auto | needs swtpm on PATH |
--tpm-state= |
auto | path derived as <image>.tpmstate; off is transient |
--discard-disk= |
yes | pass TRIM/discard through to the image |
--notify-ready= |
true | waits for guest init READY=1 (nspawn defaults the opposite) |
--tpm-state=off (or ephemeral + auto) is wrong for guests that bind disk encryption keys to the vTPM — those keys vanish on every shutdown.
Networking: user-mode vs TAP
User-mode (no root, NAT via QEMU):
systemd-vmspawn --image=./disk.raw --network-user-mode
TAP (root, proper L2, needs systemd-networkd):
sudo systemd-vmspawn --image=./disk.raw --network-tap -M taplab
Requirements called out by the man page:
- root privileges for TAP
-
systemd-networkdrunning on the host - stock unit:
/usr/lib/systemd/network/80-vm-vt.network
If TAP comes up but the guest has no DHCP, check that networkd owns the host vt-* side and that your nftables forward/NAT policy allows it.
Firmware and Secure Boot
# See what firmware JSON files systemd can find
systemd-vmspawn --firmware=list
# Prefer Secure Boot-capable firmware
systemd-vmspawn --image=./disk.raw --secure-boot=yes
# Direct kernel boot (no firmware), or UKI path
systemd-vmspawn \
--directory=./rootfs \
--linux=./rootfs/boot/vmlinuz \
--initrd=./rootfs/boot/initrd.img
On recent systemd builds, --firmware= also accepts auto|uefi|bios|none, a path to a firmware definition, or describe to print the selected UEFI image. Booting a UKI requires UEFI firmware. Excess CLI arguments after options are passed as extra kernel cmdline via SMBIOS.
Console modes
--console=interactive # default: host TTY ↔ guest console
--console=read-only # watch only
--console=native # QEMU monitor available
--console=gui # graphical QEMU UI
--background=44 tints the terminal while the VM runs (interactive/read-only only).
Lab 2 — Directory root + private users + journal forward
This is the man page's "systemd system image" pattern, adapted for a local tree:
# Assume an OS tree at ./system (mkosi.output/system style)
# Map host subuid range into the guest via virtiofsd
SHIFT=$(grep "^$(whoami):" /etc/subuid | cut -d: -f2)
systemd-vmspawn \
--directory=./system \
--private-users="${SHIFT}" \
--linux=./system.efi \
--forward-journal=./vm.journal \
--machine=sysimg \
enforcing=0
What each piece does:
-
--private-users=UID_SHIFT[:RANGE]— turn on UID/GID mapping for directory boots (default range 65536). Required when the directory is not root-owned in a way the host can safely expose. -
--linux=— direct-boot a kernel or UKI; for directory images without--linux=, vmspawn searches BLS entries under/boot(XBOOTLDR) and/efi(ESP). -
--forward-journal=— host-side file or directory; guest journal is received viasystemd-journal-remotesemantics. -
enforcing=0— extra kernel cmdline token (SMBIOS), useful so SELinux does not block first boot of a lab image.
Inspect the forwarded journal later:
journalctl --file=./vm.journal -b
# or, if you forwarded to a directory:
# journalctl --directory=./vm-journals
Lab 3 — Resource caps via scope properties
When vmspawn is not run with --keep-unit, it registers a scope under machine.slice by default:
systemd-vmspawn \
--image=./disk.raw \
--machine=capped \
--slice=machine.slice \
--property=MemoryMax=2G \
--property=CPUQuota=200% \
--network-user-mode
--property= accepts the same assignments as systemctl set-property. Use it for MemoryMax=, CPUQuota=, TasksMax=, and friends so a runaway guest cannot eat the host.
Lab 4 — Credentials into the guest
vmspawn mirrors unit credentials:
printf 's3cret-db-password\n' > ./db.pass
chmod 600 ./db.pass
systemd-vmspawn \
--image=./disk.raw \
--machine=credlab \
--load-credential=dbpass:./db.pass \
--set-credential=app.env:APP_ENV=lab \
--network-user-mode
Inside a systemd guest, those show up through the normal credentials directory ($CREDENTIALS_DIRECTORY / ImportCredential= / LoadCredential= in units). Binary values in --set-credential= use C-style escapes (\n, \x00); shells may unescape once, so double-escaping is sometimes required.
For host-side encryption of credential files before load, pair with systemd-creds encrypt and unit LoadCredentialEncrypted= — that is the service-manager path; vmspawn itself takes plaintext load/set forms as documented.
Lab 5 — SSH over vsock with systemd-ssh-proxy
The man page ends with this workflow:
CID=3735928559 # pick an unused CID in 3..0xFFFF_FFFE
systemd-vmspawn \
--directory=./system \
--private-users="$(grep "^$(whoami):" /etc/subuid | cut -d: -f2)" \
--linux=./system.efi \
--vsock-cid="$CID" \
--machine=sshlab \
enforcing=0
In another terminal (while the VM runs):
# Ephemeral key path pattern from systemd-vmspawn(1)
ls /run/user/"$UID"/systemd/vmspawn/
ssh -o StrictHostKeyChecking=no \
-i /run/user/"$UID"/systemd/vmspawn/machine-*-sshlab-ed25519 \
"root@vsock/${CID}"
Notes from the docs:
- By default vmspawn generates an ephemeral SSH key so it can talk D-Bus into the guest (
--pass-ssh-key=yesdefault). Keys live only for that invocation under/run/user/$UID/systemd/vmspawn/. -
--ssh-key-type=ed25519is default;rsaexists for ancient guestsshd. - On Debian/Ubuntu, vsock needs membership in group
kvm. -
ssh root@vsock/$CIDneeds a client that understands the systemd vsock proxy path (systemd-ssh-proxyintegration).
Disable key generation only if you provide another way in: --pass-ssh-key=no.
Bind mounts, extra disks, bind-user
# Host path → same path in guest
systemd-vmspawn --image=./disk.raw \
--bind=/var/cache/build \
--bind-ro=/usr/src/linux-headers
# Host:guest path pair (escape colons with \:)
systemd-vmspawn --image=./disk.raw \
--bind=/home/you/proj:/opt/proj
# Additional data disk
systemd-vmspawn --image=./disk.raw \
--extra-drive=raw:./data.raw \
--extra-drive=qcow2:./bulk.qcow2
--bind-user=alice (systemd 259+) is stronger than a plain bind:
- Host home is exposed under
/run/vmhost/home/via virtiofs with UID translation. - Transient user/group records are injected as
userdb.transient.*credentials sonss-systemdin the guest can resolve the account.
Caveats from the man page (read these before using it on untrusted guests):
- Guest needs systemd 258+ with
nss-systemdinnsswitch.conf. - The propagated record includes the UNIX password hash — use a strong hash (
yescrypt/$y$) on the host. - Mapping is transient; leftover files owned by a recycled guest UID can become someone else's later.
machined registration
# As root, registration defaults on; as user, defaults off (Debian man page)
sudo systemd-vmspawn --image=./disk.raw --machine=reg1 --register=yes
machinectl list
machinectl status reg1
machinectl shell reg1
Recent systemd also adds --system / --user to pick which manager / machined instance to talk to (v260+).
Ephemeral smoke-test pattern
Golden image stays clean; every run is disposable:
systemd-vmspawn \
--image=./golden.raw \
--ephemeral \
--grow-image=30G \
--cpus=2 \
--ram=2G \
--network-user-mode \
--machine=smoke-$$ \
--set-credential=run.id:smoke-$(date -u +%Y%m%dT%H%M%SZ)
When the process exits, the snapshot is gone. Do not attach long-lived --extra-drive= in this mode (unsupported with --ephemeral).
systemd unit wrapper (optional)
For a lab VM you want under systemd supervision:
# /etc/systemd/system/vmspawn-lab@.service
[Unit]
Description=vmspawn lab VM %i
After=network-online.target
Wants=network-online.target
[Service]
# Type=notify works well because vmspawn notifies readiness
# after the guest is ready (--notify-ready=true by default)
Type=notify
ExecStart=/usr/bin/systemd-vmspawn \
--image=/var/lib/machines/%i.raw \
--machine=%i \
--cpus=2 \
--ram=2G \
--network-tap \
--register=yes \
--property=MemoryMax=3G
KillMode=mixed
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start vmspawn-lab@webtest
sudo systemctl status vmspawn-lab@webtest
machinectl status webtest
Exit status
From systemd-vmspawn(1):
- On tool/setup failure, the
errnovalue is propagated. - If the guest supplies
EXIT_STATUS, that is returned. - Otherwise success.
Boundaries — pick the right tool
| Need | Prefer |
|---|---|
| Same-kernel OS tree, fast iteration |
systemd-nspawn / machinectl
|
Host /usr add-on without a VM |
systemd-sysext / portable services |
| Real guest kernel, firmware, TPM, Secure Boot | systemd-vmspawn |
| Multi-node virt farm, live migration, fancy storage pools | libvirt / oVirt / Proxmox |
| Single app packaging | Podman / Docker / Quadlet |
Confidential computing (--coco=sev-snp|tdx) exists on recent systemd builds but is marked experimental in the man page — treat it as a research path, not a homelab default.
Troubleshooting checklist
-
No KVM —
ls -l /dev/kvm; nest virt; or--kvm=nofor a slow lab. -
vsock / SSH fails on Debian — user in
kvmgroup; re-login. -
TAP has no address — networkd active;
80-vm-vt.networkpresent; check nftables. -
Directory boot permission errors — set
--private-users=from/etc/subuid. -
Guest never "ready" — guest init must
sd_notifyREADY=1, or pass--notify-ready=no. -
TPM-bound LUKS unlock dies every boot — do not use
--tpm-state=off/ ephemeral auto-off. -
Firmware missing — install OVMF/AAVMF packages;
--firmware=list.
Wrap-up
systemd-vmspawn closes the gap between "nspawn is too weak" and "I guess I will memorize forty QEMU flags."
Practical defaults for day-to-day labs:
systemd-vmspawn \
--image=./disk.raw \
--machine=lab \
--cpus=2 \
--ram=2G \
--network-user-mode \
--tpm=yes \
--register=yes
From there, add ephemeral snapshots for CI-style smoke tests, TAP when the guest must sit on a real L2 segment, credentials instead of baking secrets into the image, and vsock+SSH when you want a shell without wiring a second NIC.
References
- systemd-vmspawn(1) — Debian man page (options, examples, exit status)
- systemd-vmspawn(1) — Arch man page (includes newer firmware/coco notes)
- Upstream man source: systemd/systemd
man/systemd-vmspawn.xml - systemd-nspawn(1) — namespace sibling
- machinectl(1) — machine registration / shell
- systemd-creds(1) — encrypt/list service credentials
-
sd_notify(3) — readiness protocol (
READY=1) - UAPI Boot Loader Specification — BLS lookup for directory boots
- Related reading on this blog: nspawn containers, sysext/confext, ukify/UKI, portable services, soft-reboot
Top comments (1)
That comparison table is the clearest version of the isolation ladder I've seen in one place, and "nspawn-shaped ergonomics, full VM underneath" is a genuinely good design instinct.
The row you don't have is the one where someone else owns the host. That's Krova Cloud : each Cube is a full VM with its own guest kernel, provisioned by one API call with vCPU, RAM and disk rather than a flag list or libvirt XML. No public IP by default, and every port mapping takes an IP allowlist behind a stateful default-deny firewall.
Your kernel point is the one that matters and it's worth restating: because the kernel comes from the host at boot, reboot inside a Cube doesn't change it and nothing warns you. Getting onto a refreshed kernel needs a cold restart. Same class of surprise as your QEMU flag pile, different layer.
Where vmspawn wins outright, and I won't pretend otherwise: TPM, Secure Boot firmware, and guests that must look like bare metal. We don't offer any of those.
Is TPM passthrough the main reason you moved off nspawn, or was it just needing a real kernel?