Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Sunshine on SteamOS (for Proxmox VE)

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

Scope and naming

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.

Security notice — privileged container

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.

Supported environment

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.

Installation

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) and shadercache — which need symlinks and fast native storage — are automatically bind-mounted onto the container's root disk by steamos-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>.acf from /games/SteamLibrary/steamapps/, pre-seed a minimal manifest with StateFlags 2 in ~/.local/share/Steam/steamapps/, and restart.

  • exFAT/NTFS have no POSIX ownership: mount them on the host with uid=1000,gid=1000 (the container deck user) or Steam cannot download into the library. For a removable disk, use noauto,nofail and 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 || true in pre-start if the container should refuse to start without the disk. (One hookscript per guest — merge if you already use one.)

Apple TV pairing

  1. Install Moonlight on Apple TV.
  2. Pair the game controller with Apple TV (not with the server).
  3. Open Moonlight.
  4. Add the host manually using the container IP if it is not discovered (multicast discovery may not traverse VLANs).
  5. Select the host; Moonlight displays a pairing PIN.
  6. Open https://CONTAINER_IP:47990 (self-signed certificate; create your Sunshine credentials on first visit).
  7. Enter the Moonlight PIN under PIN.
  8. 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.

Steam Remote Play (secondary path)

  1. Install Steam Link on Apple TV.
  2. Sign into Steam in the LXC (via Moonlight).
  3. Enable Remote Play in Steam settings.
  4. 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).

Maintenance

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 (stable channel, default) keeps Sunshine on the pinned, checksum verified release; latest resolves 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, /games or Sunshine pairing.
  • The diagnostics command exits nonzero when a required check fails and supports --json for monitoring integrations.

Switching display modes (different client screens)

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 mode

Moonlight 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.)

Desktop Mode (KDE Plasma, Steam Deck style)

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-select accepts the SteamOS session names (plasma, gamescope, …) that Steam invokes, not just gaming/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.

Multi-GPU selector (advanced): eGPU/iGPU switching

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, plus card-*) — DRM node numbers renumber when a GPU is attached, detached or reset, so raw renderD12x names and even by-path links are unreliable;
  • binds the whole /dev/dri into 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-gpu inside 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 resolution

Switching 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.

Decky Loader + NonSteamLaunchers (advanced)

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 /games as the SD card (reapplied on repair; NSL self-updates overwrite it). Note that moving launcher prefixes to an exFAT/NTFS /games fails by design — those filesystems cannot hold the symlinks inside Wine prefixes; rsync aborts before anything is deleted. Game downloads themselves are fine there.

Game library on exFAT/NTFS disks

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.

Heroic Games Launcher (advanced)

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.

Sunshine packetsize on the pinned stable (companion script)

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).

Display server: Wayland zero-copy (default) or Xorg (legacy)

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 stack

Why 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 noop backend (seat managers cannot activate a session inside an LXC) and WLR_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 via setpriv --ambient-caps -all because 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).

Conventions

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.

Developer mode

dev_mode="trace,keep,logs,pause" ./ct/sunshine-on-steamos.sh

trace = set -x, keep = keep a failed container, logs = preserve detailed logs, pause = pause before running the container installer.

Known limitations (v1)

  • 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 --net0 changes are not picked up — adjust the profile with nmcli inside 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.

Uninstall

The installation lives entirely inside the container:

pct stop CTID && pct destroy CTID
rm -f /etc/modules-load.d/steamos-streaming.conf   # optional host cleanup

If you configured a game-library bind mount, the host path and its contents are left untouched.

License

MIT — see LICENSE.

About

Native Steam Gaming Mode + Sunshine in a Proxmox VE LXC. Use an AMD iGPU or dGPU for headless gaming without dedicating it to a VM, while keeping the GPU available for other workloads. If this helped you, consider giving the repo a ⭐.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages