How to run Electron on Linux on Docker on Mac
My co-Recurser Hazem tried running Programmable Matter on Linux, and it didn't work very well (I had previously run it only on my Mac). So the past few days I have been getting it running on Linux on Docker in order to fix it. I had not used Docker before so this was an adventure! I learned a lot.
Docker lets you run virtual computers (I'll call them guests, although this is not official Docker terminology) inside your computer (the host), and guests can run different operating systems from the one the host runs. You can use it to develop and test software on other OSes (or different versions of your host OS); or to bundle up a bunch of software packages so users can install and uninstall them all together; or to run programs in a restricted environment so they can't damage the host.
I have a vague idea how this all works but I still find it extremely magical! Docker seems to be very well put together and everything worked really smoothly for me. Nonetheless I ran into a lot of puzzling stuff and did a whole lot of Googling to get it working. Mostly why this was complicated is that Programmable Matter is based on Electron, which makes some unusual demands of the operating system it runs on.
Here is what I needed to do at a high level:
learn about Docker. I went through the getting started tutorial, it was great.
build an image containing the base Linux installation and any extra libraries needed to run my app. (To run a guest computer you load an image into a container.)
set things up so that the app running on the guest can display a window on my Mac.
figure out some Docker security stuff.
run the app!
Building a Docker image
To build an image, you write a
Dockerfile, which is a script of actions that modify the guest computer's storage; when the script finishes, the storage is snapshotted into a file on the host. Here's the one I wrote (with explanations interpolated):
This line names a base image to start with. Docker has a repository of images of various base operating systems and add-ons. This one is a Debian image with Node.js version 14 installed (see https://hub.docker.com/_/node). There are a bunch of choices here: for example, the sample app in the tutorial uses an image with Node on top of Alpine Linux, which is a lot smaller than Debian. (I picked
node:14 because the smaller ones sounded from the docs like they might not work for me, but I should go back and try it.)
From this point on in the
Dockerfile you can run ordinary shell commands in the context of the guest:
# stuff needed to get Electron to run RUN apt-get update && apt-get install \ git libx11-xcb1 libxcb-dri3-0 libxtst6 libnss3 libatk-bridge2.0-0 libgtk-3-0 libxss1 libasound2 \ -yq --no-install-suggests --no-install-recommends \ && apt-get clean && rm -rf /var/lib/apt/lists/*
The base images are typically pretty stripped down compared to a normal OS installation. In particular,
node:14 is missing a bunch of libraries that Electron depends on for rendering the display. I found these by trying to start the app, getting an error like
searching for the missing library at https://www.debian.org/distrib/packages#search_contents, and adding the matching package. (
Git is needed by
electron-forge, which I'm using to run my app.)
# Electron doesn't like to run as root RUN useradd -d /programmable-matter programmable-matter USER programmable-matter
If you try to run Electron as root you get
so we need to add a user (more about
--no-sandbox below). The
USER command changes to the given user for subsequent commands.
WORKDIR /programmable-matter COPY . . RUN npm install RUN npx electron-rebuild
WORKDIR command sets the working directory for subsequent commands, then
COPY copies a file tree containing the app code from the host filesystem to the guest filesystem. This includes
package.json, so we can run
npm install. We also run
electron-rebuild which builds some native code modules to match the installed Electron version. (
Electron-rebuild is run by
electron-forge on launch if we don't do it here, but that causes a Docker security issue, see below; and also makes startup slower, so I moved it here.)
# Electron needs root for sandboxing # see https://github.com/electron/electron/issues/17972 USER root RUN chown root /programmable-matter/node_modules/electron/dist/chrome-sandbox RUN chmod 4755 /programmable-matter/node_modules/electron/dist/chrome-sandbox
Without this the app errors out with
As far as I understand things: Electron is based on Chromium, which has a fancy multi-process architecture in which some processes are run with restricted permissions ("sandboxed"). Sandboxing can be done with a Linux kernel facility called "user namespaces", but this facility is not enabled on some Linux distributions, so there is a workaround using a setuid helper program. However
npm install can't set the setuid bit without root permission, and the Electron package chooses not to ask for it, so we need to do it explicitly. (I'm not sure whether user namespaces can be enabled on the Debian base image I started with, but I think it may use an older kernel version and this is a fairly recent feature.)
USER programmable-matter CMD npm run start
Now we are ready to build the image with
docker build -t programmable-matter .
and run it with
docker run programmable-matter
But we get an error:
XQuartz and DISPLAY
I like long specific error messages like this, they are easy to Google. I found this, which suggests either passing the
--no-sandbox argument or setting up a custom
seccomp configuration. I was feeling tired and not very interested in setting up a custom configuration for something I had never heard of, so I tried
--no-sandbox, which got me past the namespace error and on to a new one:
Not very helpful—I was expecting this to be hard to Google, but this Electron issue was the second hit. Turns out I needed to set the
DISPLAY environment variable so Electron knows where to render its windows, using X, the standard windowing system on Linux.
My Mac is running an implementation of the X display server, XQuartz, which can receive connections from X programs and render their windows. When you're running an X client and server on the same computer they normally connect over a Unix socket for performance (and also for security). I tried to mount the X Unix socket from the host to the guest, following this post, but I got several variations of
It turns out that mounting arbitrary Unix sockets into a container is not supported on Docker for Mac. Instead (following these instructions) I set an XQuartz preference to allow connections from network clients, ran
xhost +localhost to allow connections from
localhost, and ran
docker like so:
docker run -e DISPLAY=host.docker.internal:0 programmable-matter
It works! The guest OS can reach network ports on the host at
-e DISPLAY= passes the environment variable to the
CMD in the
Dockerfile. I also needed to run
xhost +localhost so XQuartz will accept the connection. This is not secure for general use (XQuartz trusts the IP address of the incoming connection), but it seems OK for local use.
Docker and seccomp
After a rest on the fainting couch, I decided to try to set up
seccomp in order to get rid of the
--no-sandbox argument. I am not sure how much practical value this has, since the whole app is running in a container, but maybe it prevents dangerous access between app processes in the container. In any case
--no-sandbox seems like an escape-hatch flags that should be avoided. So following these instructions I tried running the container like so:
docker run -e DISPLAY=host.docker.internal:0 --security-opt seccomp=chrome.json programmable-matter
chrome.json is from Jessie Frazelle's dotfiles (see also how it was made). As far as I understand things,
seccomp configures a list of Linux system calls that programs running on the guest are allowed to call, and
chrome.json includes the namespace calls Chromium uses for sandboxing. But:
The file in question is part of a native library I'm using, NSFW, and the error comes up while
electron-forge is running
electron-rebuild. I thought that
copyfile was the name of a system call so I tried adding it to
chrome.json, but that didn't work. I did find
copy_file_range, however, and adding that one worked! I spent some time digging to find where the string
copyfile comes from (
node-gyp which I think generates and runs a
Makefile) but gave up.
Finally I realized that
electron-rebuild can be run at image build time instead of container run time; now the original
chrome.json works. So I guess there is no
seccomp restriction at image build time.
Truly, friends, we live in a time of wonders! Please email me with comments, criticisms, corrections.