A Guide to Running GUI Applications in a Docker Container
Containers are not usually associated with GUI applications, but there may be times when one might still want to run such a program inside a container, for example to isolate the application’s dependencies. Installing a GUI application in a container means that not only the application, but also all its specific dependencies are encapsulated inside the container (respectively, the container image), and can therefore reliably be removed from the system in a single step.
The primary challenge is to let a container communicate with the host’s display system, so that it can create GUI windows on the host. A GUI application will likely also need to share files with the host system, which in turn requires the appropriate user permissions.
In this example, I will use the pinta paint program, which requires the Mono runtime. I do not use any other programs that depend on Mono, and as I like to keep my system installation relatively clean, I would like to isolate the application and its libraries as much as possible.
Getting a GUI application to run in a container requires several distinct steps:
- Installing the application and its dependencies in a container.
- Letting the application and the host’s window system talk to each other.
- Creating an image of the installed application for later use.
- (Optional:) Letting the application and the host share files.
This assumes that Docker (or an equivalent container management system) is already installed and running on the host, and that the user has the necessary permissions to use it.
To distinguish which commands are issued on the host, and which are issued in an interactive container session, the respective prompts are shown in the examples below!
Also, this example assumes a Linux environment throughout.
Installing the Application
The first step is to obtain a suitable base image. Here, we will use Ubuntu:
host> docker image pull ubuntu:jammy
Next, we log into the container and install the desired application.
We could simply run the container using docker container run --rm -it ubuntu
(-it
means to run an interactive session and to attach a terminal,
--rm
will shut down and remove the container once the interactive
session ends), but instead we will use the following command, so that
the container can access the graphical display later. (This will be
explained in the next section.)
host> docker container run --rm --net host -v /tmp/.X11-unix:/tmp/.X11-unix -it ubuntu
Now we can install the desired application:
container# apt update
container# apt install -y pinta
The installation process will ask for the current timezone; enter the appropriate information.
Sharing the Screen
Attempting to run pinta from within the container at this point will most likely fail. For an application to launch a window requires three things:
- The application (the “client”) must know how to talk to the windowing system (the “server” or “display server”).
- The application must have appropriate permission to access the server.
- The application must tell the server where the window should appear.
On Unix, running X11, local client applications usually communicate with
the display system via a Unix domain socket. A domain socket is visible
in the fileystem; and the one used for X11 communication is found
in the directory /tmp/.X11-unix/
.
The -v
option provided when starting the container created a “bind mount”:
a directory on the host system has been mapped to a directory of the
filesystem inside the container. (The path before the colon refers
to the host filesystem; the path after the colon refers to the
container filesystem. Here the respective paths are equal, later
we will see an example where this is not the case.) For the client
inside the container to be able to communicate with the display server
on the host also requires to use the “host” networking mode, as specified
by --net host
.
An application not only needs to be able to communicate with the display server, it also needs to authenticate itself. There are several different ways for X11 applications to authenticate themselves to the server; for now, we will use one of the simplest. On the host, issue the command:
host> xhost +local:
This allows any local application to access the display server. (If
you are paranoid, you may want to switch this behavior off by issuing
the command xhost -local:
when you have shut the GUI application and
container down.)
Finally, we need to tell the application which display to use. This is
done via the DISPLAY
environment variable inside the container:
container# export DISPLAY=:0
Now, you should be able to execute the command:
container# pinta
and have the window appear on your screen.
Creating an Image
We can create and save an image of the currently running container. This way, we will be able to run the application without having to to go through the entire installation process again in the future.
On the host, run docker container ls
to find the ID of the running
container, and then (still on the host), create an image of the container
by feeding its ID to docker’s commit
command (substitute the real ID,
of course):
host> docker commit <ID> pinta:pinta
This will create a new image, named pinta:pinta
, as you can verify
by running docker image ls
(on the host). It is not necessary to
stop the container before creating an image!
It is now safe to shut down the application and exit the container.
Because the container was started with the --rm
option, it will
be removed once the interactive session ends. You may also want to
turn off access to the display server by issuing xhost -local:
on
the host at this point.
Sharing a Directory
We may generally want to share files between the host system and the containerized application. Using pinta, as an example, we may want to read a graphics file from the user’s (host) home directory, modify it, and write it back.
Docker’s bind mounts provide a convenient method to accomplish this. In a bind mount, a directory (or even a single file) in the host’s filesystem is mapped to an entity in the container’s filesystem. In contrast with docker volumes, bind mounts are not managed by Docker, and their backing store exists solely in the host’s filesystem.
In the following, let’s assume that there is a directory called pinta
in the user’s home directory on the host. Then we can run a containerized
version of pinta using the following command line, using the new image
that was created in the previous step (remember to run xhost +local:
first):
host> docker container run --rm \
--net host \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v /home/janert/pinta:/pinta \
-e DISPLAY \
pinta:pinta pinta
Compared to the previous run
command, this one includes two bind
mounts: one is for the Unix domain socket required by X11. The other
one maps the directory /home/janert/pinta
on the host system to the
directory /pinta
in the container. Any file placed into the directory
on the host will be visible to the pinta process inside the container,
and pinta can write its results back to that location as well.
Also new is the -e
option that sets the DISPLAY
environment variable
inside the container to the value it has outside the container. (You can
also supply a value explicitly: -e DISPLAY=:0
.)
The command runs the newly created image pinta:pinta
, and runs the
pinta
command inside of the container once the container starts. No
interactive session will be started. When pinta is ended, the container
will be removed.
Display Server Permissions
Earlier, we used the command xhost +local:
to allow any
local application to contact the display server. As this amounts to a
disabling of access controls, this is generally frowned upon, although
it seems acceptable for a single-user machine. But for a machine that is
shared among users, a more fine-grained authentication scheme is required,
which enables access for an individual user. This is provided through
the X11 “authority file” facility.
When starting a graphical user session, the display manager creates a
file named .Xauthority
in the user’s home directory. This file
contains a security token; run xauth list
on the host to display
the contents of this file. An application presenting this token to
the display server will be allowed access.
For this scheme to work, the application inside the container must have access to the token, so that it can present the token to the display server when trying to create a window. Here is an ad-hoc method for adding this token to an interactive container session:
-
Install
xauth
in the container:apt install -y xauth
-
Inside the container, run
xauth add
, followed by the entire line displayed by thexauth list
command from earlier. The entire command may look something like this:container# xauth add box/unix:0 MIT-MAGIC-COOKIE-1 dbc4ba56e43ea134b3f7a7befd232bdb
A more elegant way that also lends itself to automation, uses a bind mount
to make the .Xauthority
file visible inside the container. To do so, run
the container with the following command line:
host> docker run --rm --net host -v /tmp/.X11-unix/:/tmp/.X11-unix/ -v /home/janert/.Xauthority:/dot.Xauthority -it ubuntu
Then, install xauth
in the container as before and run:
container# xauth merge /dot.xauthority
Now an application inside the container should be permitted to create
a window on the host. (Don’t forget to set the DISPLAY
variable inside
the container.)
Finally, it is not a good idea to store the security token inside a container image, because the token will change every time a new graphical user session (on the host) is started. The running container should read the token every time it starts. Using a bind mount as shown makes sure that the container always sees the currently valid token.
Changing the User
Containers, by default, run as root, and so do the processes inside them. It is generally not a good idea to run processes with unnecessary privileges, but in the present case, there is an additional consideration: through the “bind mount”, the containerized processes have access to the host’s disk: one more reason to restrict what they can do. This also has a very practical side: any files created by pinta and written to the host directory will be owned by root, not by me (the user). This is clearly not a good situation.
Using Docker,
the effective user for the container can be changed using the -u
option.
This option takes the desired user ID (uid) and group ID (gid) as a
colon-separated pair. For instance, to start the process for the user
with uid and gid both equal to 1000, we would say:
host> docker run -u 1000:1000 ...
If I want to run the container and its contained process (such as pinta)
as myself, then the uid and gid supplied here must be my own uid and
gid in the host system, as stored in /etc/passwd
and reported by the
id
command (both on the host system).
One side effect of using one’s own user ID for the container is that
this takes care of X11 authentication automatically! Usually, local
clients authenticate themselves to the display server using the “SI”
(or “server interpreted”) protocol. When running a graphical desktop
session, local connections for the current user are automatically
allowed - this is how regular GUI applications run. (Look for the “SI”
entry when running xhost
without any arguments.) By using my own
uid for the containerized process, the process identifies itself to
the display system as a local process of the current user, and hence
has the necessary permissions. In other words, it is now no longer
necessary to run host +local:
to grant access.
Changing the user on the command-line can be brittle. In particular,
there will in general be no entry for that user in /etc/passwd
inside the container. The containerized application may therefore get
confused if it expects to access the user’s home directory (as many
GUI applications do), because it has no way of determining the
location of the home directory for the specified user. (In fact, such
a home directory does not even exist, inside the container.) Of
course, the /etc/passwd
file on the host is inaccessible to the
containerized application!
The Dockerfile
So far, we have constructed containers (and images) interactively,
running a shell inside the container and installing the desired
applications manually, and then persisting the resulting container
to an image via commit
. But that’s not the way images are usually
built. Now that we know what we need, we can capture the entire
process in a Dockerfile
, like the one below:
FROM ubuntu:jammy
RUN addgroup --gid 1000 user && \
adduser --uid 1000 --gid 1000 --home /pinta --disabled-password --gecos "" user
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y pinta
USER 1000:1000
ENV DISPLAY=:0
ENTRYPOINT [ "pinta" ]
Most instructions should be self-explanatory. A user is created inside
the image (or: container), with the desired uid and gid. The directory
that will be used for the bind-mount is given as home directory; this also
means that this directory will, conveniently, be the working directory when
running application. The line ARG DEBIAN_FRONTEND=noninteractive
is a
transient instruction to suppress interactive prompts (such as for the local
timezone) when running apt-get
. Finally, the user and display are set,
and the application is defined that will be executed when the container
is run. An image can now be created by running:
host> docker build . -t pinta:user
and the resulting image can be run using:
host> docker run --rm --net host -v /tmp/.X11-unix/:/tmp/.X11-unix/ -v /home/janert/pinta:/pinta pinta:user
Because the user and the display are already defined in the image, it is not necessary to specify them on the command-line when starting the container. On the other hand, Docker pretty much requires that the networking mode and the bind mounts must be given on the command line, as shown. By defining a shell alias of this entire command line, the container can be run like any other application.
One can debate whether the user and the display should be set in the
image as is done here. In general, I’d recommend that anything
specific to the container should be set in the image, whereas anything
related to the containers execution environment should be specified
only when running it. In the present case, one can argue that the
values of both the user ID and the DISPLAY
variable, as parts of the
execution environment, do not belong into the image. But the image
created here is not intended to be portable, and is only intended to
be run, under specific conditions, by a single user. Hence bundling
these bits of information for convenience, as is done here, seems
permissible.
Further Reading
I have greatly benefited from the James Walker' article How to Run GUI Applications in a Docker Container, in particular in regards to the explanation of the X11 domain socket. I also highly recommend his comprehensive selection of excellent articles on Docker at How-To Geek.