Skip to content

Repository files navigation

podstage

Play a game streamed to your Steam Deck (or any moonlight client) while your desktop keeps doing its own thing.

podstage runs each stream as a headless, isolated Steam Big Picture session inside a rootless container: its own display, audio, Steam login and settings, but shared game downloads with your main install. The game renders on a nested gamescope display, and a streaming backend captures and encodes only that session: sunshine or moonshine. Your monitors, your sound and your Steam config stay untouched.

A PyQt6 GUI on the host handles setup, sandboxes and sessions. Every step is also a CLI command, including the first Steam login, so a machine without a desktop works the same way.

podstage streaming Path of Exile 2 to a Steam Deck

Why podstage

The idea came on the couch: my gaming PC playing YouTube on the TV while I played on the Steam Deck, graphics down, fan roaring. The powerful machine sat idle while the little one did the work.

Steam Remote Play and a plain sunshine install mirror your desktop session: streaming takes over the screen you are sitting at, grabs the audio, and shares one Steam config and one logged-in account. podstage spins up a separate, invisible session instead:

  • The desktop keeps its monitors, audio and Steam settings, and stays usable while someone streams. (Desktop Steam closes on session start by default; a Setup toggle keeps it running, e.g. for a second account.)
  • Input from the client stays inside the session, in both directions.
  • Sandboxes sit side by side, each with its own login, Steam settings, Input layout and per-game presets. One per client, per account, or per use case.

A headless server like moonshine already isolates the stream. podstage drives it as one of its two backends and adds the sandboxed Steam around it: isolated login, games shared from your host libraries instead of downloaded twice, plus the setup, provisioning and monitoring you would otherwise assemble by hand.

What podstage does

podstage is an orchestrator. It writes no compositor, no encoder and no streaming server; it assembles existing ones into one disposable session and manages its lifecycle:

  • builds the runtime image, the container that carries the whole session stack, so the host installs nothing but podman
  • creates sandboxes, one isolated $HOME per client or account, with its own Steam login, settings and Input layout
  • provisions games, symlinked from your host Steam libraries and mounted read-only, so nothing downloads twice and a session cannot write to host game files
  • wires the container to the machine: GPU (CDI or /dev/dri), the host's 32-bit GL and NGX libraries, input devices and /dev/uinput, the ports
  • starts and supervises the session: compositor, gamescope, Big Picture, the streaming backend, plus a focus watchdog and a performance probe inside the container
  • handles what surrounds the stream: sunshine/moonlight pairing, mDNS, encoder settings, telemetry, the preview, and the first Steam login over the stream

Only one session runs at a time, by design. The container is disposable: stopping a session tears it down, while the sandbox it used (Steam login, settings, saves, prefixes) stays on disk and is what the next session starts from.

flowchart LR
    subgraph host["Host · Linux · Wayland · NVIDIA / AMD / Intel GPU"]
        gui["Management GUI (PyQt6) or CLI<br/>setup · sandboxes · session · telemetry · logs"]
        libs[("Shared Steam libraries")]
        home[("Sandbox $HOME<br/>login · settings · prefixes")]
        subgraph container["container · rootless podman"]
            pipeline["compositor → gamescope (Vulkan) → Steam Big Picture → game (Proton)<br/>private audio"]
            backend["Streaming backend<br/>sunshine or moonshine"]
            pipeline -->|captures| backend
        end
        gui -->|starts · stops · monitors| container
        libs -.->|symlinked, read-only overlay| pipeline
        home -.->|mounted| pipeline
    end
    backend -->|encode| moonlight["moonlight client"]
Loading

gamescope plus Big Picture is settled, not a placeholder: Steam forces the gamepad UI under gamescope, and gamescope provides the Xwayland environment, fullscreen forcing and scaling the rest builds on. A desktop-UI session exists in the runtime as a debug path, not as a way to play; the streamed first login runs the normal Big Picture pipeline.

What is baked into the image vs. mounted at runtime, the exact run flags, and how input hotplug works inside the container is in containers/runtime/README.md.

Built on

podstage bundles and drives upstream projects:

Component Role
Host podman (rootless) the sandbox itself: one container per session, running as your user, no daemon, no root
Python ≥ 3.11 · PyQt6 CLI, core and the management GUI
udev rules · avahi client input pinned to a dedicated seat, and the mDNS announcement for the sunshine backend
OverlayFS host game libraries read-only, per-sandbox writes on top
Container gamescope nested Vulkan compositor: Big Picture, fullscreen, resolution and scaling. Same on both backends
Steam · Proton the game session itself (-gamepadui)
sunshine default backend: capture, hardware encode (NVENC/VAAPI), GameStream server
labwc · PipeWire the Wayland output sunshine captures, and a private audio graph, host audio untouched
moonshine alternative backend: compositor, capture, Vulkan Video encode, mDNS and server in one Rust process, with its own PulseAudio
seat-shim.c · keeper.c · focus-nudge.c · perf-probe.c podstage's own helpers: the small pieces of glue that keep the stack above working together inside the rootless namespace

