cd ~{,/zhs/}

Run Desktop App with systemd-nspawn Container

Meta Info




Choose Distribution

One could install basically almost any modern Linux distribution into a container, the tools and instructions depend on the distributions of both host and container.

I’ve tried Arch Linux and Ubuntu 18.04 containers on my desktop running Arch Linux. For more examples, refer to examples in the manual.

Arch Linux Container

$ su # switch to root
# container_dir=/var/lib/machines # directory of containers
# mkdir $container_dir # create it if not exists yet
# cd $container_dir # change into it
# container_name=archlinux-container
# mkdir $container_name
# pacman --sync arch-install-scripts # provides `pacstrap` command
# pacstrap -c $container_name

About the last pacstrap command:

Ubuntu 18.04 Container

# container_dir=/var/lib/machines # directory of my containers
# mkdir $container_dir # create it if not exists yet
# cd $container_dir # change into it
# container_name=bionic-container
# bionic_repo_url=''
# pacman --sync debootstrap ubuntu-keyring
# debootstrap --include=systemd-container --components=main,universe bionic $container_name $bionic_repo_url

For $bionic_repo_url, one could select a suitable mirror from Launchpad.

Command debootstrap should finish with I: Base system installed successfully. If last output is E: Couldn't download packages:..., just run it again to complete installation.

Configure and Run

After acquiring a directory of minimal installation of distribution, one can use command systemd-nspawn -D $container_name to start up the container, -D stands for --directory=.

There are two easy and full-featured methods to configure a container:

At first I did try with configuration files, then I realized that, it would be much more flexible and less hard-coded to run command in shell scripts.

Environment Variables

--set-env=, -E in command options or Environment= in [Exec] section of configurations.

Access Control


No command option needed, or VirtualEthernet=no in [Network] section of configurations.


By default a container has no access to host filesystem.

Mount host path with --bind= in command options or Bind= in [Files] section of configurations; --bind-ro= or BindReadOnly= for read-only access.

These options could be used multiple times, so that you may expose as many files/folders to container as you want. I recommend allowing read-only access as long as it works.

I’ll show several simple use cases below, with basic --bind=, other forms are similar.

Simplest case, expose certain file/folder on host to the same path in container:

$ host_path=/absolute/path/on/host
# systemd-nspawn \
> --directory=$container_name \
> --bind=$host_path

If you want container to access it with different path:

$ host_path=/absolute/path/on/host
$ container_path=/absolute/path/inside/container
# systemd-nspawn \
> --directory=$container_name \
> --bind=$host_path:$container_path

To allow one-time access, you could use an empty string for host path: --bind=:$container_path. systemd-nspawn will create a temporary folder for container below the host’s /var/tmp directory, and remove it automatically when container is shut down.

By default, a folder will be bind-mounted recursively. To allow access to particular path without sub-directories below it, you could append mount option: --bind=$host_path:$container_path:norbind.


For quick and full access, mount the whole /dev/ directory on host to container.

For more fine-grained access control, consider what devices does app really need to run well, and which files/directories under /dev/ are the devices allocated to mount.

Here is what I understood for my own usage:

Device File Access to
/dev/dri/card0 First graphics card of Intel or AMD
/dev/nvidia0 First graphics card of NVIDIA
/dev/shm Shared memory
/dev/input/js0 First joystick

Note: For NVIDIA cards, you may need to bind mount /dev/nvidia* to make it work.

Mounting device directory/file is not enough, you also need to grant corresponding access to specific device nodes by DeviceAllow= property.

To specific device node, use /dev/full/path/to/device as specifier; for a group of device nodes, run cat /proc/devices to check out device group list:

For access control:

For example, my AMD graphic card and joysticks:

--bind=/dev/dri/ --property=DeviceAllow='char-drm rw' # Graphic cards
--bind=/dev/input/ --property=DeviceAllow='char-input r' # Joysticks


Pass X server temporary directory and DISPLAY environment variable on host to container:

--bind-ro=/tmp/.X11-unix/ and --set-env=DISPLAY=$DISPLAY

or in configuration file:



(Run echo $DISPLAY to see its value, which is usually :0.)

X Authority

As far as I experienced, if run GUI application as the same user as host, one may not need to handle the authority stuff. So if window shows up after instructions above, just skip this step.

$ auth_file=/tmp/${container_name}_xauth
$ xauth nextract - "$DISPLAY" | sed -e 's/^..../ffff/' | xauth -f "$auth_file" nmerge -
# systemd-nspawn \
> --directory=$container_name \
> --bind=/tmp/.X11-unix \
> --bind="$auth_file" \
> --set-env=DISPLAY="$DISPLAY" \
> --set-env=XAUTHORITY="$auth_file"

Prepare Application

Startup the container, install your application, create an unprivileged user then run it for test. If it doesn’t work as expected, go back to configure container out.

UI Consistency

DBus Tray

if [[ -n $DBUS_SESSION_BUS_ADDRESS ]]; then # remove prefix
else # default guess
sudo systemd-nspawn --directory=$container_name \
--bind-ro=$host_bus:$container_bus \


if [[ -n $PULSE_SERVER ]]; then # remove prefix
else # default guess
systemd-nspawn --direcoty=$container_name \
--bind-ro=$host_pulse:$container_pulse \

Note that: Apps that explicitly depend on ALSA could break PulseAudio. For Arch Linux container pacman --sync pulseaudio-alsa --assume-installed pulseaudio solves the problem.


If app starts up but looks different than how it does on host. Mounting themes, icons and fonts to container would help:


Note that the user directories like ~/.cache, ~/.config and ~/.local/share in container need to be existing before mounting, otherwise applications would probably fail to write into them.

For font configuration:


For cursor icon search path:


These two commands would be useful to manually refresh cache in container:

fc-cache --force
gtk-update-icon-cache --force

Ready to Go

After test, container should be ready for app. Write a shell script and wrap it into a desktop entry file, so it looks like a native app then.

Shell Script



# Path to container
container_path=/var/lib/machines # container parent directory
container_path+=/$container_name # container root directory

# Binary of target application

# Run container directly into target application
pkexec systemd-nspawn \
--directory=$container_path \
--user=$USER --chdir=$HOME \
--bind-ro=/tmp/.X11-unix/ \
--as-pid2 $app_binary

Tool pkexec is used for root authorization under GUI, feel free to replace with sudo if doesn’t require password.

See also: my fish script to run steam: ~/.local/bin/steam.

Desktop Entry

Reference: Desktop entries (.desktop)


[Desktop Entry]
Name=Your App
Comment=Description of your app

For Exec key, script file name app.bash could be used only after adding its parent folder to your PATH environment variable, otherwise input its full path /home/host_username/.local/bin/app.bash. Former method is also convenient for running from terminal.

For Icon key, put custom icon file into $XDG_DATA_DIRS/icons/hicolor/[size]/apps/.

See also: my desktop entry to run steam: ~/.local/share/applications/steam.desktop.