Desktop Container
The reference LXQt desktop image, how to run it with and without a GPU, its Wayland backend, GPU selection and second display, and the apps panel.
Desktop Container
The Desktop Container is the reference minimal-functionality container developers can base upon, or test Selkies quickly. The bare minimum LXQt desktop (Openbox window manager) is installed together with Firefox and Google Chrome, as well as an embedded TURN server inside the container for quick WebRTC firewall traversal. Chrome's launcher carries --no-sandbox: its sandbox needs either the capabilities of its setuid helper or unprivileged user namespaces, and a container's default seccomp profile grants neither, so the container itself is the isolation boundary (Electron applications installed into the session need the same switch; Firefox's sandbox degrades on its own). The container defaults to an X11 (Xvfb) session; set SELKIES_WAYLAND=true to switch it to the headless Wayland backend instead.
It is built on the Base Container (ghcr.io/selkies-project/selkies/base:main-${DISTRIB_FLAVOR}), which is the same session with no desktop environment in it: the X11 display server and the headless Wayland backend with its nested labwc, PipeWire audio, the CUPS scheduler Selkies runs its printer on, the GPU runtime, s6, the embedded coTURN, and Selkies itself. Build a desktop of your own on that image the way addons/desktop/Dockerfile builds LXQt on it, and everything below applies unchanged. Under the Wayland backend the LXQt session runs natively on the nested compositor, anchoring its panel and desktop through layer-shell and controlling its windows through wlr-foreign-toplevel. The desktop is also listed as a session file for each backend, LXQt's own xsessions/lxqt.desktop and this image's wayland-sessions/lxqt.desktop (LXQt on labwc), which is what selkies-session (Jupyter, Coder, and Open OnDemand) starts in this image.
The same LXQt desktop runs on either backend. On Wayland the capture compositor Selkies owns serves Wayland clients and manages no windows, so the container nests labwc inside it. When SELKIES_WAYLAND_COMPOSITOR is left unset or empty, the entrypoint first probes ${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY} for a compositor the operator may already have started; the probe connects to the socket, so a stale file from a dead run counts as absent. A live socket is captured directly by exporting it as SELKIES_WAYLAND_HOST_DISPLAY (setting that variable yourself selects host capture the same way). Host capture takes each capability from what that compositor offers: frames through ext-image-copy-capture or wlr-screencopy and input through the virtual keyboard and pointer protocols where they exist, and through xdg-desktop-portal's RemoteDesktop session on the session bus where they do not, which is how a GNOME or KDE session is captured and driven; a portal backend that asks the user's consent (GNOME) does so once, the restore token it returns being kept under $XDG_STATE_HOME/pixelflux/, and its PipeWire frames are imported by the encoder in place. Only when nothing answers does the container nest labwc. That compositor supplies window management, the titlebar controls every window carries, and an XWayland server, so X11-only applications keep working; Selkies detects its socket and aims input, clipboard and display scaling at it. Pointer motion reaches the nested compositor as the capture compositor's own pointer, relative motion included; labwc relays both to its clients, and a nested kwin_wayland, whose backend makes that motion absolute, is given each delta through its org_kde_kwin_fake_input device, so a game under either turns with the mouse. -e SELKIES_WAYLAND_COMPOSITOR=<name> runs another compositor there instead, and -e SELKIES_WAYLAND_COMPOSITOR=none skips the nested compositor entirely: applications then connect to the capture compositor directly, which is leaner for a single Wayland-native application but leaves no window management, no XWayland, and no desktop session. An X11 desktop can also run in a rootful Xwayland started against the capture compositor (WAYLAND_DISPLAY=<capture socket> Xwayland -shm -geometry WxH :N, then the X11 session on DISPLAY=:N): Selkies follows that display for the clipboard (rootful Xwayland bridges no selection by itself, so the X11 XFixes monitor runs beside the compositor path), and its cursors arrive through the compositor like any other client's. Commands launched from the apps panel always run in the session the applications use — the nested compositor's socket and its XWayland display, a rootful Xwayland's display, or the capture compositor — with that session's D-Bus address and desktop identity, and an application is launched the way its own desktop entry launches it.
A second display needs no configuration. On Wayland each display streams at the density of the screen it is on, one stream pixel per device pixel, and its page's UI-scaling pick becomes that screen's output scale, so a dense laptop screen and a plain external monitor each show the desktop at their own scale. On X11 the desktop has one DPI, the primary's, so a secondary streams no denser than the primary: on a denser screen it asks for the primary's density and the browser stretches the stream, which keeps the UI one physical size across the screens at the cost of sharpness there; on a less dense screen it keeps its own density and shows the UI larger, since the primary's would ask for a buffer beyond that screen's pixels. On X11 each display is a RandR output of its own wherever the X server offers spare outputs a client can plug in, which the Xvfb these images build does: a second display is plugged in, given its exact mode and position, and unplugged when its page leaves, so window managers and toolkits meet what a monitor cable would show them and none has to be taught about a framebuffer; a layout that both adds a display and moves the primary publishes the move first, because a desktop takes in an added screen at once and a moved one later. The rest of this paragraph's X11 behavior is what a server without such outputs gets. A window manager that reads the monitor set only as it starts tiles a maximized window across the whole framebuffer rather than the per-display regions, so Selkies restarts such a manager (Openbox and xfwm4 are the ones measured to) the first time the session extends, with the command line it was started with plus --replace, so it reads the set the layout published; a manager that follows monitor changes live is left alone, and so is one that never reads the set at all (a stock kwin_x11 builds its screens from RandR CRTCs, of which such a server has one, and spans them whatever is published; given an output per display it needs no help). There each display is published as a RandR logical monitor over the one output a framebuffer server has, and a toolkit realizes a monitor only where that output is listed; a server older than xorg-server 21.1 hands an output to one monitor alone, so on those the desktop lays itself out for the primary display and leaves the rest to the applications placed there. The set is announced on an output property, because RRSetMonitor emits no RandR event and GTK ignores the root's ConfigureNotify, so a display added, removed or moved inside an unchanged framebuffer would otherwise never reach a desktop already running. The toolkit has to identify a monitor by its RandR name rather than by its output for any of this to show: one that still keys on the output folds every display sharing one into a single monitor, whatever the server published (GTK 3.24.31 does, 3.24.41 does not). GTK reads that monitor set and Xinerama reports it; Qt reads it as well, and also takes any monitor listing the primary output for the primary screen, so while several displays share the output none of it is primary and the monitor flag alone names the primary display. On Wayland each display is a screen of the capture compositor's own, and the nested labwc gains and loses its matching screens at runtime: Selkies asks for one over the compositor's control socket ($XDG_RUNTIME_DIR/labwc.sock, which the labwc these images build serves unconditionally) when a second display attaches, and has it destroyed when that display disconnects, so its windows are evacuated to the primary by the compositor's own monitor-unplug path — the X11 behavior. The second-display button follows what the session can actually do: the control socket grows a screen on demand (a nested KWin needs no socket — Selkies grows its screens as zkde_screencast_unstable_v1 virtual outputs, which live as long as the stream that requested them; Selkies proves that rung once per session by growing a token-sized screen and giving it back, because a stock kwin serves the request without registering the output and only the patched kwin the KDE images carry does), and without one — no Wayland protocol creates a screen at runtime — a session keeps the screens it was started with, so Selkies offers a second display only where a spare screen already exists, held at a token size until a display arrives for it (start such a session with WLR_WL_OUTPUTS=2 to pre-provision the second screen). The session lays its windows, panels and maximized applications out per monitor, and Selkies mirrors the capture arrangement into the session's own output management so a display asked for above or to the left of the primary lands there rather than beside it; the session places its screens in its logical space, where a screen at scale 2 is half its pixel size, so the arrangement closes up around a scaled screen rather than leaving the gap its pixel width would. A window dragged off the edge of one display carries on into the next: the capture compositor hands the held drag to the next screen's window at the crossing, and the session places its cursor there at that screen's own scale, whatever scale the drag began on. On X11 the desktop carries one DPI however many displays it has, and the primary's page owns it: a secondary shown at another density would otherwise rescale the whole session every time its window was restored, since the session has only the one Xft resource. That resource is written into the home (an XFCE session keeps it in xfconf instead), so a home that outlives a restart brings the desktop up at the density its last page gave it; the server reads the desktop back, and settles it, before it takes a page, a page at another density moves it, and an explicit --scaling-dpi is applied at startup and pins it against page syncs. On Wayland a DPI is an output scale, so each display's page scales the screen it owns and no other; native Wayland applications render at the scale of the screen they are on, while XWayland applications take one scale for the session. The cursor size and the remote-cursor delivery cap stay the primary's.
Neither applies with SELKIES_WAYLAND_COMPOSITOR=none: applications sit on the capture compositor itself and simply see outputs appear and disappear as Selkies creates them.
A Wayland session asked for on a GPU it cannot reach starts as X11 instead. The compositor needs a working GBM/EGL stack on a DRM render node; where there is none — no /dev/dri in the container, an NVIDIA runtime without the graphics driver capability, a node the container's user may not open, a node with no allocator behind it — it composites in software and hands its clients no dmabuf either, while the same container under Xvfb still reaches the GPU. Because device paths do not answer whether that stack works, the container runs the compositor's own renderer bring-up at startup and switches backend on what it finds. With no GPU present at all both backends render in software, so Wayland stays. The bring-up ships as selkies-gpu-probe, which reports the GPU it found and whether the renderer came up on it — the facts, not the decision, since which backend to start is the deployment's own call and this image's entrypoint is what makes it. docker exec <container> selkies-gpu-probe asks it the same question yourself. A bring-up that dies or hangs on the driver stack counts as a GPU the compositor cannot reach, so a session is never supervised into restarting a crash. Older container-toolkit releases pass the NVIDIA driver without the DRM render node the Wayland backend renders on, so add --device /dev/dri where that is the case, and --group-add "$(stat -c %g /dev/dri/renderD128)" where the host keeps that node to a group the container's user is not in.
-e SELKIES_WAYLAND_X11_FALLBACK=false keeps Wayland regardless. The session then renders in software throughout: a compositor without a GPU shares no dmabuf, so applications pointed at the GPU's driver produce buffers it cannot accept and draw nothing at all.
The dashboards' apps panel is backed by proot-apps: portable per-user applications installed into the home directory rather than the image, so they survive a container replacement and need no root. proot works by tracing every process it starts, so on a host that denies ptrace nothing can be installed or launched — Selkies runs the check at startup and the panel is hidden rather than shown failing. --ui-sidebar-show-apps=false hides it regardless. Which applications are installed is read from the runner and sent to the page, so the same session opened in another browser shows what it already has rather than offering to install it again.
Read the Development section for customizing this container for your own usage.
Start it and connect to https://localhost:8080. The default WebSocket transport carries everything on that one port, so that is the whole command:
The default login is
ubuntu/mypasswd. Change it with-e PASSWD=...(or-e SELKIES_BASIC_AUTH_PASSWORD=), and do that before putting a session anywhere others can reach it.
docker run --name selkies -it -d --rm --shm-size=2g -p 8080:8080 \
ghcr.io/selkies-project/selkies/desktop:main-ubuntu26.04--shm-size matters because the browsers inside the desktop crash on Docker's 64 MB default. The image tag chooses the distribution in the image — ubuntu26.04 or debiantrixie, a free choice unrelated to your host — and main is the newest commit, latest the newest release. -e SELKIES_BASIC_AUTH_USER= and -e SELKIES_BASIC_AUTH_PASSWORD= replace the default login, TLS is on by default with a per-install self-signed certificate the browser warns once about (--https-cert/--https-key point at a real one, -e SELKIES_ENABLE_HTTPS=false serves plain HTTP behind your own TLS terminator), and -e SELKIES_WAYLAND=true runs the same desktop on the headless Wayland backend.
Only the opt-in WebRTC transport needs more than that. It uses the embedded TURN server, whose listening and relay ports have to be published as well:
docker run --name selkies -it -d --rm --shm-size=2g \
-e SELKIES_MODE=webrtc \
-e SELKIES_TURN_PROTOCOL=udp -e SELKIES_TURN_PORT=3478 \
-e TURN_MIN_PORT=65532 -e TURN_MAX_PORT=65535 \
-p 8080:8080 -p 3478:3478 -p 3478:3478/udp \
-p 65532-65535:65532-65535 -p 65532-65535:65532-65535/udp \
ghcr.io/selkies-project/selkies/desktop:main-ubuntu26.04Add --gpus 1 --runtime nvidia to docker run when using NVIDIA GPUs, with the NVIDIA Container Toolkit v1.20.1 or higher on the host, or --device /dev/dri for Intel and AMD; the Wayland backend needs the render node, which both pass in, as below.
Hardware OpenGL is set up automatically for whichever GPU the session renders on, and that GPU is one choice for the whole session: the capture compositor, the encoder and the X server's DRI3 node all take it. -e SELKIES_RENDER_DRI=/dev/dri/renderD### (or the legacy DRINODE) names it outright; otherwise -e SELKIES_AUTO_GPU= picks it, true (the default) taking the first GPU on the machine and false disabling the selection. Anything else is a case-insensitive token matched against the identity the kernel itself reports: a vendor name (nvidia, amd/ati, intel, arm/mali, qualcomm/adreno, broadcom/videocore, apple, imagination/powervr, vmware, virtio), a kernel driver name (amdgpu, i915, xe, nouveau, panfrost, msm, v3d), a devicetree vendor prefix (qcom, rockchip, brcm), or a raw PCI vendor ID (0x10de). On a host with more than one GPU that is what decides which: -e SELKIES_AUTO_GPU=nvidia renders on the discrete card rather than on whichever the kernel enumerated first. What the container can select among is itself a container-level choice, and the simplest one: --gpus '"device=1"' (or -e NVIDIA_VISIBLE_DEVICES=, which also takes a UUID) narrows the NVIDIA devices passed in, and --device /dev/dri/renderD### passes one DRM node rather than all of /dev/dri. A GPU that was never passed in cannot be selected, so provisioning only the one a session should use settles it outright, and the setting picks among whatever is there.
The GL stack follows that GPU rather than the devices lying around. On the NVIDIA stack GLX runs on the driver's own client library, which shares buffers with the display server through DRI3 and presents at the screen's refresh, and EGL on X11 runs on the driver too, through its X11 platform libraries the image carries; Zink on its Vulkan driver, what replaces VirtualGL here, remains Mesa's own route to the GPU. Zink needs no render node of its own, so a container given the driver but no /dev/dri still gets hardware GL, all of it through Zink then. What it does need is the driver's modeset node, /dev/nvidia-modeset, which the toolkit passes in under the display capability: without it no Vulkan swapchain comes up on any surface, EGL is left to software rendering, and the container log says so. Every other vendor renders through Mesa's own driver on the DRI3 render node. The image builds XLibre's Xvfb for that, which carries glamor and DRI3 on every vendor, NVIDIA's own EGL included: the framebuffer server draws on the GPU it was given, and applications present into it without a copy, Zink's among them. The build carries two patches. The screen pixmap lives on the GPU, so a present is a blit there, and the framebuffer is sized by the RandR screen Selkies sets. And the server starts with spare outputs, each with a CRTC of its own, that stay disconnected until Selkies plugs one in through its Connected output property: a display added to a session is then a monitor like any other to the window manager and the toolkits, rather than a region of one output they each have to be taught about. So a hybrid host whose session renders on the integrated GPU keeps Mesa's driver for it even with an NVIDIA card in the machine. -e DISABLE_ZINK=true opts out of Zink; without a GPU, Mesa falls back to software rendering either way. The entrypoint settles both from the one report, which also measures the client paths the way an application would take them — which vendor's EGL brings the GPU up, whether Mesa reaches it on a driver of its own or through Zink, and whether Zink can present into a window there — and docker exec <container> selkies-gpu-probe prints it.
Port 3478 and 65532-65535 (change the ports accordingly) are the ports for the internal TURN server, which is only needed while a client is on the WebRTC transport (started with --mode=webrtc, or switched to from the menu, which the default dual mode allows) to route WebRTC through restrictive networks. With the default WebSocket transport, you only need to expose the single web port (8080). When deploying multiple containers, the TURN ports must be changed (together with the environment variables TURN_MIN_PORT/TURN_MAX_PORT with at least two ports in the range plus the environment variable SELKIES_TURN_PORT) and cannot be used by any other host process or container.
If UDP cannot be used, at the cost of higher latency and lower performance, omit the ports containing /udp and use the environment variable -e SELKIES_TURN_PROTOCOL=tcp.
All these ports must be exposed to the internet if you need WebRTC access over the internet. If you need to use TURN within a local network, add -e SELKIES_TURN_HOST={YOUR_INTERNAL_IP} with {YOUR_INTERNAL_IP} set to the internal hostname or IP of the local network. IPv6 addresses must be enclosed with square brackets such as [::1].
Otherwise, to enable host networking, add --network=host to the Docker® command to work around this requirement if your server is not behind a firewall. Note that running multiple desktop containers in one host under this configuration may be problematic and is not recommended. You must also pass new environment variables such as -e DISPLAY=:22 and -e SELKIES_PORT=8082 into the container, all not overlapping with any other X11 server or container in the same host. Selkies serves everything on this single port; access the container using the specified SELKIES_PORT.
If you are behind a reverse proxy or can only expose one HTTP port and you use WebRTC mode, you will need to use an external STUN/TURN server capable of srflx or relay type ICE connections if you use this in a container WITHOUT host networking.
Follow the instructions from coTURN and WebRTC and Firewall Issues in order to make the container work using an external TURN server (WebRTC mode only).
Base Container
The session with no desktop in it, how it starts, how it is laid out, how to run a checkout inside it, and how to build a desktop of your own on it.
KDE Plasma Desktops
The docker-selkies-egl-desktop and docker-selkies-glx-desktop images, what each draws the desktop on, how they are laid out, and how they are developed and kept in step.