Running Graphical Applications in Incus Containers
My everyday Linux distribution is Alpine Linux, which uses the musl libc C standard library implementation instead of the more common GNU libc (or “glibc” for short). As a consequence, some compiled binaries don’t work natively on Alpine since they are linked against glibc. In those cases, I need a way to run the applications without a ton of overhead and in a way that is relatively easily manageable.
In this post I’ll show you one way to do this, using Incus system containers. I’m currently using this method to play the (really nice) game Stardew Valley and have also tested it with Firefox, both using Wayland and X11 (through xwayland).
What are system containers?
Unlike a virtual machine (VM), Incus system containers do not emulate a whole virtual system with hardware and everything, but instead make use of Linux kernel features to create an isolated environment in which the processes get executed.
This allows for good isolation with a lot of control over networking, hardware access, file access and even RAM usage, without the larger overhead of a full virtual machine. It is basically just the programs running in their own environment, but on top of your host linux kernel.
What is Incus?
Incus is basically just a manager of the system containers. It also supports virtual machines and application containers, but I won’t cover these here.
Incus has a nice CLI and makes it easy to configure and manage containers, so I found it very convenient to use for this project.
Choosing a distro for the container
I decided to go with Devuan for my container OS, for the simple reason that I am familiar with Debian, which uses glibc, but wanted to avoid systemd - Devuan exactly fills this use case. This should also work for other OS though, with minor changes. I will later show an example for Ubuntu aswell.
What do we need to get GUIs to work?
Usually, your desktop environment is either running on Wayland or X11 as a display protocol. In the case of Wayland, there is also the compatibility layer xwayland, which allows running X11 applications on a Wayland system. The way your applications create windows and display content is usually by talking to a UNIX-socket with one of these protocols.
Thus, to make GUI applications work inside the container, we will need to give the container access to these sockets on the host system. This allows the applications to integrate visually seamlessly with your host OS.
My host system uses Sway, which is Wayland based, and I also use xwayland, so I will pass through both the Wayland and the X11 socket to the container.
What about audio?
For audio, usually PulseAudio or PipeWire are used. PipeWire is a little newer and doesn’t have direct application support for a lot of applications, so even on systems that use PipeWire, usually pipewire-pulse is running, which allows PulseAudio-compatible applications to work with PipeWire.
In any case, it is basically just a matter of giving access to another UNIX-socket to the container system. My host system runs PipeWire with pipewire-pulse, so I could theoretically provide both the PipeWire and the PulseAudio socket to the guest, but I was lazy here and only passed through the PulseAudio socket since it has the widest support and PipeWire was not required. If you need to use PipeWire directly, just figure out the location of its socket on the main system (it should be practically the same place anyway) and adapt the configuration.
What about file access?
Perhaps you want your container to be able to access host files - in this case Incus makes it quite easy to mount a directory from the host system inside the container. In my case, I will actually mount my whole home directory inside the container to make my life a bit easier.
Creating the Incus configuration for our container
Incus containers can be configured in two ways basically, either interactively with the CLI using the command incus config set
or a bit more easily with incus config edit
, where we can configure our container with the YAML format.
You can also directly apply a configuration when you create a container with incus launch
by piping YAML formatted configuration to it through standard input.
You can also create re-usable configurations that can be used for multiple containers, called “profile”, with the commands under incus profile
. I used this to create a profile for all my future GUI containers for a specific OS, which I will show you now for both Devuan and Ubuntu.
An additional tool I make use of for configuring the container is cloud-init. Many container images that Incus provides exist in a “cloud” flavour, which has cloud-init installed. This can be used to install/update packages on the container, write files or execute commands to perform initial configuration on first boot of the container.
This is quite cool because it allows us to do basically everything in the config file and have an easily re-creatable container that doesn’t require any more manual setup after its creation.
Devuan GUI profile
Let’s start with the full configuration, I will explain it below:
config:
cloud-init.vendor-data: |
#cloud-config
package_update: true
package_upgrade: true
package_reboot_if_required: true
packages:
- pulseaudio-utils
- xserver-xorg-video-intel
runcmd:
- [update-rc.d, x11-common, disable]
- [service, x11-common, stop]
- [mkdir, -pm, "0700", /home/.run]
- [chown, -R, "1000:1000", /home/.run]
- [ln, -s, /mnt/wayland-1, /home/.run/wayland-1]
write_files:
- path: /etc/profile
append: true
content: |
export DISPLAY=:0
export PULSE_SERVER=/mnt/pulse.sock
export WAYLAND_DISPLAY=wayland-1
export XDG_RUNTIME_DIR=/home/.run
export XDG_SESSION_TYPE=wayland
if [ ! -d "/tmp/.X11-unix" ]; then
mkdir -pm 0700 /tmp/.X11-unix
chown -R 1000:1000 /tmp/.X11-unix
fi
ln -fs /mnt/X0 /tmp/.X11-unix/X0
description: Sets up GPU, Wayland, X11 and PulseAudio
devices:
gpu:
gid: "1000"
type: gpu
uid: "1000"
pulse:
bind: instance
connect: unix:/tmp/1000-runtime-dir/pulse/native
gid: "1000"
listen: unix:/mnt/pulse.sock
mode: "0700"
security.gid: "1000"
security.uid: "1000"
type: proxy
uid: "1000"
wayland:
bind: instance
connect: unix:/tmp/1000-runtime-dir/wayland-1
gid: "1000"
listen: unix:/mnt/wayland-1
mode: "0700"
security.gid: "1000"
security.uid: "1000"
type: proxy
uid: "1000"
x11:
bind: instance
connect: unix:/tmp/.X11-unix/X0
gid: "1000"
listen: unix:/mnt/X0
mode: "0700"
security.gid: "1000"
security.uid: "1000"
type: proxy
uid: "1000"
Now let’s go through it from top to bottom:
Installing applications
On my particular laptop (ThinkPad X220), which is a little older, I found that I needed the xserver-xorg-video-intel
package for Stardew Valley to work properly with the integrated graphics of the CPU. I recommend you do your own research on which driver(s) you need exactly, for most cases the package xserver-xorg-core
should be chosen for X11 support. You may even get away with only installing mesa-utils
, which also installs a bunch of drivers without an X11 server - in my case this was enough for Firefox to work, but as mentioned, not enough for Stardew Valley.
I also install pulse-utils
, which installs the PulseAudio client library and some tools.
Running commands
Here I just disable the X11 server, which runs as the x11-common
service on Devuan. If you only installed mesa-utils
, there is no need to do this.
For Wayland, I create the directory /home/.run
for the socket and set the correct owner and permissions. Then I create a symbolic link from the socket in /mnt
to /home/.run
.
Writing files
First, let me explain the environment variables:
DISPLAY=:0
- Needed for X11 support, adapt this to the number of the socket on your host in/etc/.X11-unix/
. For me, this isX0
.PULSE_SERVER=/mnt/pulse.sock
- This lets PulseAudio client applications know where to look for the server socket.WAYLAND_DISPLAY=wayland-1
- The name of your wayland socket, as found in your$XDG_RUNTIME_DIR
directory.XDG_RUNTIME_DIR=/home/.run
- This lets applications know where to look for some sockets, in our case just the Wayland socket.XDG_SESSION_TYPE=wayland
- Set towayland
for Wayland orx11
for X11, lets applications know what to use.
Then there is a little bit of scripting to make sure that the /tmp/.X11-unix
directory exists, and finally we create a symbolic link to the X11 socket inside of that directory.
Giving access to sockets and the GPU
This is done in the devices
section. First we give access to the host GPU.
Next we use proxy
devices to mount the sockets for PulseAudio, Wayland and X11 in the container. Here we just make sure to set the correct UID/GID for the user in the container and otherwise choose the correct paths on the host.
On my host system, $XDG_RUNTIME_DIR
is /tmp/1000-runtime-dir
so you need to change this part to the correct value on your system for the PulseAudio and Wayland sockets. The X11 socket should always be located at /tmp/.X11-unix
.
Ubuntu GUI profile
This one is a bit simpler, I only tested it with Firefox and that worked fine without installing an X11 server. Accordingly, we also don’t need to disable the X11 service. If this does not work for you, you might also need the X11 packages mentioned above, just remember to also disable the X11 service with systemd if you do install an X11 server.
We also do not need to create or set XDG_RUNTIME_DIR
or create /tmp/.X11-unix
because systemd already seems to do this, so we just create the symbolic links in /etc/profile
.
config:
cloud-init.vendor-data: |
#cloud-config
package_update: true
package_upgrade: true
package_reboot_if_required: true
packages:
- pulseaudio-utils
- mesa-utils
write_files:
- path: /etc/profile
append: true
content: |
export DISPLAY=:0
export PULSE_SERVER=/mnt/pulse.sock
export WAYLAND_DISPLAY=wayland-1
export XDG_SESSION_TYPE=wayland
ln -fs /mnt/X0 /tmp/.X11-unix/X0
ln -fs /mnt/wayland-0 /run/user/1000/wayland-0
description: Sets up GPU, Wayland, X11 and PulseAudio
devices:
intel-igpu:
gid: "1000"
type: gpu
uid: "1000"
pulse:
bind: instance
connect: unix:/tmp/1000-runtime-dir/pulse/native
gid: "1000"
listen: unix:/mnt/pulse.sock
mode: "0700"
security.gid: "1000"
security.uid: "1000"
type: proxy
uid: "1000"
wayland:
bind: instance
connect: unix:/tmp/1000-runtime-dir/wayland-1
gid: "1000"
listen: unix:/mnt/wayland-1
mode: "0700"
security.gid: "1000"
security.uid: "1000"
type: proxy
uid: "1000"
x11:
bind: instance
connect: unix:/tmp/.X11-unix/X0
gid: "1000"
listen: unix:/mnt/X0
mode: "0700"
security.gid: "1000"
security.uid: "1000"
type: proxy
uid: "1000"
Creating the profiles
Simply run the following command to create the profiles, assuming you stored the above configurations in files with the below names:
incus profile create devuan-gui < devuan-gui.yaml
# or
incus profile create debian-gui < debian-gui.yaml
Creating the container
To create and start a container using the default profile and our custom profile, run:
incus launch images:devuan/chimaera/cloud stardewvalley -p default -p devuan-gui
# or
incus launch images:debian/22.04/cloud my-debian-container -p default -p debian-gui
(Side note: I use Devuan Chimaera here instead of the current stable Daedalus because Stardew Valley needs an older version of OpenSSL that is no longer present in Daedalus. You can of course use newer versions, though I have not tested these!)
Here you also have the option of passing some container-specific configuration, for example like this snippet I use to create mount my home directory inside the container:
incus launch images:devuan/chimaera/cloud stardewvalley -p default -p devuan-gui <<EOF
devices:
homedir:
path: /home/debian
shift: "true"
source: /home/unicorn
type: disk
EOF
Keep in mind you need to change the path of source
and path
to match the host path and the desired mount path in the container. I use shift: true
because in an unprivileged container, the UID 100 is not actually the same UID as 1000 on the host system. The shift
option takes care of shifting the UIDs so that they match.
Running things in the container
To make sure all our stuff in /etc/profile
actually works, I recommend running every command with su -l
, which runs the shell as a login shell.
To simply get a root shell, I would use:
incus exec devuan-gui -- su -l
To get the shell of the debian
user (default on Devuan), I would use:
incus exec devuan-gui -- su -l debian
To directly launch an application, like Stardew Valley in my case, I use:
incus exec devuan-gui -- su -l debian /path/to/stardew_valley
Wrapping up
This is a less complete and formal guide than my usual ones, please feel free to email me at unicorn@regrow.earth if you have questions and I am happy to answer them and add some explanations here!