Machines
Every one of these commands is a call to the
service on the system bus, which asks polkit
whether you may: sudo always works, and a rule can let your group through
without a password. The examples here use sudo.
Every machine nspawn starts is the systemd unit systemd-nspawn@NAME.service,
registered with systemd-machined under its name. machinectl list,
machinectl status NAME, systemctl status systemd-nspawn@NAME and
journalctl -u systemd-nspawn@NAME (with --namespace=nspawn for what the
machine prints, see logs) all work on it; nspawn adds its own
commands on top.
The unit also carries a drop-in, nspawn-hooks.conf, that calls nspawn around
the machine’s life: ExecStartPre prepares its network and settings,
ExecStartPost publishes its ports once it is registered, and ExecStopPost
releases everything however the machine ended; a machine with
dependencies first waits for them in another ExecStartPre.
So a start by machinectl start NAME or at boot (systemctl enable systemd-nspawn@NAME) prepares the machine as nspawn start does, and a program
that exits or crashes is cleaned up after as nspawn stop would. The drop-in
names the nspawn binary that wrote it, which is why nspawn belongs in
/usr/local/bin or /usr/bin. It also carries the machine’s restart policy and
resource limits, when it has them, and, for an app machine and for a booted one
whose output goes to nspawn’s journal (the default), the ExecStart= that runs
systemd-nspawn through nspawn attach-exec, which is how run -i and run -t
hand the program an input or a terminal and how the output reaches that
journal.
start
sudo nspawn start NAME [--network NETWORK]... [--network-alias [NETWORK=]NAME]...
[-p [IP:]HOST:CONTAINER[/udp]]... [--entrypoint PROGRAM] [-e VAR[=VALUE]]...
[-v SOURCE:TARGET[:ro]]... [-l KEY=VALUE]... [--restart POLICY] [-m SIZE]
[--cpus N] [--pids-limit N] [HEALTHCHECK OPTIONS] [OTHER OPTIONS]
[--secret NAME[:TARGET[:MODE[:UID:GID]]]]... [--depends-on NAME[:CONDITION][:optional]]...
[--env-file FILE]... [--label-file FILE]... [--mount type=TYPE,...]...
[--ip [NETWORK=]ADDRESS] [--gpus GPUS] [--image-command] [--no-wait] [-- ARGUMENTS...]
start regenerates /etc/systemd/nspawn/NAME.nspawn from the machine’s
record, sets up the network it is configured for, starts the
unit through systemd and waits up to 30 seconds for the machine to register
with machined. For a booted machine it then waits, up to 20 more seconds, until
the machine’s own systemd is listening, so that a command can follow right
away; --no-wait skips that last wait. A program that runs and returns at once
is reported as such, not as a failure.
Everything given to start is remembered for the machine, so a plain
nspawn start NAME next time reuses the network, the ports, the command, the
variables and the volumes of the last run:
--networkswitches the machine between the bridge, networks of its own made withnetwork create(several at once,--network-aliasgiving it more names on them), a veth pair, the host’s network, another machine’s (container:NAME) and none, and--ipfixes its address on one made withnetwork create; see Networking.-p [IP:]HOST:CONTAINER[/udp]publishes a port on the host, on every IPv4 and IPv6 address or on one. It needs a bridge network;-p noneforgets all published ports.--entrypoint,-e,--env-fileand the arguments after--change what an app image runs; see Command, entrypoint and environment. Boot images refuse them.-vmounts a host directory or a named volume into any kind of machine, and--mountis its long form; see Volumes.-l/--label,--label-file,--restart,-m,--memory-swap,--cpusand--pids-limitlabel the machine, give it a restart policy and bound its resources; see Labels and Restart policies and limits.--health-cmdand the other--health-*options give it a healthcheck, or replace the image’s; see Healthchecks.--hostname,-u,-w,--cap-add,--cap-drop,--privileged,--read-only,--tmpfs,--shm-size,--device,--device-cgroup-rule,--dns,--dns-search,--add-host,--ulimit,--oom-score-adj,--stop-signal,--stop-timeout,--initand--sysctlshape the machine further,--log-driversays where the program’s output goes, and--timezonehow systemd-nspawn sets/etc/localtime; see Other flags.--secrethands it a secret as a file; see Secrets.--gpusand--device VENDOR/CLASS=NAMEhand it devices of the host’s CDI specs; see GPUs and CDI devices.
Machines are started by name. A reference (fedora:44, docker.io/x) is
refused with a hint to pull it or to create a machine from a local image.
Images that were not installed by nspawn, for example something created with
machinectl import-tar, can be started too: they get the stock template’s veth
networking, and nspawn starts systemd-networkd on the host when the host has no
.network files of its own (see veth); the flags above
are refused for them.
run
sudo nspawn run [-d] [--rm] [-i] [-t] [OPTIONS] REFERENCE [COMMAND [ARGUMENT...]]
run makes a machine from an image and starts it: pull,
or create from a local image with the same reference, then start, with the
options of both. A name without a registry is looked for here under the
configured registry, then as Docker Hub’s (app as docker.io/library/app,
the name of an image compose pulled from Docker Hub), and only then pulled from
the configured registry.
What follows the image replaces an app’s command; one that
starts with a dash comes after --. Without -d it stays with the machine:
- The machine’s output follows until it ends, stdout and stderr together, a
line at a time. It is read from the journal (nspawn’s own, or the system’s with
--log-driver journal), sonspawn logs NAMEshows it later, and the machine goes on shouldrunbe interrupted. With--log-driver noneit comes straight from the program and nothing is kept. runexits with the program’s exit code, or, for a program under the stub init, 128 plus the signal nspawn sent it: 130 after Ctrl-C, 137 afterkill. A signal from elsewhere (a crash, a kill from inside the machine, Ctrl-C with-t) gives 255 there. A program that is PID 1 ends with 1 whatever signal killed it butkill’s SIGKILL (137), which is how systemd-nspawn ends then. Ctrl-C, SIGTERM, SIGHUP and SIGQUIT go to the program; a third Ctrl-C within a second leaves the machine running and returns.-igives the program this standard input,-ta terminal; closing that terminal stops the machine, since nothing would read it any more.--rmremoves the machine once it ends, and leaves nothing behind when the start fails. Named volumes stay, and so does an image the run had to pull, under the image’s local name; without--namethe machine gets a name of its own.
sudo nspawn run --rm docker.io/library/busybox:latest sh -c 'echo hi; exit 3'; echo $?
echo abc | sudo nspawn run -i --rm docker.io/library/busybox:latest wc -c
sudo nspawn run -it --rm docker.io/library/alpine:3 sh
sudo nspawn run -d --name web -p 8080:80 docker.io/library/nginx:latest
A booted image shows its console until it powers off (Ctrl-C powers it off).
run -it on one waits for its boot, opens a root shell and powers the machine
off when the shell ends, with the shell’s exit code: sudo nspawn run -it --rm fedora:44 is a throwaway Fedora with its own systemd.
Boot machines
A boot image is started with Boot=yes: systemd-nspawn runs the image’s init
as PID 1, like machinectl start does, and the machine joins the bridge with
an address that its own systemd-networkd configures.
execenters the machine’s namespaces directly, so nothing is needed inside: no D-Bus, no PAM. The exit code comes back and the program is found on the machine’sPATH.shellopens machined’s login session (OpenMachineShell), which needs D-Bus inside the machine; the hub images have it. Right afterstartthe machine’s D-Bus may not be up yet, soshellretries for up to 20 seconds instead of failing.stopasks machined to power the machine off and repeats the request every two seconds until the machine is gone, for up to a minute; the repetition covers the window right after boot in which the init has not installed its signal handlers yet.--forcekills every process at once.- With volumes, a small unit mounted into the machine,
nspawn-volumes.service, holdslocal-fs.targetuntil all of them are mounted, so services find their data in place whatever the backend.
App machines
An app image is started with Boot=no and ProcessTwo=yes: systemd-nspawn’s
stub init is PID 1 and the image’s entrypoint runs under it, as its child, with
the environment, working directory, user and stop signal from the OCI config.
Its network namespace is built on the host before the program starts, so the
network is there when it starts.
systemd-nspawn hands its payload NOTIFY_SOCKET=/run/host/notify, a socket only
root may write to with recent systemd: a program that notifies systemd as another
user logs that it could not, and goes on. -e NOTIFY_SOCKET= (or
NOTIFY_SOCKET: "" in a compose file’s environment) quiets one that takes an
empty value for none, as Go’s and Erlang’s clients do; libsystemd’s and Rust’s
refuse it.
A user namespace cannot join a network namespace that belongs to the host, so an
app on a bridge network runs without one (PrivateUsers=no), and so does an app
given devices of the host on any network, since a device node cannot be idmapped.
Capabilities,
seccomp and the other namespaces still apply. Such an app gets the capabilities
images need to run as written (CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL,
MKNOD, NET_BIND_SERVICE, NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT,
AUDIT_WRITE) plus SYS_BOOT, with the kexec system calls filtered out. It does
not get systemd-nspawn’s set: its
CAP_SYS_ADMIN outside a user namespace is root on the host. --cap-add puts
one back, --privileged all of them.
An image that carries s6-overlay (linuxserver.io’s images, Home Assistant’s and
others) runs with its /init as PID 1 of the machine, as s6 requires:
ProcessTwo= is left out and the stop signal becomes
systemd-nspawn’s KillSignal=.
--init=false asks the same of any app, and --init puts the program back
under the stub init. A program that is PID 1 ignores the signals it has no
handler for, so stop kills it after its timeout when it does
not handle its stop signal.
execandshellenter the namespaces of the machine’s leader process, on a terminal when standard input and output are one, with the image’s environment.execruns as the machine’s user,shellruns/bin/shas root, and-u USERswitches user for both.stopsends the image’s stop signal (StopSignalin the config,SIGTERMby default,--stop-signalover it) to the program itself, waits--timeoutseconds (the machine’s--stop-timeout, else 10) and kills the machine if it is still there. A stop through systemd (systemctl stop, shutdown) reaches the stub init as a poweroff request, which it answers with SIGTERM and SIGHUP to the program.- A program that exits on its own ends the machine; its network and ports are
released by the unit hooks, and
nspawn stopon it afterwards only clears what it left behind and saysNAME was not running.
Command, entrypoint and environment
What an app runs comes from its image’s entrypoint and cmd, unless the flags say otherwise:
- The arguments after
--replace the image’scmdand follow itsentrypoint.nspawn start web -- nginx -Tstill runs/docker-entrypoint.shfirst. - An argument may hold newlines, as a script for
sh -cdoes, from the command line, compose or the image’s own config: the program is then started through the image’s/bin/sh, which gets the arguments encoded and runs the program with them as they were given. A variable’s value may hold line breaks the same way (-e, compose’senvironment): it goes in encoded and the image’s/bin/shturns it back before the program runs; its name is then one of letters, digits and underscores. An image without/bin/shcannot take such an argument or variable. --entrypoint PROGRAMreplaces the entrypoint with one program and drops the image’scmd, so only the arguments after--follow it (--entrypoint /bin/shalone is a shell).--entrypoint ""drops the entrypoint, so the arguments run alone. An app left with nothing to run is refused withno command specified.- Both are remembered.
--image-commandforgets them and runs the image’s own entrypoint and cmd again. -e VAR=valueadds a variable on top of the image’s, and-e VARcopies it from the shell that runs nspawn, or is left out when the shell does not have it, so-e DEBUGpasses an optional setting on only where it is set; astartwhose variables are all left out keeps the ones the machine remembers.--env-file FILEreadsKEY=VALUElines (no quoting, no expansion,#comments), before the-ethat follow. The later value of a variable wins,-e noneforgets them all, andexecsees the same environment as the program. Any name but an empty one is taken (discovery.type, as Elasticsearch reads it).- The working directory, the user and the stop signal come from the image
unless
-w,-uand--stop-signalsay otherwise. The user and group are resolved from the image’spasswdandgroupfiles. WithUSER:GROUP, from the image’s config or from-u, the group (a name or a number) is the program’s primary and only group. systemd-nspawn asksgetentinside the machine, which busybox lacks and musl cannot answer, so nspawn binds a stand-in that answers these lookups and passes others to the image’s own getent. An image without a shell (distroless, scratch) gets a small program instead, so-u 1000:1000works there too. A uid missing frompasswdruns with group 0 and/as home.
ps shows the effective command; nspawn run --rm IMAGE true is a quick way to
check that an image runs at all.
Volumes
sudo nspawn start web -v /srv/www:/usr/share/nginx/html:ro -v pgdata:/var/lib/postgresql
-v SOURCE:TARGET[:MODE] mounts a host directory or a named volume:
- An absolute
SOURCEis a host directory (or file) that has to exist already:startrefuses a path that is not there rather than making one. ASOURCEstarting with.is a directory relative to the one the command runs in, and a host path is taken cleaned. ASOURCEwithout a leading/is a named volume that nspawn keeps under/var/lib/nspawn/volumes/NAME, created on first use with what the image has atTARGET, owner and files included (a program that runs as a user finds its data directory its own), and never deleted byrmorimages rm. Names may contain letters, digits,_,.and-, and may not start with a dot. TARGETis an absolute path inside the machine, other than/. The same target cannot be mounted twice.:romounts it read-only;:rwis the default. The other words a mode may hold are taken and do nothing:zandZ(the policy labels what a machine sees), a propagation (systemd-nspawn binds with its own),nocopy, andcached,delegatedorconsistent. Paths with whitespace are not supported, and the same-vgiven twice is one mount.-vis repeatable and remembered;-v noneforgets every volume.--mount type=bind|volume|tmpfs,source=...,target=...is the long form of-vand--tmpfs(readonly,tmpfs-size,tmpfs-mode); a volume needs its name, as a mount makes no anonymous volume, and a source or target with:is refused.
In machines that run with private users (every machine but an app on a bridge
network, in another machine’s network or given devices of the host), the mount is
idmapped, so a file root writes inside belongs to root on the host, and the other
way round. A source on a file system that takes no idmapped mount (overlayfs,
devtmpfs, proc, sysfs, NFS, CIFS, FUSE, tmpfs before Linux 6.3) goes in as it is,
its owners unmapped inside. On mstack
machines nspawn attaches
the volumes from the host right after the machine’s init starts, since
systemd-nspawn cannot idmap binds under managed user namespaces; that is what
nspawn-volumes.service waits for, for up to two minutes, failing visibly
otherwise. On overlay and flat the volumes come from the settings file and are
there before the init even runs.
Named volumes outlive the machines that use them. nspawn volume ls lists them
with the machines whose records mount them (--filter label=KEY[=VALUE] only
those with the label), volume create NAME makes one ahead of its first use
(filled from the image of the first machine that takes it while it is empty;
--label KEY=VALUE labels it), volume inspect shows what nspawn keeps of them
as JSON, and volume rm and volume prune remove the ones no machine uses; a volume a machine still names is refused until that machine is
started with other volumes (or -v none) or removed.
Labels
sudo nspawn start web --label caddy=web.example --label tier=front
The image’s own labels (LABEL in a Dockerfile,
OciLabels= in a mkosi.conf) are read from its configuration when it is
pulled or built, and -l/--label KEY=VALUE (a KEY alone for an empty value) on start, run or create adds the
machine’s own on top, remembered like the ports; --label-file FILE reads
them from a file of KEY=VALUE lines, and --label none forgets them.
nspawn does nothing with them itself: nspawn inspect and ps --json show the
merged labels and the image’s image_labels, for tools that configure
themselves from them, a reverse proxy say.
Restart policies and limits
sudo nspawn start web --restart unless-stopped -m 512m --cpus 1 --pids-limit 500
--restart takes four policies:
no, the default: a machine that ends stays down.on-failure: restarted when its program, or its init, dies with an error;on-failure:Nis accepted, but the count is ignored: systemd restarts on every failure.always: restarted whenever it ends, and started at boot.unless-stopped: likealways, untilnspawn stop, which also takes it off the boot list until the nextnspawn start.
A machine that ends is started again after a second, then later and later, up
to half a minute, for as long as it keeps failing; ps shows it as
restarting meanwhile, and nspawn stop ends that. The ports, the address and
the volumes come back with it, since the unit’s own hooks prepare every run.
always and unless-stopped enable the unit the way machinectl enable does,
and removing the machine takes that back. machinectl stop stops a machine for
good too, but does not take an unless-stopped one off the boot list. A
poweroff inside the machine is an ending like any other, which always and
unless-stopped restart.
-m/--memory (512m, 2g), --cpus (0.5, 2) and --pids-limit bound
the whole machine: they are the MemoryMax=, CPUQuota= and TasksMax= of its
unit, which is why nothing inside shows them. --memory also
lets the machine use as much swap again (MemorySwapMax=), and no more, unless
--memory-swap gives memory and swap together: equal to
--memory for no swap, larger for the difference, -1 for swap without a
bound. 0 removes a limit, and brings --memory-swap back to its default;
--pids-limit -1 removes it too.
The restart policy and the limits are remembered like the ports and apply at
the next start, and
nspawn update changes them without one: a running
machine gets the new limits in its cgroup at once.
sudo nspawn update web -m 1g --cpus 2 --restart always
Healthchecks
sudo nspawn start web --health-cmd "curl -fsS http://localhost/ || exit 1" --health-interval 10s --health-retries 3
An image’s HEALTHCHECK applies as it is, and
--health-cmd with --health-interval, --health-timeout,
--health-retries, --health-start-period and --health-start-interval on
start, run, create and update each replace their part of it
(--health-interval 10s alone keeps the image’s test, and 0 for a timing or
the retries, like an empty --health-cmd, is no value given); --no-healthcheck
turns it off; timings given without a command on an image that has none are kept
and probe nothing. Durations are written 10s, 1m30s or 500ms, and the
defaults are 30 seconds between probes, 30 seconds for a probe, 3 failures in a
row before the machine is unhealthy, no start period, and 5 seconds between
probes during one.
The probe runs inside the machine as its user: a --health-cmd line through
the image’s shell (/bin/sh -c unless its SHELL says otherwise), an image’s
CMD form as it is (exit 0 is healthy). It runs from a unit of its own,
nspawn-health-NAME.service, which requires the machine’s and goes with it. The machine is starting until a probe succeeds or
the start period ends, healthy after a success and unhealthy after the
retries fail in a row; a success brings it back.
psshows the health next to the state:running (healthy),running (unhealthy),running (health: starting).inspectshowshealth(the status),health_failing_streakandhealth_log, the last five probes asEND EXIT_CODE OUTPUT, andhealthcheckas it applies (durations in microseconds, 0 for the default):"health": "healthy", "healthcheck": { "interval": 2000000, "retries": 2, "start_interval": 0, "start_period": 0, "test": [ "CMD-SHELL", "test -f /ok" ], "timeout": 0 },eventsreportshealth_statuswith the new status on every change.updatechanges the healthcheck of a running machine at once, and the probes start afresh.
A stopped machine has no health, and neither has one without a healthcheck.
Dependencies
sudo nspawn network create shop
printf 'example' | sudo nspawn secret create pgpass
sudo nspawn run -d docker.io/library/postgres:17 --name db --network shop -v pgdata:/var/lib/postgresql/data \
--secret pgpass -e POSTGRES_PASSWORD_FILE=/run/secrets/pgpass --health-cmd "pg_isready -U postgres" --health-interval 2s
sudo nspawn run -d docker.io/library/nginx:1.27 --name web --network shop -p 8080:80 --depends-on db:healthy
--depends-on NAME[:CONDITION][:optional] on run, create and start is
compose’s depends_on for one machine: the other machine is started first,
and this one waits for what the condition asks of it.
| Condition | Waits until |
|---|---|
started (the default) | the other machine’s start went through. |
healthy | its healthcheck says healthy; it needs one, and an unhealthy verdict fails the start. |
completed | its program ended with 0, for a machine that prepares something and exits, such as a migration. It runs until one run of it succeeds: it runs first only when no run of it ended with 0 yet (a run of an earlier boot counts by the exit code the journal kept), not at every start or restart of the machines that depend on it. |
With :optional a failure of the other machine is a warning and this one
starts anyway. The flag is repeatable and remembered like the rest,
--depends-on none forgets them, and inspect lists them as depends_on.
The machines named must be nspawn’s, a machine cannot depend on itself, and a
cycle is refused, --network container: counting as a dependency.
systemd does the work: the machine’s unit wants the other one’s and is ordered
after it (one that completes is only ordered after it, and the hook starts it
when it has to run), and its first hook, nspawn await-dependencies, waits for
the condition. So machinectl start, a start at boot and a restart policy follow
the same order as nspawn start, which says which machines it started or ran
first:
$ sudo nspawn stop web db
stopped web
stopped db
$ sudo nspawn start web
started web
db started first: web depends on it
When a dependency fails, the start fails with its reason
(web did not start: ...). A start that waits for healthy or completed,
directly or through the machines its dependencies depend on, has no time
limit, since it lasts as long as the other machine takes; nspawn stop ends
it. A machine on another one’s network (--network container:NAME) that also
depends on it has that machine started first. Stopping a machine leaves the ones
that depend on it running, and rm refuses a machine others
depend on unless they are removed in the same command.
Other flags
sudo nspawn start web --hostname web-1 -u nobody -w /tmp --cap-drop ALL --cap-add NET_BIND_SERVICE \
--read-only --tmpfs /run:size=64m --tmpfs /tmp --shm-size 64m --device /dev/null:/dev/nullo \
--dns 10.99.0.1 --dns-search example.test --add-host db:10.99.1.3 --add-host gw:host-gateway \
--ulimit nofile=1024:4096 --oom-score-adj 500 --stop-signal SIGQUIT --stop-timeout 30 \
--sysctl net.ipv4.icmp_echo_ignore_all=1
They are remembered like the rest and shown by
inspect. Each becomes a line of the machine’s .nspawn settings file or of
its unit:
--hostname NAME: the hostname inside, the machine’s name by default. A booted machine gets it as its/etc/hostname, over the image’s, since its systemd sets the hostname from that file.-u USER[:GROUP]and-w DIR: the user (a name or a uid, listed in the image’spasswdor not, with a group of the image’sgroupfile or a gid after a colon; a group name the image lacks is refused) and the working directory the program runs with, instead of the image’s. App images only;exectakes the same user unless its own-usays otherwise.--cap-add,--cap-dropand--privileged: capabilities added to or dropped from the default set (NET_ADMINorCAP_NET_ADMIN;ALL). For an app on a bridge network, which runs without a user namespace, the default set is the one App machines lists, SYS_BOOT included, so that a reboot from inside ends the machine; for a machine in a user namespace it is systemd-nspawn’s. An interface given with--interfacekeepsNET_ADMINon its own; a name that is no capability is refused.--cap-drop ALL --cap-add NET_BIND_SERVICEkeeps NET_BIND_SERVICE alone, and--privilegedkeeps every one.--read-only: the root read-only.--tmpfs PATH[:OPTIONS]: an empty tmpfs at a path (size=64m,mode=0700; 1777 when given without options), which is how a read-only machine still writes/tmpor/var/cache. The options are tmpfs’s own (size,mode,uid,gid,nr_inodes,nr_blocks,mpol,huge,inode32,inode64,noswap): systemd-nspawn mounts it read-write, nodev and strictatime, soro,noexec,nosuidand the other atime flags are refused rather than dropped./runis a tmpfs of every machine already, so a--tmpfson or below it (/var/runin most images) is left out with a note.--shm-size: the size of/dev/shm.--device HOST[:CONTAINER][:PERMISSIONS]: a device node of the host, bound into the machine and allowed to its cgroup (r,w,m, each once;rwmby default;/dev/sda:ris the node at the same place), or a directory such as/dev/dri, whose nodes are allowed one by one. An app given devices of the host runs without a user namespace, so root inside opens a node as its owner and mode allow; a booted machine keeps its user namespace, where a node keeps the host’s ownership. A CDI name such asnvidia.com/gpu=alltakes the devices of a spec instead; see GPUs and CDI devices.--device-cgroup-rule 'TYPE MAJOR:MINOR PERMISSIONS'lets the machine’s cgroup open nodes by their numbers, the nodes that appear while it runs included:sudo nspawn run -d --name flasher -v /dev/bus/usb:/dev/bus/usb \ --device-cgroup-rule 'c 189:* rwm' --network none IMAGEreaches a USB device that comes and goes, as a flasher’s does. A rule for every minor of a major goes by the device groups the host’s
/proc/devicesnames after it at each start (char-usb_devicefor 189), and is refused then for a major without one; one node goes by its numbers (c 189:3 rw).--dnsand--dns-search: the DNS servers and search domains the machine gets instead of the host’s resolvers, in an app’s generatedresolv.confand in a booted machine’s.networkfile.--add-host HOST:IP(orHOST=IP, for an IPv6 address): lines for its/etc/hosts,host-gatewaystanding for the host’s address on the machine’s network.--ulimit NAME=SOFT[:HARD]: resource limits of the program (nofile,nproc,core, …;unlimitedis a value).--oom-score-adj: the machine’s, -1000 to 1000.--stop-signaland--stop-timeout: whatstopandrestartuse for the machine instead of the image’s stop signal and the default 10 seconds;-tonstopstill wins, and-1waits for as long as the program takes.killreads the stop signal to decide whether the restart policy applies.--timezone MODE: how systemd-nspawn sets the machine’s/etc/localtimeat each start. Its default,auto, points it at the host’s zone every time: a link where the machine’s zone database has that zone, otherwise a copy of the host’s file, which a package installed inside, such as tzdata, may replace (a read-only root gets the host’s file mounted over it). A zone set inside withtimedatectldoes not survive a restart;offleaves the machine’s own, andcopy,bind,symlinkanddeleteare its other modes. An app also takes-e TZ=.--log-driver DRIVER: where the program’s output goes.local, the default, sends it to journald’snspawnnamespace, a journal of nspawn’s own apart from the system’s, so that a program that writes a lot never floods the host’s logs;logsand an attachedrunread it, andjournalctl --namespace=nspawnshows every machine’s. It holds 1 GiB at most, shared by all machines, the oldest lines going first (/usr/lib/systemd/journald@nspawn.conf, which/etc/systemd/journald@nspawn.confoverrides).journalkeeps the output in the system’s journal, andnonedrops it:logsthen refuses the machine, an attachedrunof an app still shows the output straight from the program, and an attachedrunof a booted machine, whose console goes nowhere, is refused: it runs with-d.--initand--init=false: the program under systemd-nspawn’s stub init, which reaps orphans and turns a poweroff into SIGTERM, or as PID 1; see App machines. App images only.--sysctl KEY=VALUE:net.*keys, set in the network namespace nspawn makes for an app machine on a bridge network, after the two it gets by default,net.ipv4.ping_group_range=0 2147483647andnet.ipv4.ip_unprivileged_port_start=0, which let a user other than root ping without raw sockets and bind a port below 1024. Nothing else is accepted, and a booted machine sets its own.--interface IFACE: a network interface of the host, moved into the machine while it runs and back on the host when it stops, a wifi adapter with its whole phy included. See Physical interfaces.
A list takes none to forget it (--cap-drop none, --tmpfs none);
--hostname, -u, -w, --stop-signal, --timezone and --log-driver go
back to the default with an empty string (--hostname ""), --shm-size with
0, and --privileged=false and --read-only=false take those back. A path inside the
machine, the target of a volume or a secret, a --tmpfs or a --device path,
must be plain: a . or .. component is refused, since it would land elsewhere
once mounted. A path the image
declares as a volume with nothing mounted over it gets a note at start: no
anonymous volume is made for it, so what is written there goes with the
machine.
GPUs and CDI devices
GPUs reach a machine through the Container Device Interface. The vendor’s tool
writes a spec under /etc/cdi or /var/run/cdi
that lists the device nodes, libraries, variables and hooks each device needs
(nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml for NVIDIA,
amd-ctk cdi generate for AMD), and the machine names the devices:
sudo nspawn run -d --name app --gpus all IMAGE
sudo nspawn start app --device nvidia.com/gpu=0
--device VENDOR/CLASS=NAME takes a device by the spec’s name (=all, =0, a
UUID), and --gpus all, --gpus 2 or --gpus '"device=0,1"' stands
for the devices of the NVIDIA spec (the AMD one when only that is there).
Compose’s gpus:, the devices of deploy.resources.reservations and CDI names
in devices: do the same. The specs are read again at every start, so a driver
update that rewrites them takes effect at the next one: the device nodes are
bound into the machine and allowed in its cgroup, the libraries bound where the
spec says, the variables set after the machine’s own, and the hooks
(nvidia-cdi-hook update-ldcache, create-symlinks) run just before the
machine starts, on its root.
Without a spec of the vendor, the start fails:
$ sudo nspawn run --rm --gpus all docker.io/library/busybox:1.37 -- true
error: could not select device driver "" with capabilities: [[gpu]]
The nodes are the host’s, with the host’s owner and mode. mstack machines take no CDI devices. It is tested with specs written for the test suite, not with real GPUs.
Secrets
printf '%s' 'hunter2' | sudo nspawn secret create db-password
sudo nspawn start db --secret db-password -e POSTGRES_PASSWORD_FILE=/run/secrets/db-password
secret create NAME reads the content
from standard input, or from -f/--file, and keeps it encrypted with
systemd-creds, bound to the host’s TPM2 where there is one and to its
credential key otherwise, under /var/lib/nspawn/secrets; -l KEY=VALUE
labels it. --secret NAME on start, run or create gives the machine the
plaintext at /run/secrets/NAME, a read-only file of root’s with mode 0444;
NAME:TARGET:MODE:UID:GID chooses the path, mode and owner. The file is
decrypted when the machine starts, into a tmpfs of
root’s alone, and gone when it stops.
secret ls lists the secrets with the machines that take them:
SECRET SIZE CREATED USED BY
db-password 7 B 9s ago db
secret inspect prints what is known about them, never the content, and secret rm
removes the ones no machine takes; one a machine’s record names is refused
until that machine is started with other secrets (or --secret none) or
removed. mstack machines take no secrets.
ps
sudo nspawn ps [-a] [--json] # same as: nspawn machines ls [-a] [--json]
MACHINE IMAGE MODE COMMAND STATE UP PID NETWORK OS
api docker.io/library/busybox:latest app /bin/sh -c while true; do sleep 1; done running 6s 895101 back:10.99.2.4 -
db docker.io/library/busybox:latest app /bin/sh -c while true; do sleep 1; done running 8s 894865 back:10.99.2.2 -
web docker.io/library/busybox:latest app /bin/sh -c touch /ok; while true; do ... running (healthy) 7s 894994 front:10.99.1.2 back:10.99.2.3 127.0.0.1:8080->80/tcp -
ps lists every container machined knows about (the virtual machines it also
registers, libvirt’s among them, are left out); machines that nspawn did not
install show - in the image columns. -a adds the nspawn machines that are
not running. COMMAND is the effective entrypoint and arguments of an app,
and NETWORK the machine’s address on the default network, its other networks
as NAME:ADDRESS, and the published ports, or host, veth, none or
container:NAME. A
machine between two runs of its restart policy is listed as restarting even
without -a, a frozen one as paused, and a healthcheck follows the state:
running (healthy).
ps --json prints the same machines as the service returns them, each with its
whole record, and nspawn inspect NAME... prints one or more machines, running
or not, as a JSON array, for scripts.
ps also takes flags for scripts. -q prints the names alone,
--filter KEY=VALUE keeps the machines that match (name
as a regular expression, label, status, network, ancestor, health,
exited, volume, publish, expose, id, before, since; the same key
twice matches either value, different keys all), and --format takes a Go
template over each machine’s fields (.Names, .Image, .Command,
.CreatedAt, .RunningFor, .Ports, .State, .Status, .Labels,
.Label "KEY", .Mounts, .Networks, .Mode, .Address, .OS, .Pid),
table TEMPLATE for aligned columns with headers, or json for an object per
line. inspect -f TEMPLATE prints a template over each object, a line each; a
key the object lacks prints <no value>, an object the template fails on is said
after the others are printed, and -f json prints the objects on one line:
$ sudo nspawn ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
NAMES IMAGE STATUS
web nginx:alpine Up 1 second
worker busybox:1.37 Up Less than a second
$ sudo nspawn ps -q --filter label=tier=front
web
$ sudo nspawn inspect -f '{{.state}} {{index .labels "tier"}}' web worker
running front
running back
exec and shell
sudo nspawn exec MACHINE [-u USER] [-e VAR[=VALUE]]... [-w DIR] [-T] [-t] [-i] [-d] COMMAND...
sudo nspawn shell MACHINE [-u USER]
exec runs one command inside a running machine of either kind and exits with
the command’s status, so it works in scripts and pipelines. The program is looked
up on the machine’s PATH, the image’s environment and the -e variables of the
machine apply, and the working directory is the machine’s. Neither D-Bus nor
anything else is needed inside:
- Without
-uthe command runs as the machine’s user, its--useror else its image’sUSER, and as root on a booted machine.-u USER[:GROUP]takes names or numbers of the image’s passwd and group files; with a group, that one is the only group. -eadds variables for this command,-wa working directory.- A terminal is used when standard input and output are both one, so what goes
through pipes is byte exact;
-Trefuses a terminal even from one and-tasks for one even without.-iis accepted and ignored. -dleaves the command running in the background and returns at once.
The command runs with the machine’s capabilities and resource limits, like the
machine’s own processes, with one exception: in an app that shares the host’s
user namespace and is not --privileged, it goes without CAP_SYS_BOOT, which
the machine keeps only beside a seccomp filter against kexec that the command
does not carry. Right after a start, exec waits up to five seconds until
systemd-nspawn has finished confining the machine.
$ sudo nspawn run -d --name worker -u 65534 docker.io/library/busybox:1.37 -- sleep 600
started worker
$ sudo nspawn exec worker -- id
uid=65534(nobody) gid=65534(nobody)
$ sudo nspawn exec -u root worker -- id
uid=0(root) gid=0(root)
exec and a healthcheck probe into a paused machine fail at once. shell
opens an interactive shell as root (or -u USER): machined’s login session
for booted machines, /bin/sh in the machine’s namespaces for apps.
cp
sudo nspawn cp ./nginx.conf web:/etc/nginx/
sudo nspawn cp web:/var/log/nginx ./nginx-logs
sudo nspawn cp ./site/. web:/usr/share/nginx/html
cp copies files and directories between the host and a machine, running or
not:
- An existing directory as the destination receives the source under its own
name; anything else is the name of the copy. A destination ending in
/has to be a directory. DIR/.copies the contents of DIR rather than DIR.- What goes in belongs to root inside the machine, whatever user namespace it
runs in; what comes out belongs to whoever ran
cp. Modes and modification times are kept. - Paths inside the machine are resolved inside it, so a link there, absolute
or not, never leads to the host. Links are copied as links (a source that
ends in
/or/.names the directory a link leads to); devices, sockets and fifos are left out, and so are the kernel’s file systems mounted inside a running machine (/proc,/sysand the like). - A tree more than 2048 directories deep is refused. A file that fails to read fails the copy: the file of that name on the other side stays as it was, and what was copied before it stays copied.
A stopped overlay or flat machine can be copied into and out of; a stopped
mstack machine cannot, since its tree only exists while it runs. A stopped
machine’s volumes are mounted only while it runs, so a path at or below one of
them, or a source that holds one, is refused until it starts. Where
SELinux enforces, a host directory mounted with -v keeps its own label, which
the service may not be allowed to write to;
named volumes are nspawn’s own and always work.
logs
sudo nspawn logs MACHINE... [-f] [-n N] [--since WHEN] [--until WHEN] [-t] [--all] [--inside]
What the machine writes to its console goes, by default, to journald’s
nspawn namespace under systemd-nspawn@MACHINE.service, apart from the
system’s journal (see --log-driver above), and logs reads it with
journalctl, together with the system’s journal, which holds systemd’s lines
and the output of a machine with --log-driver journal. By default only the
machine’s own output is shown, from its creation on, every run of it included;
a removed machine of the same name does not show:
-fkeeps printing new output, starting from the last 10 lines unless-nsays otherwise.-n N(or--tail N) shows the last N lines,allevery one.--sinceand--untiltake a duration ago (42m,1h30m), Unix seconds, an RFC 3339 time or date, or a time asjournalctlreads it (-1h,"10 min ago","2026-09-24 10:00"), and nothing is followed past--until;-tprefixes each line with its timestamp.- Several machines at once are shown together, every line behind its
machine’s name (
web | ...). --allalso shows what systemd logged about the unit: start, stop, failures.--insidereads the journal of a booted machine itself (journalctl --machine), which is where the services running inside log.
logs works for stopped machines too, since the journal keeps what they wrote.
stats
sudo nspawn stats [NAME...] [--no-stream] [--json]
stats shows what each running machine uses, drawn again
every second: CPU (100% is one CPU busy), memory in use against the limit (the
host’s memory without one), network and disk traffic, and processes. The
numbers are those of the cgroup of the machine’s unit, which holds the whole
machine, and of its interfaces as the machine sees them. --no-stream prints
one table, --json one object per machine and reading.
NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
api 0.00% 2.4 MiB / 3.8 GiB 0.06% 372 B / 0 B 0 B / 4.0 KiB 3
db 0.00% 2.4 MiB / 3.8 GiB 0.06% 892 B / 0 B 0 B / 4.0 KiB 3
web 0.00% 2.4 MiB / 3.8 GiB 0.06% 522 B / 0 B 0 B / 4.0 KiB 3
events
sudo nspawn events [--since WHEN] [--until WHEN] [-f KEY=VALUE]... [--json]
events reports what happens: machines that start, die
(with their exit code), stop, are restarted, run out of memory, fail or change
health (health_status), and what nspawn does: pulls, builds, creations,
pushes, kills, updates, pauses, auto-updates and removals, of networks, volumes
and secrets too. It
reads the journal, where systemd logs every start and end of a machine’s unit
however it was started, and nspawn logs what it does, so --since reads past
events back; --since and --until take the forms logs takes.
Filters take name=, type= (machine, network, volume,
secret), event= and label=KEY or label=KEY=VALUE: the same key given
twice matches either value, different keys must all match. --json prints one
object per event. What events printed while a network backend was made, db
ran on it with run -d -l tier=data, run --rm --name job ... -- sh -c 'exit 3' ran and ended, and kill -s HUP db ended db:
2026-10-21T06:12:16.543884Z network create backend (created=1792563136, interface=nsbr-backend, internal=false, subnet=10.99.1.0/24)
2026-10-21T06:12:18.490159Z machine pull db (created=1792563138, image=docker.io/library/busybox:1.37, reference=docker.io/library/busybox:1.37, tier=data)
2026-10-21T06:12:18.843934Z machine start db (created=1792563138, image=docker.io/library/busybox:1.37, tier=data)
2026-10-21T06:12:20.792735Z machine pull busybox-1.37 (created=1792563140, image=docker.io/library/busybox:1.37, reference=docker.io/library/busybox:1.37)
2026-10-21T06:12:21.920339Z machine create job (created=1792563141, from=busybox-1.37, image=docker.io/library/busybox:1.37, reference=docker.io/library/busybox:1.37)
2026-10-21T06:12:22.569822Z machine start job (created=1792563141, image=docker.io/library/busybox:1.37)
2026-10-21T06:12:22.605119Z machine die job (code=exited, created=1792563141, exit_code=3, image=docker.io/library/busybox:1.37)
2026-10-21T06:12:22.641937Z machine fail job (created=1792563141, image=docker.io/library/busybox:1.37, result=exit-code)
2026-10-21T06:12:23.051001Z machine remove job (created=1792563141, image=docker.io/library/busybox:1.37)
2026-10-21T06:12:23.142907Z machine kill db (created=1792563138, image=docker.io/library/busybox:1.37, signal=1, tier=data)
2026-10-21T06:12:23.153501Z machine die db (code=exited, created=1792563138, exit_code=129, image=docker.io/library/busybox:1.37, tier=data)
Times are in UTC. A machine’s die carries the exit code of that run:
128 plus the signal nspawn sent it (143 after a stop), or the status systemd
logged once the machine’s record is gone (255 for an app killed by a signal).
nspawn’s own events of a type carry the same attributes whatever the action, so
a remove says as much as a create: image= and created= (which machine of
the name it was) on a machine’s, subnet=, interface=, internal= and
created= on a network’s, path= on a volume’s, size= on a secret’s.
systemd’s take image=, created= and the labels from the machine’s record,
and lack them once it is removed.
stop
sudo nspawn stop NAME... [-f] [-t SECONDS] [--no-wait]
Stops the machines as described above for boot and app machines, one after
the other, waits until each is gone (a minute at most for a booted machine),
stops the unit so that the image can be removed right away, clears the failure
a signal-killed program leaves on the unit, releases the firewall exceptions
of a veth machine and removes the machine’s published ports and network
namespace. --no-wait returns right after the request, without the kill after
--timeout (the machine’s --stop-timeout, else 10 seconds); the unit hooks
release the network when the machine ends. With a restart policy, --no-wait
on an app also lets the stub init send the program SIGTERM and SIGHUP, since
nothing stays behind to stop the unit later. -t -1 waits for as long as the
program takes. -f kills every process at once. Stopping a machine that already
ended is not an error, and a
paused machine is thawed first.
A machine with a restart policy stays stopped until it is started again: an
unless-stopped machine is also taken off the boot list until the next
start, while an always one still starts at the next boot.
restart
sudo nspawn restart NAME... [-t SECONDS]
restart is a stop followed by a start with everything the machine
remembers; a machine that is not running is started.
-t is stop’s.
pause and unpause
sudo nspawn pause NAME...
sudo nspawn unpause NAME...
pause freezes every process of a machine through the cgroup freezer of its
unit, and unpause thaws them. ps shows a frozen
machine as paused; stop thaws it first, and kill thaws it for a signal
that stops it.
top
sudo nspawn top MACHINE
PID USER TIME COMMAND
894994 root 00:00:00 (sd-stubinit)
895009 root 00:00:00 /bin/sh -c touch /ok; while true; do sleep 1; done
895198 root 00:00:00 sleep 1
top lists the processes of a running machine: the PID as
the host sees it, the user as the machine sees it, CPU time and command, read
from the machine’s cgroup and PID namespace.
kill
sudo nspawn kill web # SIGKILL: stopped for good
sudo nspawn kill -s HUP web # a signal for the program
kill sends a signal to machines. SIGKILL, the default,
stops the machine for good, as stop --force does. Any other signal goes to
the program of an app, or to the init of a booted machine, and counts as a
stop that keeps the restart policy from bringing the
machine back, unless the machine has a stop signal of its own (its image’s
STOPSIGNAL or --stop-signal) and this is another one: then the policy
decides, should the signal end it. A booted machine’s halt and poweroff
requests count as a stop; other signals to a booted machine leave it to the
policy. Right after a start, kill waits a few seconds at most for the program
to be running, so that the signal reaches it.
wait and port
$ sudo nspawn run --name job docker.io/library/busybox:1.37 -- sh -c 'exit 3'
$ sudo nspawn wait job
3
$ sudo nspawn port web
80/tcp -> 0.0.0.0:8080
80/tcp -> [::]:8080
wait NAME... waits for machines to stop and prints each one’s exit code, and
port NAME [PORT[/PROTO]] the ports a running machine publishes.
rm
sudo nspawn rm web2
sudo nspawn rm -f web # stop it first
rm removes a machine: its record, its tree, its unit files, the boot link a
restart policy made, and the layers nobody else uses. A pulled image is a
machine too, so rm and images rm remove the same thing; rm -f stops a
running machine first where both refuse otherwise. Named volumes are kept, and
rm says which.
A machine that others depend on (--depends-on) or take their network from
(--network container:NAME) stays while they are there. Named in the same
command, they are removed first and the machine after them; one of them that
stays (running, without -f) keeps it too.
The same machines on another host
nspawn generate [NAME...] prints the commands that make machines again, on
another host or on this one after a reinstall: a shell script with
network create for the networks they join, then run -d for each machine
with every flag it keeps, after the machines it depends on or takes its network
from, which come along when they are not named. Without names it covers every
machine nspawn has. For the two machines of Dependencies:
$ sudo nspawn generate web
# secret pgpass is encrypted for this host: nspawn secret create pgpass where this script runs, before it
nspawn network create shop --subnet 10.99.1.0/24
nspawn run -d --name db --pull always docker.io/library/postgres:17 \
--network shop \
--env POSTGRES_PASSWORD_FILE=/run/secrets/pgpass \
--volume pgdata:/var/lib/postgresql/data \
--health-cmd 'pg_isready -U postgres' \
--health-interval 2s \
--secret pgpass:/run/secrets/pgpass:0444:0:0
nspawn run -d --name web --pull always docker.io/library/nginx:1.27 \
--network shop \
--publish 8080:80 \
--depends-on db:healthy
Each run -d pulls its machine on its own (--pull always; blobs already
there are not downloaded again), so that a machine never takes the network
kind, mode or backend of another one made from the same image. An image
without flags of its own is pulled again instead of run, unless another
machine takes its network, which needs it running. What a flag cannot carry is
said in comments at the top: a secret is encrypted for this host, so
nspawn secret create it there first; a host directory a machine mounts has
to exist there; an image built here has to be pushed first; a healthcheck in
exec form comes back as --health-cmd, which runs it through /bin/sh, so the
image needs one. The contents of named volumes and of a machine’s writable layer
stay behind, and so do the addresses nspawn handed out: volumes are seeded again
from the image, and only addresses fixed with --ip come along.