DEV Community

Cover image for systemd-nspawn: Containers Without Images or Registries
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

systemd-nspawn: Containers Without Images or Registries

The word "container" has collapsed into one meaning: pull an image, unpack the layers, tell the daemon, run it. But that isn't the only way to use the isolation the kernel offers — and one alternative is probably already packaged for your server.

systemd-nspawn takes a directory and runs a full operating system inside it. No image format, no registry, no background daemon. In its own documentation's words it resembles chroot but is far more powerful: alongside the file system hierarchy it virtualises the process tree, the various IPC subsystems, and the host and domain names.

This article is about where that tool sits: what it isolates, what it doesn't, and the one condition for using it safely.

What does it isolate?

The documentation's list is clear. From inside the container, access to kernel interfaces such as /sys/, /proc/sys/ and /sys/fs/selinux/ is read-only; the host's network interfaces and the system clock can't be changed from within; device nodes may not be created; the host can't be rebooted and kernel modules can't be loaded.

So most "accident" scenarios are closed. A build script can't change a sysctl by mistake, an installer can't shift the host's clock.

The practical distinction I draw from that: in its default form this list of restrictions prevents accidents. Confining malicious code is a separate matter, and the documentation is remarkably direct about it.

The one condition: user namespaces

The decisive sentence in the docs: if user namespaces are not used, this sandbox can easily be circumvented from within the container. The conclusion is equally clear — untrusted code must always be run in a user namespace.

The key is the --private-users= option, which has several modes:

  • pick: turns on user namespacing and chooses the UID/GID range automatically. It reads a range from the root directory's owner, and if another container is using it, picks a new unused range between host UIDs 524288 and 1878982656. The cost isn't just time: this mode also triggers ownership adjustment, so the tree's file ownership on the host shifts permanently. Factor that in if you'll move the tree with tar/rsync or edit it from the host; that's why the documentation makes ownership behaviour configurable separately (--private-users-ownership=).
  • identity: an identity mapping for the first 65536 UIDs/GIDs. It gives no UID isolation, only capability isolation — and the docs say plainly that it "is not secure and must not be used to run untrusted code".
  • managed: delegates UID range allocation to systemd-nsresourced; this is the default when invoked unprivileged.
  • no: no user namespace at all.

Upstream's own recommendation is explicit: in most cases --private-users=managed — or pick when you're privileged — is the recommended option, because user namespacing is advised for security and massively enhances container security. The same warning applies to no: not secure, and not to be used for untrusted code.

So before saying "it runs in a container, so I'm safe", check which mode you're in.

Diagram

No images, just directories

This is the big mental shift. In Docker you pull an image; with nspawn you prepare an operating system tree. The docs name the tools too: dnf, debootstrap or pacman set up a filesystem hierarchy suitable for the job.

The location is standardised as well: /var/lib/machines/ is the suggested directory for OS container images installed on the system. When you pass --machine=, a tree is searched for in a couple of locations, most importantly there.

There's a small but annoying check: when booting a container, nspawn verifies that /usr/lib/os-release or /etc/os-release exists in the tree. If you're only opening a shell the check doesn't apply; if you're booting a very old distribution tree you may have to add the file by hand.

The most useful option while experimenting is --ephemeral: the container runs on a temporary snapshot of its file system, which is removed when it terminates. The docs add two notes: taking that snapshot is far more efficient on file systems with native subvolume snapshots or reflinks (btrfs or new xfs), and the switch leaves hostname, machine ID and other identifying settings unmodified. The second matters in CI: launch parallel runs from the same tree and you get two containers with the same machine ID.

The first container: four commands

The shortest path to making this concrete. On a Debian-based host, set up a Debian tree and step into it:

apt install systemd-container debootstrap
debootstrap --include=systemd,dbus stable /var/lib/machines/demo
systemd-nspawn --directory=/var/lib/machines/demo --private-users=pick
Enter fullscreen mode Exit fullscreen mode

The third command drops you into a shell inside the container. To boot the full OS with its init you add --boot — which is already the default when you run it as a service. On a fresh debootstrap tree the root password is locked, so --boot leaves you at a login prompt you can't get past; set the password first:

systemd-nspawn --directory=/var/lib/machines/demo passwd root
Enter fullscreen mode Exit fullscreen mode

To run it as a service, machinectl steps in:

machinectl start demo         # runs as systemd-nspawn@demo.service
machinectl enable --now demo  # use this if it should also start at boot
machinectl shell demo         # open a shell inside
machinectl poweroff demo      # shut it down cleanly
Enter fullscreen mode Exit fullscreen mode

start is one-shot; if you want the container up after a reboot you need enable, which the documentation describes as having the same effect as systemctl enable.

The container is now an ordinary systemd service, and resource control happens the ordinary way:

systemctl set-property systemd-nspawn@demo.service MemoryMax=2G CPUQuota=200%
Enter fullscreen mode Exit fullscreen mode

You don't learn a separate "container resource management" layer. But know the split: those settings belong to the unit; the .nspawn file doesn't express cgroup limits, where the equivalents are limited to Limit* (rlimits), CPUAffinity and OOMScoreAdjust.

Networking: shared by default

Networking in nspawn depends on how you start it. From the command line, if you say nothing, the container shares the host's network stack — practical for a quick test, insufficient for isolation. Started as a service, the template unit applies --network-veth, so the container is born in its own network namespace. Don't go looking for the host network in a container you started with machinectl start.

--private-network gives the container its own network namespace; --network-veth creates a virtual Ethernet link between host and container, with the container-side interface named host0. The latter implies the former.

