A single, self-contained installer that creates a native Linux gaming LXC on Proxmox VE:
- Steam Gaming Mode presented through Gamescope
- Native Sunshine (no Docker, Podman or Flatpak)
- Moonlight streaming to Apple TV (primary), Steam Remote Play (secondary)
- AMD GPU rendering (RADV Vulkan) and VA-API hardware video encoding
- A virtual display (headless sway/Wayland by default, Xorg dummy as legacy fallback) — no monitor or HDMI dummy plug
- Persistent Steam login, games, controller configuration and Sunshine pairing
- Automatic startup after host or container reboots
- Repeatable installation, diagnostics, repair and update operations
This project may be called SteamOS Streaming LXC, but the resulting system is accurately described as:
An Arch Linux LXC running Steam in a SteamOS-compatible Gamescope Gaming Mode session.
It is not an official Valve SteamOS installation because:
- the Proxmox kernel is used instead of Valve's kernel;
- Valve's bootloader is not used;
- SteamOS A/B system partitions are not present;
- SteamOS firmware updates do not apply;
- Valve's recovery and operating-system updater cannot be used safely.
The gaming interface nevertheless behaves like SteamOS Gaming Mode.
This installation uses a privileged LXC and exposes selected host GPU and input devices to the container. Treat the container as trusted and do not expose its management interfaces directly to the internet.
Keep Sunshine and Steam Remote Play on a trusted local network or access them through a VPN. Do not directly forward the Sunshine web interface (port 47990) to the internet.
The deck user has passwordless sudo inside the container as an appliance
management convenience; this is a documented hardening target.
| Component | Supported (v1) |
|---|---|
| Architecture | x86_64 |
| Hypervisor | Proxmox VE 8 or 9 |
| Container | Privileged LXC (Arch Linux) |
| GPU | AMD integrated or discrete (amdgpu, RADV, VA-API) |
| Validated | AMD Ryzen with Radeon 780M / 890M |
| Streaming | Local network, 1080p60 SDR default |
| Client | Moonlight on Apple TV |
NVIDIA and Intel GPUs, HDR, 4K120 and unprivileged containers are not supported in this release.
Run on the Proxmox VE host as root:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/netwarex/sunshine-on-steamos/main/ct/sunshine-on-steamos.sh)"The installer is interactive (whiptail) and offers default and advanced
modes. Defaults: 4 cores, 8192 MiB RAM, 512 MiB swap, 64 GiB disk, DHCP on
vmbr0, privileged, start-at-boot, tags gaming;steam;sunshine, virtual
display 1920×1080 @ 60 Hz on the wayland (sway headless) display server.
Advanced mode additionally configures CT ID, hostname, storage, disk, CPU,
RAM, swap, bridge, VLAN, static IP/DNS/MTU, GPU render node, an optional game
library bind mount (/games), streaming resolution, display server
(wayland/xorg), local controller passthrough, SSH and the deck password
policy.
On hybrid CPUs (e.g. AMD Strix Point with Zen 5 + Zen 5c cores), advanced
mode offers performance-core pinning: the container is pinned to the
performance cores and their SMT threads via lxc.cgroup2.cpuset.cpus, so
games never land on efficiency cores. (Proxmox's plain cores: setting
assigns an arbitrary mix.) To retrofit an existing container: find the CPUs
with the highest cpuinfo_max_freq, then
pct set CTID --cores N and append lxc.cgroup2.cpuset.cpus: <list> to
/etc/pve/lxc/CTID.conf, and restart the CT.
A bind-mounted game library (
mp0: /host/path,mp=/games) is not included in the container's root-disk backups — back it up separately.
A SteamLibrary folder under /games is registered with Steam automatically
before each session start. Filesystem notes for the host disk backing
/games:
-
ext4 is simplest, but multiplatform disks (exFAT/NTFS) are supported: Proton prefixes (
compatdata) andshadercache— which need symlinks and fast native storage — are automatically bind-mounted onto the container's root disk bysteamos-gamelib.service, while game content stays on the shared disk. Note the prefixes therefore do NOT travel with the disk to other machines. -
With an exFAT/NTFS library set as the default install location, Valve's Steam Linux Runtime and Proton tools try to dependency-install there and fail with "Disk write failure" (their Linux depots contain symlinks). Keep the tools in the internal library: if a tool gets stuck on the shared disk, stop the session, delete its
appmanifest_<id>.acffrom/games/SteamLibrary/steamapps/, pre-seed a minimal manifest withStateFlags 2in~/.local/share/Steam/steamapps/, and restart. -
exFAT/NTFS have no POSIX ownership: mount them on the host with
uid=1000,gid=1000(the containerdeckuser) or Steam cannot download into the library. For a removable disk, usenoauto,nofailand mount it manually before starting the container. Example host fstab entry:UUID=XXXX-XXXX /mnt/games exfat noauto,nofail,uid=1000,gid=1000,fmask=0022,dmask=0022 0 0 -
The container captures the host path at container start: mount the disk first, then start the CT (
mount /mnt/games && pct start CTID), and stop the CT before unplugging. If the disk is absent, everything degrades gracefully — Steam just shows only the internal library. -
To automate mount/unmount around the container lifecycle, use a Proxmox hookscript on the host:
cat > /var/lib/vz/snippets/steamos-games-mount.sh <<'EOF' #!/usr/bin/env bash case "$2" in pre-start) mountpoint -q /mnt/games || mount /mnt/games || true ;; post-stop) umount /mnt/games 2>/dev/null || true ;; esac exit 0 EOF chmod +x /var/lib/vz/snippets/steamos-games-mount.sh pct set CTID --hookscript local:snippets/steamos-games-mount.sh
Drop the
|| trueinpre-startif the container should refuse to start without the disk. (One hookscript per guest — merge if you already use one.)
- Install Moonlight on Apple TV.
- Pair the game controller with Apple TV (not with the server).
- Open Moonlight.
- Add the host manually using the container IP if it is not discovered (multicast discovery may not traverse VLANs).
- Select the host; Moonlight displays a pairing PIN.
- Open
https://CONTAINER_IP:47990(self-signed certificate; create your Sunshine credentials on first visit). - Enter the Moonlight PIN under PIN.
- Launch Steam Gaming Mode and sign into Steam through the stream.
Recommended initial Moonlight settings: 1920×1080, 60 FPS, codec Automatic, HDR off, bitrate 20–40 Mbps.
- Install Steam Link on Apple TV.
- Sign into Steam in the LXC (via Moonlight).
- Enable Remote Play in Steam settings.
- Pair Steam Link with the Steam instance.
Clients on a different VLAN/subnet: Steam Link discovers hosts via UDP
broadcast on port 27036, which does not cross subnets — clients then fall
back to Valve's SDR relay (high latency). Desktop Steam can force a direct
connection with the console command connect_remote IP:27036, but Apple TV
cannot. Fixes: give the container a second interface in the client VLAN
(pct set CTID -net1 name=eth1,bridge=vmbr0,tag=VLANID,ip=dhcp — NetworkManager
configures it automatically), or relay UDP 27036 between the VLANs on your
router (e.g. udpbroadcastrelay) and allow TCP/UDP 27031–27037 between the
subnets. Moonlight is unaffected (manual IP entry works across subnets).
All commands run on the Proxmox host (CTID is your container ID):
| Task | Command |
|---|---|
| Diagnostics | pct exec CTID -- steamos-streaming-diagnose [--verbose|--json] |
| Display mode | pct exec CTID -- set-display-mode [WxH[@FPS]|default] |
| Display server | pct exec CTID -- set-display-server [wayland|xorg|status] |
| GPU (selector installs) | pct exec CTID -- set-gpu [egpu|igpu|auto|status] |
| Session mode | pct exec CTID -- steamos-session-select [gaming|desktop|status] |
| Update | pct exec CTID -- steamos-streaming-update [stable|latest] or ./ct/sunshine-on-steamos.sh update CTID |
| Repair | ./ct/sunshine-on-steamos.sh repair CTID |
| Configure | pct exec CTID -- steamos-streaming-config |
| Logs | pct exec CTID -- steamos-streaming-logs [--all|--xorg|--steam|--sunshine|--audio] [--follow] |
- Update (
stablechannel, default) keeps Sunshine on the pinned, checksum verified release;latestresolves the newest Sunshine release with reduced reproducibility. Steam data, Sunshine credentials and paired clients are preserved; a failing Sunshine update is rolled back automatically. - Repair regenerates device mappings, groups, Xorg configuration, systemd
units, the PipeWire sink, the Gaming Mode wrapper and the pinned Sunshine
binary — without touching
/home/deck,/gamesor Sunshine pairing. - The diagnostics command exits nonzero when a required check fails and
supports
--jsonfor monitoring integrations.
Sunshine and Steam Remote Play always stream the virtual display's current mode; clients only scale or letterbox it. To play from a client with a different screen (e.g. an ultrawide monitor instead of a 16:9 TV), switch the virtual display:
pct exec CTID -- set-display-mode 3440x1440@120 # ultrawide session
pct exec CTID -- set-display-mode default # back to the installed modeMoonlight clients can also do this from the app grid: the "Match display to this device" entry switches the virtual display to the connecting client's requested resolution and FPS (the stream drops during the switch — reconnect afterwards; it returns immediately if the display already matches). Pick "Steam Gaming Mode" instead to keep the current mode.
Applying a mode restarts Steam Gaming Mode (a running game is closed) and
persists across reboots until default is used. The Xorg dummy driver caps
config-file modelines at a 300 MHz pixel clock, but modes applied at runtime
through RandR skip that validation — high-bandwidth modes such as
3440×1440@120 work even though they could never boot from xorg.conf.
(Refresh timing is nominal on a virtual display anyway: gamescope paces
frames from its own -r flag.)
Installed by default (opt out in the installer's advanced mode), the container carries a full KDE Plasma desktop next to Steam Gaming Mode — the same dual-session model as a Steam Deck:
- Moonlight tile "2. Desktop" switches to the Plasma desktop; tile "1. Steam Gaming Mode" (or the "Return to Gaming Mode" desktop icon) switches back. The stream survives the switch — Sunshine captures the compositor, not the session.
- Steam's own Power → Switch to Desktop menu entry works natively:
steamos-session-selectaccepts the SteamOS session names (plasma,gamescope, …) that Steam invokes, not justgaming/desktop. - Logging out of Plasma returns to Gaming Mode automatically, and a reboot
always comes back in Gaming Mode (the mode marker lives in
/run). set-display-mode/ "Match display to this device" work in both modes; the desktop's X screen is sized to the compositor mode on every start.- From a shell:
steamos-session-select gaming|desktop|status.
Implementation notes (wayland stack): the desktop is a Plasma X11
session inside a rootful fullscreen Xwayland window on the headless sway
compositor. kwin's compositing is disabled (its GLX swapchain never
presents through rootful Xwayland — black output — and sway composites the
display anyway), WAYLAND_DISPLAY is hidden from the session (Qt/KDE apps
would otherwise attach to sway as invisible wayland clients), and both
sessions are hosted by the single steamos-session.service via a
dispatcher — one unit cannot race itself, unlike a two-unit Conflicts=
design. Sunshine deliberately runs with no X display (trayless): an X
connection to gamescope's Xwayland would kill Sunshine — and drop the
stream — every time the session stops.
On hosts with more than one AMD GPU (e.g. an APU plus an OcuLink/USB4 eGPU),
advanced mode offers the GPU selector (var_multi_gpu=yes for unattended
installs, also accepted by repair to upgrade an existing container). It:
- writes host udev rules pinning stable names by PCI ID
(
/dev/dri/render-egpu,/dev/dri/render-igpu, pluscard-*) — DRM node numbers renumber when a GPU is attached, detached or reset, so rawrenderD12xnames and evenby-pathlinks are unreliable; - binds the whole
/dev/driinto the container instead of a single fixed node, so re-plugged GPUs appear without a container restart; - keeps the discrete/external GPU out of runtime suspend (an externally attached card that enters BACO/D3cold can fail to retrain its link and stays dead until a secondary bus reset);
- installs
set-gpuinside the container:
pct exec CTID -- set-gpu egpu # pin the streaming stack to the eGPU
pct exec CTID -- set-gpu igpu # pin to the integrated GPU
pct exec CTID -- set-gpu auto # default: eGPU when attached, else iGPU
pct exec CTID -- set-gpu status # show selection and the active resolutionSwitching restarts the streaming stack. The compositor, gamescope, Steam,
games and Sunshine's VA-API encoder all follow the same selection
(WLR_RENDER_DRM_DEVICE, MESA_VK_DEVICE_SELECT, DRI_PRIME,
adapter_name), which matters: a compositor on GPU A with gamescope
enumerating GPU B fails surface creation and crash-loops. If the selected
GPU disappears (eGPU unplugged), the stack falls back to whatever is present
on its next start.
Advanced mode can install Decky Loader (plugin dock in
Gaming Mode's Quick Access menu) together with the
NonSteamLaunchers plugin,
which installs Epic, GOG, Battle.net and other launchers under Proton and adds
them to Steam (var_decky=yes for unattended installs; also accepted by
repair to add it to an existing container). Both are third-party and
resolved at install time — Decky as its latest stable release, NSL from the
repo's main branch; neither ships pinnable checksummed artifacts.
Launcher integration with the shared /games library:
- The session wrappers export
STEAM_COMPAT_MOUNTS=/games, so launchers running under Proton can see the library at all — the pressure-vessel sandbox hides paths outside the Steam libraries otherwise. - Named (launcher) Wine prefixes get a
G:drive pointing at/games, so install-location pickers show it directly. - NSL's Move to SD Card is patched to treat
/gamesas the SD card (reapplied onrepair; NSL self-updates overwrite it). Note that moving launcher prefixes to an exFAT/NTFS/gamesfails by design — those filesystems cannot hold the symlinks inside Wine prefixes; rsync aborts before anything is deleted. Game downloads themselves are fine there.
When the optional /games host path lives on a FAT-family filesystem
(exFAT/NTFS/vfat — common for disks shared with macOS/Windows), the installer
serves /games through a small FUSE shim (fuse_xattrs, built from a pinned
upstream commit with chmod/chown faked to succeed). Reason: Proton persists
Windows ACLs as extended attributes, and the Windows Epic launcher refuses
any install/cache directory where its ACL write-and-readback fails (error
DP-07) — FAT-family filesystems cannot store xattrs. The shim keeps them in
hidden .xattr sidecar files on the same disk, so EGL installs work directly
on the exFAT/NTFS disk. On POSIX filesystems /games is a plain bind with no
FUSE involved. The mount is managed in-container
(steamos-games-mount.service, self-healing on daemon crash); Wine
prefixes still cannot live on FAT-family disks (no symlinks) and stay on
the container disk as before.
Advanced mode can install Heroic
(var_heroic=yes for unattended installs; also accepted by repair) — the
native Linux Epic/GOG client built on Legendary. Unlike the Windows Epic
launcher, it installs games as plain files with no directory ACL checks,
which makes it the reliable installer for exFAT/NTFS game disks. The release
is pinned and checksum-verified (like Sunshine) and installed as an extracted
AppImage (/opt/heroic, heroic command).
After installing: log in from Desktop Mode, set the default install path
(e.g. under /games), keep the Wine prefix location at its default (the
container disk — prefixes cannot live on exFAT/NTFS), and use each game's
"Add to Steam" to surface it in Gaming Mode.
The packetsize option (SUNSHINE PACKET SIZE in advanced mode) caps the
streaming UDP payload below a VPN/tunnel path MTU — but it only exists in
Sunshine ≥ 2026.7xx, and those builds currently have a broken wlroots capture
against sway (black screen), while the pinned 2026.516 stable ignores the
option. Until upstream ships a stable with both, build-sunshine-packetsize.sh
(run inside the container) builds the pinned stable with the single upstream
packetsize commit cherry-picked on top and switches /opt/sunshine/current to
it. Temporary by design: when a good stable lands, bump the installer pin and
delete the script. steamos-streaming-update reverts to the stock binary
(stream keeps working; packetsize just goes inert until you re-run the
build script).
Two display stacks are installed; new installs default to wayland
(override with the advanced-mode "Display server" setting or
var_display_server=xorg). Switch any time (restarts the session):
pct exec CTID -- set-display-server wayland # sway headless + GPU zero-copy capture (default)
pct exec CTID -- set-display-server xorg # legacy v1 stackWhy wayland is the default: the Xorg dummy path forces gamescope through Mesa's software-WSI present path (
MESA_VK_WSI_DEBUG=sw; the dummy driver has no DRI3). On newer stacks this path is broken outright: with gamescope 3.16.25 + mesa 26.1.6 on a Strix Halo (Radeon 8060S), gamescope segfaults seconds after the Steam UI attaches, in an endless crash loop. The wayland stack does not use that path and is unaffected (and was already the better-performing, validated daily driver).
The wayland stack replaces the Xorg dummy display with a headless sway compositor: gamescope presents real GPU dmabuf frames (no software copy) and Sunshine captures the compositor output zero-copy with proper frame pacing. Measured effects: capture judder/"wobble" eliminated, lower stream latency, much less CPU per frame. Implementation notes baked into the units:
- sway runs with libseat's
noopbackend (seat managers cannot activate a session inside an LXC) andWLR_BACKENDS=headless,libinput. - gamescope uses its SDL backend on SDL's wayland driver (its native wayland backend requires a primary DRM node the container does not have).
- gamescope, sway and Sunshine receive
CAP_SYS_NICE(high-priority GPU queues, Steam Deck parity); Steam itself is started viasetpriv --ambient-caps -allbecause its pressure-vessel sandbox refuses inherited capabilities. - Sunshine's
capture =setting is rewritten automatically to match the active stack on every service start.
CAP_SYS_NICE requires the container to keep that capability: the installer
writes the needed lxc.cap.drop override for new containers. To retrofit an
existing container, add on the host:
echo "lxc.cap.drop:" >> /etc/pve/lxc/CTID.conf
echo "lxc.cap.drop: sys_time sys_module sys_rawio" >> /etc/pve/lxc/CTID.conf
pct stop CTID && pct start CTID
For games that emit very long GPU command buffers (observed with
Age of Mythology: Retold — its menu starved capture to a few FPS), add
amdgpu.mcbp=1 (mid-command-buffer preemption) to the Proxmox host's
kernel command line and reboot the host; without it, capture under such
games may drop to single-digit FPS regardless of priorities. The session
wrapper also caps dxvk/vkd3d titles at the stream FPS
(DXVK_FRAME_RATE/VKD3D_FRAME_RATE) so uncapped menus cannot flood the
GPU queues; lowering such a game's own graphics settings shortens its
command buffers further. sway's contexts get a high GPU priority via a
small eglCreateContext shim (wlroots never requests one itself).
Experimental alternate for GPU-saturating games (validated end-to-end,
rough edges): sway's Vulkan renderer + portal capture keeps the stream
usable (~20 FPS, 36–76 ms host latency) where the default path collapses,
at the cost of UI framerate (~45) and added PipeWire latency. To try it
inside the container: uncomment Environment=WLR_RENDERER=vulkan in
steamos-wayland.service, change set_capture wlr to set_capture portal
in /usr/local/lib/steamos-streaming/gen-display-env, switch
/opt/sunshine/current to a Sunshine ≥2026.726 pre-release, and restart
steamos-wayland. Three hard-won requirements baked into the configs:
render_bit_depth 8 (10-bit Vulkan buffers silently defeat DMA-BUF
negotiation → CPU-copy fallback), a Sunshine with the wlroots DMA-BUF
modifier fixes, and clients allowed through the firewall on UDP 48010
(a new protocol port in recent Sunshine).
The Xorg stack's CPU-based capture is immune to all of this (at the cost of latency/judder), and one command away.
Installation state is recorded in /etc/steamos-streaming-release inside the
container. Logs: /var/log/steamos-streaming-installer.log (host),
/var/log/steamos-streaming/ and the systemd journal (container).
The installer follows the community-scripts/ProxmoxVE contribution
conventions
where they apply to a standalone project: the two-file structure
(ct/sunshine-on-steamos.sh for host-side container creation,
install/sunshine-on-steamos-install.sh for the in-container
installation), #!/usr/bin/env bash, lowercase-hyphen filenames, every
user-tunable setting as an environment-overridable var_* with a sane
default (no prompt without an escape hatch), no hardcoded credentials,
quoted expansions (shellcheck-clean, both files), and the same dev_mode
flag convention. The ct/ script uses the local install/ file when run
from a checkout and downloads it from INSTALL_SOURCE_URL otherwise. The
remaining divergence from an actual community-scripts submission is that
this project is self-contained rather than built on their shared
build.func/install.func framework and metadata infrastructure.
dev_mode="trace,keep,logs,pause" ./ct/sunshine-on-steamos.shtrace = set -x, keep = keep a failed container, logs = preserve
detailed logs, pause = pause before running the container installer.
- Networking inside the container is managed by NetworkManager (Steam
Gaming Mode reads its network state over D-Bus, like a real Steam Deck).
A static IP chosen at install time is translated to a NetworkManager
profile; later
pct set --net0changes are not picked up — adjust the profile withnmcliinside the container instead. - The Sunshine web UI's CSRF allow-list is refreshed automatically from the container IP each time Sunshine starts; after a DHCP address change, restart Sunshine (or reboot) and use the new URL. A DHCP reservation is still recommended so Moonlight's host entry stays valid.
- Privileged LXC only; the GPU render node is exposed world-writable (0666).
- The legacy Xorg stack uses X11 capture (more CPU overhead, polling judder) and gamescope's software-WSI path, which crashes on newer gamescope/mesa; the default wayland stack avoids all of that. No HDR on either stack.
- 4K is selectable but not guaranteed on all hardware; validate with 1080p60 first.
- One Moonlight session at a time.
- Anti-cheat support is whatever normal Linux/Proton provides.
- Sunshine's encoder is fully validated on the first real stream; the health check reports WARN until then.
The installation lives entirely inside the container:
pct stop CTID && pct destroy CTID
rm -f /etc/modules-load.d/steamos-streaming.conf # optional host cleanupIf you configured a game-library bind mount, the host path and its contents are left untouched.
MIT — see LICENSE.