Running a GUI Application in a Docker Container: Users, Permissions, Dockerfile

In a previous post, I explored the possibilities of running a GUI application inside a Docker container. In the current post, I will continue where we left off before, adding some details to make the process more convenient.

Display Server Permissions

Previously, 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:

  1. Install xauth in the container:

    apt install -y xauth
  2. Inside the container, run xauth add, followed by the entire line displayed by the xauth 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, which 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 ensures 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 either run xhost +local:, or to deal with the xauth command and the .Xauthority file, 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
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.