Running Lightweight Containers with systemd-nspawn
Use systemd-nspawn and machinectl to run lightweight OS containers without Docker on Linux. Build rootfs, network, bind mount, and limit resources with AI help.
- #linux
- #systemd
- #containers
- #nspawn
I spent years reaching for Docker every time I needed an isolated environment, even when the job was something as mundane as testing a package install on a clean Debian root. Then a teammate watched me wait for a daemon to come back from a bad state and asked, “Why aren’t you just using nspawn?” I had no good answer. systemd-nspawn ships with systemd, which is already on almost every server I touch, and it gives me a real OS container in seconds with no daemon, no registry, and no layered filesystem to reason about. This post is the walkthrough I wish someone had handed me back then, including where an AI assistant genuinely speeds things up and where it absolutely should not be trusted.
Why nspawn instead of Docker or a VM
systemd-nspawn boots a directory tree as a container using the same kernel namespaces and cgroups that everything else on Linux uses. Unlike Docker, there is no long-running daemon, no image format, and no separate networking stack to learn. You point it at a root filesystem and it runs. Unlike a full VM, there is no hypervisor, no guest kernel, and no virtualized hardware, so startup is near-instant and the memory overhead is tiny.
The trade-off is that nspawn is closer to “a chroot with real isolation” than to a polished application packaging tool. It shines for running a full systemd init inside the container, testing distro upgrades, building reproducible dev environments, and carving up a host into long-lived OS instances managed by machinectl. It is not trying to replace your container registry workflow. If you want a mental model: Docker packages an application, a VM packages a machine, and nspawn packages an operating system userspace sharing your host kernel.
I leaned on Claude to summarize the namespace differences when I was first comparing these, and it was a solid starting point. Treat that output the way you would treat a fast junior engineer’s whiteboard sketch: useful for orientation, not gospel. Verify against the man pages before you ship anything.
Building a root filesystem
A container is just a directory. The convention is to keep them under /var/lib/machines/, which is where machinectl looks by default. For Debian or Ubuntu, build the rootfs with debootstrap:
sudo apt install debootstrap systemd-container
sudo debootstrap --include=systemd,dbus stable \
/var/lib/machines/debian-test http://deb.debian.org/debian
For a Fedora or RHEL-family root, use dnf with --installroot:
sudo dnf --installroot=/var/lib/machines/fedora-test \
--releasever=40 install systemd dnf passwd
Both approaches give you a minimal but complete userspace. Set a root password before you try to log in, otherwise you will be locked out of the console:
sudo systemd-nspawn -D /var/lib/machines/debian-test passwd
That command boots the container without an init system, runs a single command, and drops you back to the host. Handy for one-off fixes.
Booting and entering the container
To bring up the full init system inside the container, pass -b:
sudo systemd-nspawn -D /var/lib/machines/debian-test -b
You will watch systemd start inside the container and land at a login prompt. Use the root password you just set. To leave, type poweroff inside, or hit Ctrl-] three times within one second to force-detach.
Once a container lives under /var/lib/machines/, machinectl becomes the friendlier front end:
machinectl list
machinectl start debian-test
machinectl login debian-test
machinectl shell debian-test /bin/bash
machinectl enable debian-test
machinectl stop debian-test
start boots it in the background, login gives you a getty-style session, and shell opens a shell without needing a password since it spawns directly via the host. enable wires it into systemd-nspawn@.service so it comes up at boot.
Pro Tip: machinectl shell does not require container credentials because it works through the host’s systemd. That makes it perfect for automation and recovery, but it also means anyone with host root has full access to every container. Guard host root accordingly.
Configuration files instead of long flags
Stacking flags on the command line gets unwieldy fast. nspawn reads per-container settings from .nspawn files in /etc/systemd/nspawn/, named after the machine. Create /etc/systemd/nspawn/debian-test.nspawn:
[Exec]
Boot=on
[Files]
Bind=/srv/shared:/mnt/shared
[Network]
VirtualEthernet=on
Now machinectl start debian-test picks these up automatically. Keeping configuration in a tracked file rather than in shell history is one of those small disciplines that pays off when you hand the box to a colleague six months later.
Networking with veth and a host bridge
By default an nspawn container shares the host network, which is fine for quick tests but not for isolation. To give the container its own interface, use --network-veth:
sudo systemd-nspawn -D /var/lib/machines/debian-test -b --network-veth
This creates a host0 interface inside the container and a matching ve-debian-test interface on the host. systemd-networkd on both sides will configure addresses automatically if it is running. For containers that should sit on the same L2 segment as other hosts, attach the veth to a bridge with --network-bridge:
sudo systemd-nspawn -D /var/lib/machines/debian-test -b \
--network-bridge=br0
In a .nspawn file the equivalents are VirtualEthernet=on and Bridge=br0 under [Network]. When I was debugging why a container could not reach its gateway, I described the symptoms to an AI assistant and it correctly pointed me at IP forwarding and a missing Bridge= line. Good catch, but I still confirmed sysctl net.ipv4.ip_forward myself rather than blindly toggling it.
Bind mounts, resource limits, and read-only roots
Sharing a host directory into the container uses --bind (read-write) or --bind-ro (read-only):
sudo systemd-nspawn -D /var/lib/machines/debian-test -b \
--bind=/srv/data:/data --bind-ro=/etc/pki:/etc/pki
Because nspawn containers live in cgroups, you can cap resources with the same properties systemd uses everywhere. Pass them as --property:
sudo systemd-nspawn -D /var/lib/machines/debian-test -b \
--property=MemoryMax=512M --property=CPUQuota=50%
For an ephemeral or hardened container, make the root immutable. --read-only mounts the entire tree read-only, while --volatile=overlay keeps /usr from the image but throws away all other writes when the container stops:
sudo systemd-nspawn -D /var/lib/machines/debian-test -b --read-only
sudo systemd-nspawn -D /var/lib/machines/debian-test -b --volatile=overlay
The --volatile mode is excellent for CI-style runs where you want a clean state every time without rebuilding the rootfs. Combine it with a bind mount for the one directory whose output you actually want to keep.
Pro Tip: Validate resource limits by stress-testing inside the container and watching systemd-cgtop on the host. If your MemoryMax is being ignored, you are almost certainly running an old container that bypassed machinectl, so the unit scope was never applied.
Keeping AI in the loop without handing over the keys
Most of these commands are easy to get wrong in subtle ways, and that is exactly where an AI pairing tool earns its place. I routinely ask one to draft a .nspawn file, explain a cryptic journalctl -M debian-test line, or generate the debootstrap invocation for a distro I rarely touch. Tools like GitHub Copilot and Cursor are fast at the boilerplate, and a Warp terminal workflow makes it easy to review a suggested command before it runs.
Here is the line I will not cross: the AI is a fast junior engineer, not a senior one. It does not understand your blast radius, it cannot feel the consequences of a wrong --bind into /etc, and it will confidently invent flags that do not exist. Keep a human in the loop for anything that touches a real host, and never give the AI production credentials. Let it draft against a throwaway container, then you read, you verify, and you run. For repeatable prompt patterns around this, I keep a small library in our prompts collection and the curated prompt packs, and I sketch new ones in the prompt workspace before they graduate into a runbook.
When something does go sideways on a live box, the human-reviewed automation in our incident response dashboard and code review tooling is built around the same principle: the model proposes, a person disposes. More container and systemd walkthroughs live under Linux admins if you want to keep going.
Wrapping up
systemd-nspawn is one of those tools that quietly removes friction once you adopt it. There is no daemon to babysit, the containers are just directories you can tar and move, and machinectl gives you a clean lifecycle interface that feels native because it is. Build a rootfs, boot it, bind what you need, cap what you must, and let an AI assistant accelerate the boring parts. Just keep your hands on the wheel for anything that reaches production, and keep your real credentials far away from the model.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.