component.md
September 3, 2026 · View on GitHub
Component Structure
Selkies is composed of a small number of core components plus several optional addons.
Refer to Getting Started on how you can get on board.
Retrieve the latest SELKIES_VERSION release, and pick the DISTRIB_FLAVOR of the
container images below (ubuntu26.04 or debiantrixie). The flavor names the distribution
inside the image, so it is a free choice and not a property of the host:
export SELKIES_VERSION="$(curl -fsSL "https://api.github.com/repos/selkies-project/selkies/releases/latest" | jq -r '.tag_name' | sed 's/^v//')"
export DISTRIB_FLAVOR="ubuntu26.04"
When instructed to install binfmt, use the following command with Docker®/Podman:
docker run --rm --privileged tonistiigi/binfmt:latest --install all
Core Components
At runtime, Selkies is a single Python application — the selkies wheel. The HTML5 web client is bundled into it, and screen/audio capture and encoding are provided by the pixelflux and pcmflux extensions, which are installed automatically as dependencies of the wheel.
Every release carries the same build in each medium below. The Releases page holds the architecture-independent wheel, a .deb for Ubuntu 24.04 and 26.04 and for Debian bookworm and trixie, an .rpm for Fedora and Enterprise Linux 9, an Alpine .apk, an Arch .pkg.tar.zst, a self-contained AppImage, and the noarch conda package the AppImage environment is built from (pixelflux, pcmflux, pulsectl-asyncio, and aitop have no conda-forge builds, so a conda install of it still needs those from pip). Each of those is built for both x86_64 and aarch64, except the Arch package, which Arch Linux publishes for x86_64 alone. The container images below are published to ghcr.io instead: the base and desktop images as v${SELKIES_VERSION}-${DISTRIB_FLAVOR}, the coTURN and TURN-REST addons as v${SELKIES_VERSION}, each beside its floating latest tag.
A pre-release ships the same media under a tag such as 2.0.0rc0 and is marked as a pre-release: the floating latest image tags stay on the last full release by default, the releases/latest API keeps pointing at it, and pip resolves the pre-release only when asked with --pre or an exact version.
For the most recent unreleased commit, download the Build Artifacts of the CI, Images, or Packages workflows for that commit from the GitHub Actions Workflow Runs. Build Artifacts can also be downloaded using the GitHub CLI command gh run download.
Python Application
The term host or server refers to the Python components across this documentation.
The Python components are responsible for the host server backend: capturing and encoding the host screen (via pixelflux) and audio (via pcmflux), injecting keyboard/mouse/gamepad input into the X11 display (via a vendored python-xlib using XTEST/XFixes), receiving input signals and communicating other data (including the clipboard) between the client and the host, serving the HTML5 web client, and — only in WebRTC mode — establishing the WebRTC connection to the client. Everything is served by a single aiohttp server on a single port (default 8080).
In the default WebSocket mode, encoded screen frames, audio, input, and other data are multiplexed over WebSocket connections to a WebCodecs-based web client. In the opt-in WebRTC mode (--mode=webrtc), host screen video and audio are transported using the WebRTC MediaStream interface (through a vendored fork of aiortc under src/selkies/webrtc/), and other data are transported using the WebRTC DataChannel interface.
Every distribution medium carries this one application: the .deb, .rpm, .apk and .pkg.tar.zst packages install it into a private environment at /opt/selkies, the AppImage runs it out of a conda-forge prefix of its own, and the container images have it already. Each of them puts the selkies, selkies-resize, and selkies-gpu-probe commands on PATH. Getting Started has the container command and Native Install the rest.
For the most recent unreleased commit, take the same media from the CI workflow run of that commit in the GitHub Actions Workflow Runs — Build Artifacts download from the page, or through the GitHub CLI command gh run download — and install them exactly as the released ones. The container images need no download at all: every push republishes them as ghcr.io/selkies-project/selkies/desktop:main-ubuntu26.04 and the other flavors.
Web Client
The term client refers to the web components across this documentation.
The web client is a WebCodecs-based HTML5 application (with the core selkies-core.js, the WebSocket transport core selkies-ws-core.js, the WebRTC transport core selkies-wr-core.js, and the input library lib/input.js). It is responsible for the web browser interface that you see when you use Selkies.
It decodes the incoming H.264 stream using the browser WebCodecs API with a low-latency zero-copy rendering path (a browser without WebCodecs is served the striped JPEG stream instead, painted through createImageBitmap), plays Opus audio, and detects keyboard, mouse, gamepad, and clipboard input from the user, then sends them to the host server backend. It also handles remote cursors with the Pointer Lock API so that you can correctly control interactive applications and games.
The web client source lives at addons/selkies-web-core and is built and bundled into the Python wheel automatically (installed at src/selkies/selkies_web), so there is no separate web package to download or install. A source checkout builds that bundle with scripts/ci/build-web.sh (requires npm), the one script the wheel build, the conda recipe, the root Dockerfile, and the devcontainer all run, so every channel ships the same files. To serve your own copy of the web files, point --web-root= (or the SELKIES_WEB_ROOT environment variable) at a built web directory containing an index.html. Rebranding (name, icons, manifest) is done at build time in the addons/selkies-web-core source tree, not by editing the shipped artifacts.
What the shipped interface shows is a server setting rather than a build: --ui-title and --ui-show-logo name and brand the sidebar header, --ui-show-sidebar and --ui-show-core-buttons drop the sidebar or its device toggles entirely, and one --ui-sidebar-show-<section> flag per section — video, screen and audio settings, stats, shortcuts, clipboard, files, apps, sharing, gamepads, webcam, fullscreen, gaming mode, trackpad, keyboard button, soft buttons — hides just that one. These govern the page only: the capability behind a hidden control keeps working, so the feature's own setting (--webcam-enabled, --file-transfers, --enable-sharing) is what actually turns it off.
Media Capture and Encoding (pixelflux and pcmflux)
Screen capture and video encoding are performed by pixelflux, a Rust (PyO3) extension. It encodes H.264 with hardware NVENC (NVIDIA) or VA-API (Intel/AMD) when a supported GPU is available, and otherwise falls back to software H.264 — x264, or the BSD-licensed OpenH264 in a pixelflux built without GPL components — or encodes Motion JPEG. H.265 and AV1 in the capture path are planned but not yet implemented.
Audio capture and encoding are performed by pcmflux, a companion Rust (PyO3) extension that captures from PulseAudio (or PipeWire-Pulse) and encodes to Opus.
Both are pulled in automatically as dependencies of the selkies wheel, so you normally do not install them separately. Their Rust references are published at https://pixelflux.selkies.io and https://pcmflux.selkies.io.
Licensing note (GPL toggle): the software H.264 encoder of pixelflux is chosen when pixelflux is built, never by a Selkies setting: the default build uses GPL-2.0+ libx264 (with an install-time notice), and a build made with PIXELFLUX_ENABLE_GPL=0 excludes every GPL-licensed component and uses the BSD-licensed OpenH264 instead, behind the same h264enc / h264enc-striped encoders. Selkies reads pixelflux.SOFTWARE_H264_ENCODER to name the encoder in its logs and to default a session known to run on OpenH264 to CBR rate control (OpenH264 targets a bandwidth rather than a quality level); OpenH264 encodes 4:2:0 only, so --video-fullcolor has no effect on its software path. Licensing lists every third-party component of an installation with its license and where the GPL pieces come from.
Optional Components
These components are not required for the base Selkies runtime, but may be needed for specific deployments or preferences. These sections are nonetheless recommended to be read carefully.
Joystick Interposer
The Joystick Interposer is a special library that allows the usage of joysticks or gamepads inside unprivileged containers (most of the occasions with shared Kubernetes clusters or HPC clusters), where host kernel devices required for creating a joystick interface are not available. It uses an LD_PRELOAD hack to intercept application calls that open a Linux joystick/gamepad device and pass data through a unix domain socket, translating gamepad events from Selkies into joystick/gamepad events without requiring access to /dev/input/js0 or kernel modules such as uinput (much like how VirtualGL intercepts OpenGL commands).
Note: the
LD_PRELOADused here (and in fake-udev) is a deliberate, legitimate interposition technique for redirecting device access in unprivileged environments. It is unrelated to — and distinct from — the process-globalLD_PRELOADanti-pattern thatpixelflux's multi-GPU NVENC support specifically avoids when selecting a GPU for hardware encoding.
On this backend Selkies delivers gamepad input over the sockets alone, so an application sees a controller only when it is started with the interposer preloaded. It is meant for containers: on a host where the kernel is reachable, Kernel Gamepads covers the same ground with no preloading and no shadowed system libraries. The interposer is built from source and wired automatically in the Desktop Container and the desktop containers, every native Selkies package ships it under /usr/$LIB for images built on those, and the AppImage carries it at usr/lib/selkies_joystick_interposer.so (its AppRun exports the path as SELKIES_INTERPOSER rather than preloading it, since Selkies itself must keep seeing the real device nodes); elsewhere, build and install it (and fake-udev) from the source in this repository:
git clone https://github.com/selkies-project/selkies.git && cd selkies
apt-get update && apt-get install --no-install-recommends -y build-essential
make -C addons/js-interposer && PREFIX=/usr make -C addons/js-interposer install
cd addons/fake-udev && make && cp libudev.so.1.0.0-fake libudev.so.1 libudev.so /usr/lib/$(gcc -print-multiarch)/
The /dev/input directory has to exist for the Joystick Interposer to augment it:
mkdir -pm1777 /dev/input
Each of the four gamepad slots is interposed as both a joydev node (js0-js3) and an evdev node (event1000-event1003). Opening either path by name is intercepted whether or not the file exists, and an application that scans /dev/input sees the evdev node of every bound slot added to the listing and, if it watches the directory with inotify, sees a slot bound or withdrawn later as that node appearing or vanishing, so no placeholder files are needed either way.
The following environment variables are required to be set in the environment each application is being run in to receive the joystick/gamepad input.
export SELKIES_INTERPOSER='/usr/$LIB/selkies_joystick_interposer.so'
export LD_PRELOAD="${SELKIES_INTERPOSER}${LD_PRELOAD:+:${LD_PRELOAD}}"
You can replace /usr/$LIB/selkies_joystick_interposer.so with any non-root path of your choice for the interposer library.
SDL2 applications discover the four pads through fake-udev. Where discovery through libudev is unavailable — SDL_JOYSTICK_DISABLE_UDEV=1, an SDL sandbox build, or an SDL built without udev — name the evdev nodes instead, which needs no placeholder files. Never name the joydev nodes: with fake-udev active, a /dev/input/js0 hint is a second, different node for the slot SDL already enumerated as event1000, so the pad shows up twice.
export SDL_JOYSTICK_DEVICE=/dev/input/event1000:/dev/input/event1001:/dev/input/event1002:/dev/input/event1003
Check the Joystick Interposer README.md documentation for usage instruction and compiling information on other platforms.
Check the following links for explanations of similar, but different attempts, for reference:
https://github.com/Steam-Headless/dumb-udev
https://github.com/games-on-whales/inputtino
https://github.com/games-on-whales/inputtino/tree/stable/src/uhid
https://games-on-whales.github.io/wolf/stable/dev/fake-udev.html
https://github.com/games-on-whales/wolf/tree/stable/src/fake-udev
fake-udev
The fake-udev addon provides a fake libudev shared library (libudev.so.1) designed to be used with LD_PRELOAD. It intercepts libudev calls and simulates the presence of a fixed set of virtual gamepads, so that applications which discover input devices through libudev (for example, via udev_enumerate_scan_devices) find the Selkies virtual gamepads; a pad is listed only while the interposer serves it, and one served later arrives as a hotplug add, so a scanner never opens a node that would only time out. A running udev daemon is no substitute on this backend: the pads exist only as interposer sockets, so a real libudev query never reports them (the kernel devices are the case where it does). fake-udev covers discovery and the Joystick Interposer covers the device itself — applications that enumerate through libudev need both, and, like the interposer, it uses LD_PRELOAD by design.
Kernel Gamepads
Where /dev/uinput is available — a desktop host rather than an unprivileged container — Selkies registers each gamepad slot as a real kernel device instead. Applications then enumerate it through the kernel like any USB controller, so neither the Joystick Interposer nor fake-udev is involved and nothing has to be preloaded. This is what lets Steam, Proton, and browsers running inside the remote desktop find the controller.
SELKIES_UINPUT_GAMEPAD (--uinput-gamepad) selects the behavior:
| Value | Behavior |
|---|---|
auto (default) | Kernel devices when /dev/uinput is writable and the interposer is not configured for the session (SELKIES_INTERPOSER or LD_PRELOAD); the interposer sockets otherwise. |
true | Always register kernel devices. |
false | Never register kernel devices. |
The kernel device is the same Xbox pad the interposer presents, with the same axis ranges, and it is created when a client's controller is associated with the slot, so an idle slot is not a phantom controller. The interposer sockets stay bound either way; avoid running an application against both backends at once, or it will see the pad twice.
This needs the uinput module and write access to /dev/uinput for the account running Selkies, and read access to the created /dev/input/event* node for the applications:
sudo modprobe uinput
sudo usermod -aG input "$(whoami)"
V4L2 Interposer
The V4L2 Interposer is the webcam counterpart of the Joystick Interposer: an LD_PRELOAD library that presents the client's camera to applications as a V4L2 capture device (/dev/video0), with no v4l2loopback kernel module, no /dev/video* node, and no elevated privilege. Unmodified consumers pick it up — Chromium, Firefox, ffmpeg, GStreamer, v4l2-ctl and libv4l2-based applications. Turn the uplink on with --webcam-enabled=true (SELKIES_WEBCAM_ENABLED); it is off by default.
The browser encodes its camera (H.264 or VP8 where the engine can, JPEG otherwise) and Selkies hands each encoded frame to pixelflux's virtual camera, which decodes it, fits it to the device format and publishes it to every sink on its own thread. One camera is shared by every client and lives as long as the server, so an application that opened the device keeps it across transport switches and browser reconnects.
Applications reach the camera through whichever sink the deployment can offer, and the interposer socket is always served:
| Sink | Reached by | Requires |
|---|---|---|
| Interposer socket | applications started with the library preloaded | nothing beyond the library |
v4l2loopback device (--webcam-device, auto by default) | every application, with nothing preloaded | the v4l2loopback module and a writable output device: a desktop host or a privileged container |
| PipeWire node | PipeWire-native applications and the pipewire-v4l2 wrapper | a reachable PipeWire daemon |
The device advertises one fixed format, as a fixed-function webcam does: --webcam-width and --webcam-height size it (client frames are scaled and letterboxed to fit), and --webcam-pixel-format pins the format or, left at auto, follows the first uplink — a browser sending JPEG gets an MJPEG device its frames pass through untouched, any other uplink an I420 one. --webcam-encoder chooses what clients encode with.
The Desktop Container and the desktop containers build and wire the library automatically, every native Selkies package ships it under /usr/$LIB (the .deb and .rpm carry the 32-bit variant too), and the AppImage carries it at usr/lib/selkies_v4l2_interposer.so, whose path its AppRun exports as SELKIES_WEBCAM_INTERPOSER. Elsewhere, build it from the source in this repository and preload it in the environment each application runs in:
git clone https://github.com/selkies-project/selkies.git && cd selkies
apt-get update && apt-get install --no-install-recommends -y build-essential
make -C addons/v4l2-interposer && PREFIX=/usr make -C addons/v4l2-interposer install
export SELKIES_WEBCAM_INTERPOSER='/usr/$LIB/selkies_v4l2_interposer.so'
export LD_PRELOAD="${SELKIES_WEBCAM_INTERPOSER}${LD_PRELOAD:+:${LD_PRELOAD}}"
On x86_64, make -C addons/v4l2-interposer all32 install32 (with gcc-multilib) adds the 32-bit variant for 32-bit applications, since /usr/$LIB resolves per process bitness.
Never preload the interposer into the Selkies process itself. It answers for /dev/video0 in whatever process it is loaded into, so the capture side would stop seeing the real device nodes; the container entrypoints drop every Selkies preload before starting the backend for the same reason.
Check the V4L2 Interposer README.md for the device surface it emulates, the SELKIES_WEBCAM_SOURCE frame-source selector, and a test server that stands in for a browser.
Universal Touch Gamepad
The Universal Touch Gamepad is a JavaScript library that adds a customizable on-screen touch gamepad overlay to the web interface. It intercepts navigator.getGamepads() to inject a virtual gamepad, making touch devices compatible with applications and games that expect the browser Gamepad API.
Selkies Dashboard
The Selkies Dashboard and the modern TypeScript variant Selkies Dashboard (Wish) are reference React dashboards that demonstrate how to build and brand your own sidebar/control UI on top of selkies-core using window messaging. They are provided as examples/starting points, not as a required component.
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 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 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). 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. -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. 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 a framebuffer server has one, and spans them whatever is published; the KDE images rebuild it to read the monitors). On X11 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 behaviour. 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. A window dragged off the edge of one display carries on into the next: the capture compositor keeps delivering a held drag to the screen it started on past its edge, and the nested labwc the images ship carries its cursor across the boundary (labwc-seam.patch) instead of clamping it at the first screen's last column. 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. Wayland applies that DPI as each screen's own output scale; X11 has only the session-wide one, so displays at different device pixel ratios cannot both be sized to their own.
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.04
Add --gpus 1 --runtime nvidia to docker run when using NVIDIA GPUs, or --device /dev/dri for Intel and AMD; the Wayland backend needs the render node passed in either way, 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 GL runs through Zink on its Vulkan driver — what replaces VirtualGL here, and what needs no render node of its own, so a container given the driver but no /dev/dri still gets hardware GL. Every other vendor renders through Mesa's own driver on the DRI3 render node, for which the image builds its own Xvfb, the upstream server plus a DRI3 patch no release of it carries. 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, 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 when using the opt-in WebRTC transport (--mode=webrtc) 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).
coTURN
Check the WebRTC and Firewall Issues: coTURN section for installing and running coTURN on self-hosted standalone machines, cloud instances, or virtual machines. STUN/TURN is only relevant to the opt-in WebRTC transport.
Pion TURN's
turn-server-simpleexecutable or eturnal are recommended alternative TURN server implementations that support Windows as well as Linux or MacOS. STUNner is a Kubernetes native STUN and TURN deployment if Helm is possible to be used.
The coTURN Container is a reference container which provides the coTURN TURN server. Other than options including -e TURN_SHARED_SECRET=, -e TURN_REALM=, -e TURN_PORT=, -e TURN_MIN_PORT= (at least 49152), and -e TURN_MAX_PORT= (at most 65535), add more command-line options in -e TURN_EXTRA_ARGS=.
Run the Docker®/Podman container built from the coTURN Dockerfile (replace main to latest for the latest stable release):
docker run --name coturn -it -d --rm -e TURN_SHARED_SECRET=n0TaRealCoTURNAuthSecretThatIsSixtyFourLengthsLongPlaceholdPlace -e TURN_REALM=example.com -e TURN_PORT=3478 -e TURN_MIN_PORT=65500 -e TURN_MAX_PORT=65535 -p 3478:3478 -p 3478:3478/udp -p 65500-65535:65500-65535 -p 65500-65535:65500-65535/udp ghcr.io/selkies-project/selkies/coturn:main
The relay ports and the listening port must all be open to the internet.
If the TURN relay port range is wide, it may take a very long time for the containers to start up. Simply using --network=host instead of specifying -p 65500-65535:65500-65535 and -p 65500-65535:65500-65535/udp can also be plausible.
Modify the relay ports -p 65500-65535:65500-65535 and -p 65500-65535:65500-65535/udp combined with -e TURN_MIN_PORT=65500 -e TURN_MAX_PORT=65535 as appropriate (at least two relay ports are required per connection).
In addition, use the option -e TURN_EXTRA_ARGS="--no-udp-relay" if you cannot open the UDP min-port= to max-port= port ranges, or -e TURN_EXTRA_ARGS="--no-tcp-relay" if you cannot open the TCP min-port= to max-port= port ranges. Note that the --no-udp-relay option may not be supported with web browsers and may lead to the TURN server not working.
Consult the WebRTC and Firewall Issues: TURN Server Authentication Methods and TURN-REST sections for the difference between static auth secret/TURN REST API authentication and traditional long-term credential authentication.
TURN-REST
The below is an advanced concept likely required for multi-user WebRTC-mode environments.
A TURN server is required with WebRTC when both the host and the client are under Symmetric NAT or are each under Port Restricted Cone NAT and Symmetric NAT.
In easier words, if both the host and client are behind restrictive firewalls, the web interface and signaling connection (delivered using HTTP(S) and WebSocket) are delivered and established, but the WebRTC video and audio stream does not establish. In this case, the TURN server relays the WebRTC stream so that the host and client can send the video and audio stream, as well as other data.
The recommended multi-user TURN server authentication mechanism is the time-limited short-term credential/TURN REST API mechanism, where there is a single shared secret that is never exposed externally (only the TURN-REST Container and the coTURN TURN server know), but instead authenticates WebRTC clients (which are Selkies hosts and clients) based on generated credentials which are valid for only a short time (typically 24 hours).
The TURN-REST Container is an easy way to distribute short-term TURN server authentication credentials and the information of the TURN server based on the REST API to many Selkies host instances, particularly when behind a local area network (LAN), which may or may not have restricted firewalls.
Using the selkies --turn-rest-uri= option or SELKIES_TURN_REST_URI environment variable, the Selkies host periodically queries a URI such as https://turn-rest.myinfrastructure.io/myturnrest or http://192.168.0.10/myturnrest.
This URI is ideally behind a local area network (LAN) inaccessible from the outside and only accessible to the Python hosts inside the LAN, or alternatively behind authentication using any web server or reverse proxy, if accessible from the outside. This information is periodically sent to the web client (that is also preferably behind authentication with HTTP Basic Authentication or a web server/reverse proxy) through HTTP(S), thus the TURN server information and credentials being propagated to both the Python host and the web client without exposing the TURN server information outside.
Because the time-limited TURN credentials automatically expire after some time, they are not useful even if they are leaked outside, as long as the pathway to the air-gapped or authenticated TURN-REST Container REST HTTP endpoint is not exposed plainly to the internet. app.py may also be hosted standalone without a container using the same startup command in the Dockerfile.
Other authentication methods such as TURN-REST over various types of REST API authentication (but adding support for TURN-REST behind Basic Authentication is trivial, so reach out with some funding) or TURN oAuth authentication are not supported as of now, and likely requires funding.
The TURN-REST Container (or similarly, Kubernetes Pod) should be triggered with the Docker®/Podman options -e TURN_SHARED_SECRET=, -e TURN_HOST=, -e TURN_PORT=, -e TURN_PROTOCOL=, -e TURN_TLS=, -e STUN_HOST=, -e STUN_PORT=, where the options are dependent on the TURN server configuration of coTURN or other TURN server implementations.
Run the Docker®/Podman container built from the TURN-REST Dockerfile (replace main to latest for the latest stable release):
docker run --name turn-rest -it -d --rm -e TURN_SHARED_SECRET=n0TaRealCoTURNAuthSecretThatIsSixtyFourLengthsLongPlaceholdPlace -e TURN_HOST=turn.myinfrastructure.io -e TURN_PORT=3478 -e TURN_PROTOCOL=udp -e TURN_TLS=false -p 8008:8008 ghcr.io/selkies-project/selkies/turn-rest:main
From Selkies, it is sufficient to use the selkies --turn-rest-uri= option or export SELKIES_TURN_REST_URI= environment variable, pointing to the HTTP(S) URI to the TURN REST API server.
Consult the WebRTC and Firewall Issues: TURN Server Authentication Methods section for more information on TURN authentication methods.
Encoders and Interfaces
This section lists the encoders and interfaces that are actually implemented in the current runtime. The set of available video encoders depends on the transport mode.
Encoders
Video is encoded by the pixelflux extension.
WebSocket mode (default) — select with the SELKIES_ENCODER environment variable or the --encoder= command-line option:
Encoder (--encoder=) | Codec | Acceleration | Notes |
|---|---|---|---|
h264enc (default) | H.264 AVC | NVIDIA NVENC / Intel & AMD VA-API, software fallback (x264, or OpenH264 in a GPL-free pixelflux) | Uses hardware encoding when a supported GPU is available; add --use-cpu=true to force software |
h264enc-striped | H.264 AVC | Software (x264, or OpenH264 in a GPL-free pixelflux) | Striped/parallel software H.264 |
jpeg | Motion JPEG | Software | Maximum-compatibility fallback |
WebRTC mode (--mode=webrtc) — the same SELKIES_ENCODER / --encoder= knob drives both transports. WebRTC can produce only full-frame H.264, so in this mode the published menu is filtered to the encoder below and a websockets-only choice (h264enc-striped, jpeg) falls back to the default with a logged warning; switching back to WebSockets restores the configured menu and value:
Encoder (--encoder=) | Codec | Acceleration | Browsers |
|---|---|---|---|
h264enc (default) | H.264 AVC | Hardware-first (NVENC/VA-API), else the software encoder pixelflux was built with (x264, or OpenH264 in a GPL-free build) | All major |
Additional codecs (H.265/HEVC, AV1, VP8/VP9) are planned for pixelflux in the mid-term
future; the vendored WebRTC stack already carries the RTP-side code for them.
Display Capture
| Interface | Device Selector | Input Injection | Operating Systems | Notes |
|---|---|---|---|---|
X.Org / X11 (via pixelflux) | DISPLAY environment | vendored python-xlib (XTEST/XFixes), under src/selkies/Xlib/ | Linux | Default backend |
Wayland (via pixelflux) | headless compositor started by Selkies (--wayland=true / SELKIES_WAYLAND=true) | input injection through the pixelflux Wayland backend | Linux | Native Wayland mode; Mac and Windows support is planned |
Audio Encoder
Opus is currently the only adequate full-band audio codec supported in web browsers by specification.
| Encoder | Codec | Operating Systems | Browsers | Notes |
|---|---|---|---|---|
pcmflux | Opus | Linux | All major | Bitrate via --audio-bitrate; Opus RED (RFC 2198) redundancy via --audio-redundancy |
Audio Capture
| Interface | Device Selector | Operating Systems | Notes |
|---|---|---|---|
PulseAudio or PipeWire-Pulse (via pcmflux) | PULSE_SERVER or PULSE_RUNTIME_PATH environment, --audio-device-name | Linux | Default capture device is output.monitor |
Client Uplinks
Both are off by default and need a secure context in the browser; see Usage.
| Uplink | Selected with | Codec | Delivered to the session as |
|---|---|---|---|
| Microphone | --microphone-enabled | Opus (WebRTC) or Opus over the WebSocket | a PulseAudio source, through the same sound server the capture reads |
| Webcam | --webcam-enabled | H.264, VP8 or MJPEG, chosen by --webcam-encoder (auto measures the client) | a V4L2 device: the V4L2 Interposer socket, a v4l2loopback device, or a PipeWire Video/Source node |
Transport Protocols
| Transport | Selected with | Ports | Notes |
|---|---|---|---|
| WebSockets (default) | --mode=websockets | single TCP port (default 8080) | WebCodecs-based client decode (striped JPEG without WebCodecs); no STUN/TURN required |
| WebRTC (opt-in) | --mode=webrtc | signaling over the same port; media over UDP (or TCP) with ICE | Uses a vendored aiortc fork; may need STUN/TURN, see WebRTC and Firewall Issues |
Use --enable-dual-mode=true to let the client switch between the WebSocket and WebRTC transports from the UI.