Streaming backends

How the picture is composited, captured and encoded can be chosen per profile. Everything else stays the same, gamescope included: it renders the session on both backends, and the game never sees a difference. What differs is everything around it.

flowchart TB
    subgraph sun["sunshine · default"]
        direction TB
        s1["labwc<br/>headless wlroots compositor"] --> s2["gamescope"] --> s3["Steam Big Picture → game"]
        s1 -->|wlr-screencopy| s4["sunshine<br/>capture · NVENC/VAAPI · server"]
        s4 -->|host avahi| s5(["moonlight"])
    end
    subgraph moon["moonshine"]
        direction TB
        m1["moonshine<br/>compositor · capture · Vulkan Video · server"] --> m2["gamescope"] --> m3["Steam Big Picture → game"]
        m1 -->|built-in mDNS| m5(["moonlight"])
    end
    classDef shared stroke-width:2px
    class s2,s3,m2,m3 shared
Loading
sunshine (default) moonshine
encode NVENC / VAAPI: NVIDIA, AMD, Intel (Broadwell+) Vulkan Video: NVIDIA RTX, AMD RDNA2+, Intel Arc
pairing (podstage does it) web UI or CLI, TLS + login CLI only, plain HTTP, no auth
mouse & keyboard per-install toggle, host layout always streamed, layout per profile (XKB)
gamepad Xbox pad; DualSense via the gamepad_ds5 switch its own inputtino pad, no switch
mid-game pad reconnect gamepad_reconnect switch (Xbox pad) not available (hidraw)
render size first client's mode, locked until restart the connecting client's mode, per connect
image podstage-runtime (about 3 GB) podstage-moonshine (about 4 GB), built on top of it

sunshine was the initial approach and runs on anything with a hardware encoder: labwc composites the session, sunshine captures that output through wlr-screencopy. The cost is the plumbing between the parts, a dedicated seat for the client's input devices, faked udev hotplug in the rootless namespace, and a pointer capability held up so gamescope keeps mouse input.

moonshine is compositor, capture, Vulkan Video encode, mDNS and GameStream server in one Rust process. Steam and gamescope sit on top unchanged, but the input layer below disappears, because that compositor never opens an evdev device. Its container runs under a seccomp profile derived from podman's own, with one syscall ungated for moonshine's DMA-BUF import cache (see containers/moonshine/README.md).

On both backends, holding the controller's Select/Back button (default two seconds, adjustable) presses the Guide button (the Steam menu, e.g. to quit a game): sunshine's back_button_timeout and moonshine's home_button.hold_ms, wired to one Setup-page switch with a hold-time field. Steam Deck clients need this, the Deck's local Steam consumes the physical Steam button. A desktop Steam left running sees the emulated pad too; disable "Guide Button Focuses Steam" there. For text entry the Deck's own on-screen keyboard (Steam+X) types into the stream; on sunshine this needs the mouse & keyboard input switch, moonshine always streams the keyboard.

podstage sandbox add tv --backend moonshine
podstage runtime build --backend moonshine     # once, builds from source
podstage doctor                                # checks this GPU can encode

Both backends are checked on every podstage doctor run, whether a profile uses them or not, so you can see whether this machine can do moonshine before choosing it. The GUI does the same, with a Build image button per backend group on the Setup page.

Requirements

  • Linux with a Wayland desktop. Developed on Bazzite-DX (Fedora-based, KDE Plasma); other modern distros should work.
  • podman.
  • A GPU with hardware video encode: NVIDIA (NVENC, via CDI injection), AMD or Intel (VAAPI via /dev/dri, Broadwell+ on Intel). The moonshine backend wants more, see Streaming backends. The GUI adapts its encoder controls and telemetry to the detected vendor.
  • Steam on the host; its libraries are shared into the sandboxes.
  • Python ≥ 3.11 for the CLI and core. PyQt6 ≥ 6.6 only for the GUI, which is optional.
  • A moonlight client with a gamepad (Steam Deck, laptop, phone with controller); mouse and keyboard are a toggle. A PlayStation controller needs the gamepad_ds5 experimental switch on the sunshine backend.

