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:

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!