A Docker image that runs the regular Steam desktop client inside jlesage/baseimage-gui (TigerVNC + Openbox, with a noVNC web UI). It does one job: download, cache and update games so other PCs on your network can fetch them through Steam's Local Network Game Transfers instead of the internet.
It is not meant for playing or streaming games. It does not need a GPU, but it can use an AMD/Intel GPU or iGPU for a smoother UI if you pass one in (see "GPU or software rendering").
- Web UI:
http://<container-ip>:5800, VNC:<container-ip>:5900 - Persistent data:
/config(Steam client, login, settings) - Game libraries: any container path you mount, e.g.
/mnt/games
On another PC, a finished setup looks like this. That PC is pulling the game
from this container over the LAN, at about 1.4 Gbit/s. The blue bar names the
source DOCKER, which is the container hostname in the compose example.
The red boxes mark that source and the transfer rate.
Pull the image GitHub Actions publishes to GitHub Container Registry:
docker pull ghcr.io/pat-e/steam-gui-docker:latestOr build it yourself (steam-gui:local):
./build.sh # builds steam-gui:local
# or: IMAGE_TAG=you/steam-gui:latest ./build.shcp docker-compose.example.yml docker-compose.yml
# edit docker-compose.yml: replace every <...> placeholder, pick ONE network option
# image: is ghcr.io/pat-e/steam-gui-docker:latest, or steam-gui:local if you built it
docker compose up -dOpen the web UI. On the first start Steam downloads its client (a few hundred MB)
into /config, then shows the login window.
- Read the one-time notice and click OK. It reminds you of step 3 and is
not shown again. It is tracked by
/config/.steam-gui/focus-notice-acknowledged; setSTEAM_GUI_FOCUS_NOTICE=0to skip it. - Log in with the QR code (Steam mobile app) or with your password plus Steam
Guard. The session is stored in
/config, so this survives restarts and image rebuilds. - Settings > Interface > Enable context menu focus compatibility mode: ON.
This is required. Openbox sends an unfocus event before each click, so without
this setting, clicks on drop-down and right-click menus land on the window
underneath. (Steam stores this setting in its embedded browser's localStorage,
key
EnableContextMenuBlurDelay3. That store is a LevelDB database the image cannot safely pre-seed, so you turn it on once by hand.) It takes effect immediately, no restart needed. - Container name on your LAN: other Steam clients show this machine by its
hostname (for example as the source in their download queue). Set
hostname:in the compose file (the example usesdocker), otherwise Docker uses a random container ID like3f2a9c1b7d4e. Withnetwork_mode: hostthis is ignored and the host's own name is shown. The screenshot at the top is that name on the other PC. - Settings > Downloads:
- Game File Transfer over Local Network: on.
- Allow transfers from this device to: Only me, so only PCs logged in to your own account can pull games. Choose Steam Friends or Anyone only if you mean it.
- Shader Pre-Caching > Allow background processing of Vulkan shaders: off. This machine never runs games, so this only burns CPU. You can also turn off shader pre-caching entirely.
- Without a GPU: also make sure Settings > Interface > Enable GPU
accelerated rendering in web views (requires restart) is off. In GPU
mode, leave it on. In software mode the container already starts Steam with
-cef-disable-gpu -cef-disable-gpu-compositing. Turning the setting off makes the choice stick in Steam's own config too.
- Settings > Storage: add your library (see below), then install the games you want cached. Steam keeps installed games updated on its own.
- On every other PC, enable Game File Transfer over Local Network too. Those PCs then fetch installs and updates from this container when it has the files.
Settings > Storage > Add Drive > Let me choose another location, then pick
the mounted folder (e.g. /mnt/games). The image includes xdg-desktop-portal
and xdg-desktop-portal-gtk plus a private D-Bus session bus, so Steam can show
a folder picker. (See "Known limitations" if the picker shows up empty.)
environment:
STEAM_LIBRARY_PATHS: "/mnt/games,/mnt/games2"Before Steam starts, each listed container path that exists and is not yet in
Steam's libraryfolders.vdf is added under the next free index. The script
reuses the contentid from the folder's own libraryfolder.vdf if one exists,
or creates that file if not. Paths already listed are left alone, so this is safe
to keep set. The default is empty, so nothing is touched. On a brand-new
/config, registration waits until Steam has installed itself: log in once,
then restart the container.
docker exec steam-gui add-steam-library /mnt/games "Games"
docker restart steam-guiSteam rewrites its library list when it exits, so you cannot edit it while
Steam is running. add-steam-library only queues the path in
/config/.steam-gui/pending-libraries. The next Steam start applies it, before
Steam launches. Restart the container to apply it now (with
KEEP_APP_RUNNING=1, Steam > Exit also works). Paths that are not mounted
stay queued and are retried on the next start.
These work, with caveats:
- Permissions: the container runs Steam as
USER_ID:GROUP_ID, which must be able to create files and directories and to rename and set timestamps on the share. Steam also writes and runs a small test script (.steam_exec_test.sh) in the library root, so don't mount withnoexec. For SMB, map ownership withuid=/gid=(orforceuser) to match. - Locking and renames: Steam stages updates and renames files into place.
SMB without proper locking, or rclone without
--vfs-cache-mode full(or at leastwrites), can cause failed or corrupted updates and endless "validating". - rclone mounts: the mount must be visible inside the container. Mount it
on the host with
--allow-otherand bind it in with:/mnt/games:rshared, or Docker sees an empty directory. Set the container to start after the mount (e.g.restart: unless-stoppedtogether with a systemd dependency on the mount). - Performance: downloads, update patching and serving LAN transfers are I/O heavy. Gigabit SMB/NFS is fine; high-latency or cloud-backed mounts are slow at patching and verifying.
- If the share is missing when Steam starts, Steam marks the library as unavailable. Fix the mount and restart the container.
The image works both ways and picks the mode on every start (STEAM_GPU=auto):
-
Software (default, no GPU): if no
/dev/dri/renderD*node exists, or the app user cannot open it, Steam runs withLIBGL_ALWAYS_SOFTWARE=1(Mesa llvmpipe) and-cef-disable-gpu -cef-disable-gpu-compositing. Turn off Enable GPU accelerated rendering in web views in Steam (see first-run steps). -
GPU (AMD/Intel with open-source drivers: amdgpu, i915/xe): pass the device and add the app user to the host's
rendergroup:devices: - /dev/dri:/dev/dri environment: SUP_GROUP_IDS: "<render-gid>" # on the host: getent group render | cut -d: -f3
The render group's id differs between hosts, so look it up on yours. Startup then finds a usable render node, leaves out the software-GL settings and lets Steam use the GPU. The baseimage's X server also uses the GPU (DRI3/GBM). The container log shows which mode was picked (
GPU rendering enabledorNo usable GPU: software rendering). The proprietary NVIDIA driver is not supported by the baseimage.
STEAM_GPU=on or off forces the mode.
Run the container without privileged, without cap_add: SYS_ADMIN and without
/dev/fuse. None of these are needed to run the client and download or serve
games:
/dev/fuseis not used. The Steam client, its runtime and this image mount no FUSE filesystems. (xdg-document-portalwould use FUSE, but only for Flatpak apps; its failure is harmless.)SYS_ADMINmainly serves to unlock namespace syscalls in Docker's default seccomp profile. Disabling seccomp filtering for this container gives the same effect without granting the capability.- What current Steam does need: the client starts
steamwebhelper(the UI) inside Valve's sniper runtime through pressure-vessel/bubblewrap. That creates an unprivileged user namespace and makes mounts inside it. Docker's default seccomp profile blocks user-namespace creation withoutCAP_SYS_ADMIN, and on AppArmor hosts Docker'sdocker-defaultprofile deniesmount. If either blocks it, the log showsbwrap: No permissions to creating new namespace, the UI stays black or empty, and Steam reports webhelper crashes.-no-cef-sandboxalone does not fix that, because bubblewrap still runs.
Recommended (expected minimum):
security_opt:
- seccomp:unconfined
- apparmor:unconfined # only matters on AppArmor hosts (Debian/Ubuntu)Host requirement: unprivileged user namespaces must be allowed. Debian 11+ and
most distributions allow them by default. Ubuntu 23.10+ restricts them with
kernel.apparmor_restrict_unprivileged_userns=1. If bubblewrap still fails
there, that sysctl is the cause.
This is unverified on this exact image. Test in this order and stop at the first configuration that works:
- No extra options at all: remove
security_opt,cap_addand/dev/fuse. Start it and wait until the login window or library appears. seccomp:unconfinedonly.seccomp:unconfined+apparmor:unconfined(the recommended default above).- If the UI starts but
steamwebhelperkeeps crashing with sandbox errors, addSTEAM_EXTRA_ARGS: "-no-cef-sandbox"(turns off Chromium's own sandbox). - If bubblewrap reports
Can't mount proc, also addsystempaths=unconfinedtosecurity_opt. Docker's masked/procpaths can block a fresh procfs mount inside a user namespace. - Last resort: your old
cap_add: [SYS_ADMIN].
What to check at each step:
docker compose up -d --force-recreate steam-gui
docker logs -f steam-gui 2>&1 | grep -Ei 'bwrap|namespace|sandbox|webhelper|pressure-vessel'
# Steam's own logs (default /config layout):
docker exec steam-gui sh -c 'grep -Eih "sandbox|namespace|bwrap|crash" \
/config/.steam/root/logs/webhelper*.txt /config/.steam/root/logs/cef_log.txt \
/config/.steam/root/logs/console_log.txt 2>/dev/null | tail -n 40'
# Quick userns probe as the app user (ok = namespaces are allowed):
docker exec -u 1000:1000 steam-gui unshare -U true && echo ok # use your USER_ID:GROUP_ID"Works" means you can log in, open Settings, and start a download. A "Steam is having trouble / webhelper" dialog or a black window means try the next step.
(Alternative to seccomp:unconfined: a custom seccomp profile, i.e. Docker's
default profile plus clone, unshare and setns allowed unconditionally,
passed as seccomp=/path/profile.json. Tighter, but you have to maintain it.)
Local Network Game Transfers need the container to be a real host on the LAN. Docker's default bridge network (NAT) breaks peer discovery.
- macvlan (option A in the compose example): the container gets its own LAN IP. Note that with macvlan the Docker host itself cannot reach the container without an extra macvlan "shim" interface on the host. Other machines can.
- host networking (option B): simplest. The container shares the host's IP and ports.
Allow these to and from the container's IP (or the host, with host networking):
| Port | Proto | Purpose |
|---|---|---|
| 27040 | TCP | Local Network Game Transfers (data) |
| 27031-27036 | UDP | Steam LAN discovery (Remote Play / transfers) |
| 5800 | TCP | noVNC web UI (LAN only, ideally protected) |
| 5900 | TCP | VNC (optional) |
Outbound internet access for Steam (HTTP/HTTPS plus Steam's usual ports) must
also be allowed. Do not expose 5800/5900 to the internet. The web UI is a
logged-in Steam session. See the baseimage docs for VNC_PASSWORD, web
authentication and HTTPS.
Public environment variables from the baseimage that are useful here:
| Variable | Recommended | Notes |
|---|---|---|
USER_ID, GROUP_ID |
your host user | Owner of /config and library files |
TZ |
your zone | e.g. Europe/Berlin |
KEEP_APP_RUNNING |
1 |
Restart Steam if it exits or crashes |
DISPLAY_WIDTH, DISPLAY_HEIGHT |
e.g. 1600/900 |
Virtual screen size |
SUP_GROUP_IDS |
host render gid |
Only for GPU mode (/dev/dri) |
LANG |
en_US.UTF-8 (default) |
The locale is generated in the image |
Image-specific variables:
| Variable | Default | Notes |
|---|---|---|
STEAM_LIBRARY_PATHS |
empty | Comma-separated container paths to register before start |
STEAM_GPU |
auto |
auto, on or off: GPU vs software rendering |
STEAM_GUI_FOCUS_NOTICE |
1 |
0 disables the one-time first-run notice |
STEAM_EXTRA_ARGS |
empty | Extra Steam arguments. Never -tenfoot/-gamepadui, because Big Picture cannot serve LAN transfers |
Dockerfile: Debian 13 baseimage with 32-bit libraries, Mesa (software and GPU drivers),steam.debfrom Valve, locales, D-Bus and the GTK desktop portal. It includes a workaround for the baseimage's/var/logsymlink, which breaks Debian 13's apt.rootfs/startapp.sh: sets up the environment, applies optional library registrations, starts a session bus, shows the one-time notice, thenexec steam -nofriendsui -cef-disable-gpu -cef-disable-gpu-compositingwithLIBGL_ALWAYS_SOFTWARE=1. Desktop mode only.rootfs/etc/openbox/main-window-selection.xml: only the main "Steam" window is maximised; menus and dialogs float.rootfs/etc/xdg-desktop-portal/portals.conf: selects the GTK portal backend.rootfs/usr/local/bin/steam-library-register: the idempotentlibraryfolders.vdfedit.rootfs/usr/local/bin/add-steam-library: thedocker exechelper.tests/test-library.sh: offline tests for the library logic andstartapp.sh, using a fake Steam binary. Run them withsh tests/test-library.sh..github/workflows/publish.yml: on every push tomain, GitHub Actions buildslinux/amd64and pushesghcr.io/pat-e/steam-gui-docker. Tags arelatest(themainbranch),sha-<commit>, and, for a git tagv1.2.3,1.2.3and1.2. The workflow checks thatdocker pullworks without a login. If a new package stays private, set it to Public once in the package settings; later pushes keep that setting.
- The context-menu focus setting cannot be preset (see first-run step 3).
- The folder picker depends on xdg-desktop-portal running on a private session
bus without systemd. If the picker opens empty or not at all, use
STEAM_LIBRARY_PATHSoradd-steam-libraryinstead, and check/config/.steam/root/logs/console_log.txtfor "desktop portal" messages. - Steam updates its own client inside
/config; image rebuilds don't change the client version.
MIT.