Tested configuration. Verified end to end on Bazzite-DX 43 (KDE Plasma, Wayland) with an NVIDIA RTX 4080 SUPER, streaming to a Steam Deck. AMD is validated on a Rembrandt iGPU, Intel confirmed by a community report (Arc B580). Other distros and non-KDE compositors are untested (see Portability); reports welcome.

Getting started

git clone https://github.com/slooock-dev/podstage && cd podstage
python3 -m venv .venv && . .venv/bin/activate
pip install -e '.[ui]'       # core + CLI + GUI; drop [ui] for CLI only

podstage runtime build

With the GUI

./ui.sh, then work three pages top to bottom:

  1. Setup: every red or amber check has a fix button, root-gated ones open a pkexec prompt. Build the image, install the two udev rules, open the firewall: mDNS for auto-discovery, the profile's moonlight port block for the stream itself. Everything after this runs without a password.
  2. Sandboxes: create a profile (name, resolution, port, backend), then Streamed login: the sandbox boots into Big Picture's sign-in, so you pair moonlight (step 3) and log in over the stream, without a window on the host. Start Steam login opens the isolated Steam on the desktop instead, for the settings Big Picture does not expose. Either way the library is provisioned automatically.
  3. Session: pick the sandbox, Start, then Pair with the PIN moonlight shows.

Headless, from the CLI

podstage doctor                              # what is missing
podstage setup                               # prints the (sudo) setup commands
podstage sandbox add deck
podstage sandbox login deck                  # first Steam login, over the stream
podstage session start deck
podstage session pair deck 1234              # PIN from moonlight

Everything the GUI does is a command, the first Steam login included: sandbox login boots a fresh sandbox into Big Picture's sign-in over the stream (QR code via the Steam Mobile App, or the on-screen keyboard), and Steam bootstraps entirely in-container. The one step that still wants a desktop is sandbox setup, which opens the sandbox's Steam visibly on the host for settings Big Picture does not expose.

Managing a session

The GUI

