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. Windows follow their display on X11 whichever way it is laid out: RandR leaves every window at its root coordinates when a screen moves, so when the primary moves aside for a display added on its left or above, or back to the origin once that display leaves, Selkies asks the window manager to move the primary's windows along with it, and a departed display's windows onto the primary, each kept inside its screen. A desktop's own background window is looked after too: told of the screens one at a time, pcmanfm-qt moves its one window to the primary's new place and then grows it over the union, and Openbox keeps the top or left edge of a window growing that way on the monitor most of it is on, so a display added above or left of the primary would leave that window at the primary's offset, blank on the new display and showing it the primary's share of the wallpaper; a desktop window reaching past the screen is asked back to the origin. 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; the button is offered where the session serves that protocol, read off the registry so nothing on the desktop comes or goes for the asking, and a kwin whose nested backend registers no virtual output, as those before the patched kwin the KDE images carry did, refuses the display the first time one asks for a screen), 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.
proot-apps can install from a local repository instead of its registry: a folder proot-apps localrepo fills with application tarballs, mounted into the session read-only and named by PA_REPO_FOLDER (its administrator guide describes it). Selkies reads the same variable, or --apps-repo-folder / SELKIES_APPS_REPO_FOLDER, and passes the folder to the runner, so the panel and the runner never read two repositories. On such a session the panel lists that folder's catalog rather than the remote one: the metadata/ directory the folder carries (metadata.yml and the icons under img/), served by the server under /api/apps/ behind the session's login, or, where the folder carries none, the applications it holds by name. Set the variable on the container (-e PA_REPO_FOLDER=/mnt/apps -v /mnt/apps:/mnt/apps:ro) rather than the flag alone, so proot-apps run from a terminal in the session reads the folder too.
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 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 six 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. 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. And a DRI3 client can synchronize its presents explicitly, through DRI3 1.4 timeline syncobjs, where the kernel and the GPU's EGL support them: the NVIDIA driver attaches no implicit fence to a buffer it shares, so its Vulkan and GLX presents order themselves against the server's copy that way. And the vblank an application waits on, which a framebuffer server fakes, runs at the rate the session is captured at: pixelflux publishes the fastest capture's rate on the root window (_FAKE_SCREEN_FPS) and removes it when capturing ends, the rate the server started at (60 unless -fakescreenfps says otherwise) stays the floor, and the vblanks keep time to the microsecond, so a vsynced game or compositor presents at 144 fps on a 144 fps stream, one frame per streamed frame, and no faster than the stream shows. And an application asking for that rate is told it: the server answers XFree86-VidModeExtension with the screen's size at the vblank's rate, exact to the millihertz, which is what Mesa's glXGetMscRateOML reads, so Chromium on ANGLE's GL backend no longer logs a failed eglGetMscRateANGLE and a missing extension twice a second. And the capture's blit is waited for on the GPU: the server signals a semaphore pixelflux shares with it (SELKIES-SEMAPHORE, where glamor's context has GL_EXT_semaphore_fd) after each blit, and the NVENC encode is queued behind a wait on it at once, where a one-pixel GetImage would have the CPU learn of the blit first. Under a GPU-bound application each job queued only after the CPU learnt of the one before waits a whole draw of that application, so a game drawing uncapped at 72 fps streamed at 35 fps through the GetImage and streams at 60 through the semaphore, at under half the latency, wherever the game started after the stream did (the GPU serves its contexts in the order they were made, and a game already running when a capture starts keeps the two draws a frame). 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.
On NVIDIA, GL applications run without vsync unless they ask for it: the image sets __GL_SYNC_TO_VBLANK=0, which GLX takes as a default that an application's own swap interval overrides and NVIDIA's EGL applies over the application's choice. On this server vsync costs an application about two frames of input latency, the frames its driver queues behind the fake vblank, and does not reliably pace one that renders as fast as the GPU allows, which then starves the capture: from a key press to the encoded frame on an RTX 3090, a light GLX client took 13 ms without vsync and 43 ms with it, and one drawing 14 ms frames 230 to 350 ms either way, the stream showing its frames that late. Mesa's applications keep Mesa's default, vsync, which costs as much or more (44 against 76 ms through Zink, 27 against 70 ms on a Radeon Pro VII) but does pace a GPU-bound application: without it such a stream fell from 40 to 18 fps on the Radeon. Vulkan applications choose their own present mode, and the fake vblank paces FIFO. A game that renders as fast as the GPU allows is paced without the vsync queue by a frame limiter at the stream's frame rate, such as MangoHud's, which serves OpenGL and Vulkan alike: sudo apt-get update && sudo apt-get install --no-install-recommends mangohud in the session (mangohud:i386 as well for 32-bit games), then start the game with MANGOHUD_CONFIG=fps_limit=60,fps_limit_method=late,no_display mangohud <command>, the frame rate the page streams at in place of 60 (xprop -root _FAKE_SCREEN_FPS reads it while a page streams, and _FAKE_SCREEN_MILLIHZ exactly, in millihertz). On the RTX 3090 that brought the client drawing 14 ms frames to 22 ms at a steady 60 fps, and an EGL one that streamed at 36 fps to 60, for 4 to 8 ms more than no vsync at all on a light client.
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).