Here's the built-in convenience: systemd-networkd ships two files by default — one matching the host-side ve-* interfaces, enabling automatic address provisioning over DHCP and automatic IP routing onto the host's external interfaces, and one matching the container-side host0 interface, enabling client-side address assignment via DHCP. So with systemd-networkd, networking usually "just works"; without it, you set up bridging and NAT yourself.

In unprivileged mode the options narrow: only --private-network and --network-veth are supported. More importantly, the first item on that same list limits this article's framing directly — in unprivileged mode only disk-image-based containers are supported; directory-based ones work only if owned by the "foreign" UID range.

Running it as a service

If you'd rather not start the container by hand, the infrastructure is there: machinectl provides commands to run containers as system services through the systemd-nspawn@.service template unit. Each container instance runs as its own service instance, with the container name as the instance identifier.

There are behavioural differences that catch people out, and they touch both of this article's main themes. Here's the command line the template unit runs:

ExecStart=systemd-nspawn --quiet --keep-unit --boot --link-journal=try-guest           --network-veth -U --settings=override --machine=%i
Enter fullscreen mode Exit fullscreen mode

Note three things: --boot starts the init process inside the container (not the default from the command line), -U already turns on user namespacing (so on the service path the template handles --private-users for you), and --network-veth puts the container in its own network namespace. Thanks to --link-journal=try-guest, the container's logs are readable from the host with journalctl -M <name>.

"It worked on the command line but behaved differently as a service" usually traces back to these three.

You don't have to bury settings in the unit file either: a settings file with the .nspawn suffix may sit alongside each container, and it overrides the template unit's defaults.

It also runs OCI bundles

Version footing matters here: some of these options are recent. The managed mode and the maturing of unprivileged nspawn arrived with systemd 258; --mstack= for stacking image layers and importctl pull-oci with 260. On Debian 12 (systemd 252) you'll find none of them.

A lesser-known option is --oci-bundle=, which takes the path to an OCI runtime bundle as specified in the OCI Runtime Specification. In that case no .nspawn file is loaded; the root directory and various settings are read from the OCI runtime JSON data, with command-line data taking precedence.

In current systemd the connection is closer still: OCI images pulled with importctl pull-oci can be stored as a stack and run with nspawn. So treating nspawn as an island closed off from the container ecosystem is wrong; the accurate statement is that the systemd-nspawn binary doesn't do image distribution — that job lives in a separate systemd component (importctl).

The state on my server

$ dpkg -l systemd-container | tail -1
un  systemd-container <none> <none> (no description available)

$ ls /var/lib/machines
ls: cannot access '/var/lib/machines': No such file or directory

$ cat /proc/sys/user/max_user_namespaces
386072
Enter fullscreen mode Exit fullscreen mode

The un on the first line means the package isn't installed: on Ubuntu, systemd-nspawn and machinectl ship in a separate package, and this machine doesn't have it. So "systemd is everywhere, therefore nspawn is everywhere" is wrong.

The third line shows user namespaces are plentiful — but that isn't the only place to look. The same machine also has this:

$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1
Enter fullscreen mode Exit fullscreen mode

Ubuntu 24.04 restricts unprivileged processes from creating user namespaces via AppArmor. It doesn't stop you using --private-users=pick as root, but it's the wall you'll hit if you try unprivileged nspawn. On Debian the equivalent is kernel.unprivileged_userns_clone.

Instead of Docker, or alongside it?

The comparison needs the right axis. Docker/Podman brought two things: distribution (image format, registry, layer sharing, versioning) and security defaults (a seccomp profile, dropped capabilities, rootless plus user namespaces in Podman). nspawn does none of the first, and its default on the second is weaker — you switch security on yourself with --private-users.

What nspawn is good at is running a full operating system. If you want an environment with systemd inside, several services, "like a small VM but not a VM", it's the natural choice. Typical uses: building and testing on different distribution releases, exercising systemd units under a real init, keeping a legacy application alive with its own distribution tree.

If you're running one process per application — today's microservice pattern — there's no reason to reach for nspawn. If you want a system container and you're already in the systemd world, it solves that without installing another daemon.

You can build a similar setup with the approach in my article on rootless containers under systemd with Podman quadlets; the difference is that quadlets bind OCI images to systemd, while nspawn doesn't use the concept of an image at all.

Checklist

  • Verify the package is installed; the presence of systemd doesn't imply the presence of nspawn.
  • If you'll run untrusted code, don't start without --private-users=pick; the identity mode provides no security and the docs say so explicitly.
  • Prefer --ephemeral for experiments and CI; account for the snapshot cost if your filesystem isn't btrfs or new xfs.
  • If you run it as a service, know the --boot difference; command-line behaviour and service behaviour aren't the same.
  • Keep settings in a .nspawn file rather than editing the unit.
  • Choose the network model up front: in unprivileged mode only private network and veth are supported.
  • Don't forget the os-release file when building the OS tree; nspawn checks for it.

A container isn't one thing

What interests me most about nspawn is how narrow the word "container" has become. There is no container object on the kernel side; there are namespaces, cgroups and capabilities. Every tool built on top composes those parts differently.

Docker built its composition around distribution and won. nspawn never enters the distribution business and solves only the running side — which is why you hear about it less, and why it stays simpler for some jobs.

So the question for your own setup: is your reason for containers image distribution, running a full operating system, or a tight security boundary? For the first, Docker/Podman; for the second, nspawn; for the third, a tool whose defaults are set in your favour — or nspawn with user namespaces on.

Official Sources

Top comments (0)