Page What it does
Session Start and stop the stream, the running game, the Performance card (game FPS, plus GPU/VRAM/encoder and the whole machine's CPU and RAM), a live preview, pairing, and the backend's quality settings: NVENC or VAAPI presets on sunshine, error correction on moonshine.
Sandboxes Profiles including the streaming backend, per-sandbox status (login, paired clients, disk and overlay usage with cleanup), and both Steam-login paths (over the stream or on the desktop).
Setup Doctor checks grouped by host, streaming and backend, each with a one-click fix; the one-time udev rules install, the sandbox location, desktop integration, streaming toggles (close the desktop Steam, mouse and keyboard, preview, performance metrics), experimental features, an update check, UI language, and the uninstaller.
Logs Live journald tail of the runtime container.

Sandboxes page Setup page

English and German, following the system locale (override in Setup or via PS_LANG). ./ui.sh picks the interpreter that can import PyQt6 ($PS_QT_PYTHON overrides) and hands Qt's plugin path to the app in-process, so child processes keep a clean environment.

The CLI

podstage doctor                    # validate the environment
podstage setup                     # print guided (sudo) setup commands
podstage uninstall [--keep-sandboxes] [--all] [--dry-run]
podstage runtime build [--backend sunshine|moonshine]
podstage runtime start|stop|status # drive the container directly (by HOME dir)
podstage sandbox list
podstage sandbox add <name> [--resolution R] [--port N] [--backend B] [--apps ID,…] [--fixed-resolution] [--mount PATH[:rw]]…
podstage sandbox login <name>      # streamed first login (Big Picture sign-in)
podstage sandbox setup <name>      # visible desktop login on the host
podstage sandbox remove <name> [--data] | clear-overlay <name>
podstage session start|stop|status <name>   # start: --resolution, --app
podstage session pair <name> <PIN>
podstage experimental [enable|disable <feature>]
podstage config mouse-keyboard|perf-metrics [on|off]
podstage desktop [menu|autostart [on|off]]
podstage provision <app_id> <session>

podstage runtime start --home homes/deck --resolution 1280x800@60 is what containers/runtime/run.sh wraps. Live container logs: journalctl -f CONTAINER_NAME=podstage-runtime.

Optimization

Image quality

The encoder controls on the Session page (NVENC preset, two-pass and VBV on NVIDIA, the VAAPI quality profile and rate control otherwise) only decide how well the encoder spends the bitrate it is given. The bigger wins are on the client and the network:

  • Raise the moonlight bitrate. The session streams what the client asks for, and moonlight's default could be too low.
  • Prefer HEVC or AV1 over H.264 (moonlight → Settings → Video codec). At the same bitrate HEVC looks noticeably better, AV1 better still. NVIDIA encodes all three; AMD and Intel cover H.264 and HEVC, with AV1 on newer GPUs.
  • Match the client's native resolution. On sunshine the session renders at whatever connects first and scales later clients; moonshine rebuilds the session per connect, so every client gets its own mode.
  • Prefer a wired host. High bitrate over Wi-Fi suffers from packet loss. Wiring the host, or a clean 5 GHz link, often helps more than any encoder setting.

After that, tune the encoder on the Session page: on NVIDIA max the preset (P7) and two-pass (full res), and raise VBV if fast motion still shows artifacts; on AMD and Intel raise the VAAPI quality profile.

Shader caching

Each sandbox keeps its own shader cache, so Steam's shader pre-caching costs disk per sandbox instead of once per machine, and every sandbox waits through its own "Processing Vulkan shaders" before a game starts.

On strong hardware, turn it off (sandbox Steam → Settings → Downloads). That saves gigabytes per sandbox and skips the wait; DXVK and VKD3D compile on the fly instead, which a capable CPU and GPU handle well, at the price of a brief stutter on first run in a few titles.

Security notes

podstage is built for a local, trusted network. The stream, the pairing endpoints and sunshine's web UI listen on your LAN and belong nowhere else. Streaming requires a completed pairing on both backends; moonshine's PIN endpoint has no authentication at all, which is upstream's design and nothing podstage can tighten.

Everything runs as your user; after the one-time setup, nothing needs root. The container is a compatibility sandbox, not a security boundary: it shares your network and the real /dev/uinput. Your Steam libraries are read-only overlay lowerdirs by default, so a hostile game cannot modify host game files and its writes stay in per-sandbox storage; the per-sandbox "write game updates to the host library" option trades that protection for persistent updates. Otherwise treat games with the same trust you would on the desktop.

The images are built locally, from a digest-pinned base, a sha256-verified sunshine package and a pinned moonshine commit.

Troubleshooting

  • Big Picture takes controller input but focuses nothing. Steam's UI lost its navigation focus, usually right after a game exits. A watchdog in the container re-focuses Steam's window and normally heals it; otherwise press B until the side menu opens. PS_FOCUS_NUDGE=disabled turns it off.
  • Client input controls the desktop, or the stream has no input. Both udev rules must be installed: the seat rule pins the streaming devices to a dedicated seat, the generated owner rule makes them and /dev/uinput accessible to the container. Install both from the Setup page. If /dev/uinput stays unwritable afterwards, run sudo udevadm trigger --sysname-match=uinput.
  • moonlight can't auto-discover the host. Open mDNS in the firewall (firewall-cmd --add-service=mdns, offered as a Setup fix). Pairing by IP always works, as long as the profile's moonlight port block is open too, which Setup checks separately.
  • No GPU load shown on Intel. The meter samples intel_gpu_top; install it (igt-gpu-tools) and make the GPU PMU readable (CAP_PERFMON or a relaxed perf_event_paranoid). VRAM stays unavailable on i915/xe.
  • The preview stays blank. On sunshine the capture only produces a frame while the picture is changing; on moonshine there is nothing to capture until a client connects, because the compositor only exists then. The placeholder shows until the first frame arrives.
  • A game re-downloads the same update in every session. Sandbox-side updates live in per-sandbox overlay storage (~/.local/share/podstage/overlays/) and are purged once the host updates the game past the sandbox. Update games on the host, or enable the sandbox's "write game updates to the host library" option.

Uninstall

podstage uninstall (or Setup → Remove podstage) detects and removes everything setup created: udev rules, firewall ports, the runtime images, sandboxes, data, configuration, and the desktop integration. Shared pieces (the mDNS firewall service, the NVIDIA CDI spec) are kept unless --all, since other software uses them too.

Related projects

Games on Whales / Wolf is a multi-client streaming platform built on the same isolation idea; Apollo (a sunshine fork) gives each client its own virtual display on Windows. podstage sits above the capture/encode layer either way: a complete containerized Steam Big Picture session, ready to stream, including the sandboxed Steam login itself (done in the stream, controller or QR code, no window on the host), one Linux gaming PC, one stream at a time.

The streaming servers it drives are described under Streaming backends.

Development

See CONTRIBUTING.md for the dev setup, the Qt/Python quirk for the GUI, and the test workflow.

podstage is written with AI coding assistants, with the design, the architecture and the concepts behind them coming from a person. Everything is reviewed and verified on real hardware before it lands, and CI runs ruff plus the test suite on Python 3.11-3.13.

License

MIT, see LICENSE.

About

Headless Steam Big Picture in a rootless Podman container, streamed to a Steam Deck or any Moonlight client. Shares your game library, not your desktop.

Topics

Resources

Contributing

Stars

18 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages