This document records the implementation and initial verification of an always-on Steam streaming session on a monitorless NixOS host.

The host renders a 1920×1200 display at 120 Hz on an RTX 4070 Ti. Sunshine captures and encodes that display. Moonlight on a NixOS laptop decodes the stream and returns keyboard, mouse, and controller input. XREAL glasses can display the laptop’s output without connecting directly to the server.

Outcome

The deployed system provides:

  • A virtual 1920×1200@120 display on a physically disconnected NVIDIA output.
  • An automatically logged-in gamescope session running Steam Big Picture.
  • Sunshine KMS capture with Vulkan Video hardware encoding.
  • H.264, HEVC, and AV1 encoder support on the RTX 4070 Ti.
  • PipeWire audio capture and Opus transport.
  • Remote input through Linux uinput.
  • An official Moonlight client on the NixOS laptop.
  • A LAN-only administration endpoint at sunshine.kuhree.com.
  • No public-edge route and no game/inference mode-switch service.

The initial operator test covered several games for roughly five to ten minutes. The stream was reported as smooth, with barely perceptible input lag. The XREAL glasses were a good fit for the single-screen gaming use case. Longer sessions and more games remain untested.

Constraints

The implementation was designed around these constraints:

ConstraintDecision
No physical monitorGenerate an EDID and force one DRM connector active at boot.
Existing AI workloadsKeep Sunshine always available; avoid simultaneous gaming and inference manually.
Fixed client targetUse 1920×1200@120 for the laptop and XREAL display path.
Existing user dataRun Steam and Sunshine as the inventory-defined primary user.
LAN-only useBind Sunshine to the host’s LAN address and publish only a LAN service endpoint.
Minimal custom codeUse the NixOS Sunshine and Steam gamescope modules.
CredentialsKeep Steam, Sunshine, and pairing state in the user’s home directory.

Architecture

flowchart LR
    subgraph Host["Headless NixOS host"]
        EDID["Generated EDID<br/>1920×1200 at 120 Hz"]
        Steam["gamescope<br/>Steam Big Picture"]
        Capture["Sunshine<br/>KMS capture"]
        Encode["RTX 4070 Ti<br/>Vulkan Video"]
        Audio["PipeWire audio"]
        Input["Virtual input<br/>uinput"]
    end

    subgraph Client["NixOS laptop"]
        Moonlight["Moonlight"]
        Controls["Keyboard, mouse, controller"]
        Display["Laptop display or XREAL glasses"]
        Speakers["Audio output"]
    end

    EDID --> Steam
    Steam --> Capture
    Capture --> Encode
    Encode -->|video over LAN| Moonlight
    Audio -->|Opus audio| Moonlight
    Moonlight --> Display
    Moonlight --> Speakers
    Controls --> Moonlight
    Moonlight -->|input over LAN| Input
    Input --> Steam

The laptop does not run the game. It decodes video, plays audio, and transports input. Game execution, Steam state, Proton prefixes, saves, and shader caches remain on the host.

Host design

Virtual display

A display must exist before gamescope and Sunshine have a framebuffer to use. SSH access does not create a graphical session.

A generated EDID declares one mode:

connector: DP-1
resolution: 1920 × 1200
refresh rate: 120 Hz
physical display: none

NixOS installs the EDID as firmware and adds two kernel parameters:

drm.edid_firmware=DP-1:edid/xreal120.bin
video=DP-1:1920x1200@120e
flowchart LR
    E["Generated EDID"] --> K["Kernel firmware loader"]
    K --> D["NVIDIA DRM driver"]
    D --> O["Connected DP-1 output"]

This part requires a reboot. After deployment, the kernel reported DP-1 as connected. DRM state showed an active 1920×1200 mode at 120 Hz, a 1920×1200 framebuffer, and an assigned cursor plane.

The DRM card number changed across boots, so operational checks identify DP-1 dynamically instead of assuming card0.

Graphical session

The host does not run KDE, GNOME, or a general-purpose desktop. Greetd logs the primary user into the NixOS steam-gamescope session.

flowchart LR
    G["greetd"] --> GS["gamescope"]
    GS --> S["Steam Big Picture"]
    S --> Game["Selected game"]

The deployed gamescope process uses:

gamescope --steam
  -W 1920 -H 1200
  -w 1920 -h 1200
  -r 120
  -O DP-1
  --generate-drm-mode cvt
  --force-composition
  --prefer-vk-device 10de:2782
  -- steam -tenfoot -pipewire-dmabuf

--steam enables gamescope’s Steam integration. --prefer-vk-device selects the RTX 4070 Ti instead of the AMD integrated GPU.

--force-composition was added after the first test because Sunshine KMS capture did not show the Steam overlay reliably. Gamescope can promote a game to direct scanout while composing the overlay separately. Forced composition produces one frame containing both. The setting is deployed; visual confirmation remains pending.

Capture and encoding

Sunshine runs as the primary user’s systemd user service and starts with the graphical session. A ConditionUser guard prevents the globally installed unit from also starting under root’s user manager.

Sunshine uses KMS capture. The service has the capability required to inspect the DRM framebuffer and is restricted to the host’s LAN address.

The stock nixpkgs Sunshine package was built without CUDA support. Forcing nvenc therefore failed with Cannot load libcuda.so.1. Sunshine then found a working Vulkan Video path on the same RTX 4070 Ti. The deployed configuration selects vulkan directly.

Startup verification found:

Found H.264 encoder: h264_vulkan
Found HEVC encoder: hevc_vulkan
Found AV1 encoder: av1_vulkan
Vulkan encode using GPU: NVIDIA GeForce RTX 4070 Ti

This is hardware encoding. It does not use the CPU software encoder.

Audio

PipeWire runs on the host with ALSA and PulseAudio compatibility. During a live stream, Sunshine created and selected a stereo sink monitor, then initialized Opus at 48 kHz and two channels.

flowchart LR
    Game["Game audio"] --> PipeWire["PipeWire sink monitor"]
    PipeWire --> Sunshine["Sunshine<br/>Opus encoding"]
    Sunshine --> Moonlight["Moonlight audio output"]

Input

Moonlight sends client input back to Sunshine. Sunshine creates host-side virtual input devices through /dev/uinput. The primary user belongs to the input, render, and uinput groups.

flowchart RL
    Device["Client input device"] --> Moonlight["Moonlight"]
    Moonlight --> Sunshine["Sunshine"]
    Sunshine --> UInput["Linux uinput"]
    UInput --> Game["Steam or game"]

The first deployment omitted uinput group membership. Sunshine reported a permission error. Adding the group fixed the host-side virtual-input boundary.

Client design

The official moonlight-qt package is installed through the laptop’s standalone Home Manager profile.

The client performs four jobs:

  1. Discover or connect to the Sunshine host.
  2. Decode the selected video codec in hardware.
  3. Play the streamed audio.
  4. Send local input events to the host.

The XREAL glasses remain a display attached to the laptop. They do not pair with Sunshine and do not decode the stream independently.

flowchart LR
    Host["Streaming host"] -->|encoded video and audio| Laptop["Moonlight on laptop"]
    Laptop --> Glasses["XREAL glasses"]
    Controls["Local controls"] --> Laptop
    Laptop -->|input events| Host

Administration path

The administration path is separate from the game stream.

Platform declares sunshine.kuhree.com as a LAN endpoint on the streaming host. CoreDNS derives its A record from Platform host inventory. The existing nginx instance terminates TLS and proxies the Web UI to Sunshine’s native HTTPS listener on port 47990.

flowchart LR
    Browser["LAN browser"] --> DNS["CoreDNS"]
    DNS --> Address["Streaming host LAN address"]
    Address --> Nginx["nginx on port 443"]
    Nginx --> UI["Sunshine UI on port 47990"]

Sunshine explicitly trusts both the native LAN URL and https://sunshine.kuhree.com as CSRF origins. The initial setup failed until the LAN origin was declared.

Moonlight does not send gameplay through nginx. Video, audio, and input use Sunshine’s native LAN ports directly.

Deployment and verification

The implementation was merged through Platform issue #1404 and its associated pull requests.

Configuration checks covered:

  • Sunshine enabled only on the intended host.
  • LAN address binding.
  • The Vulkan encoder selection.
  • Primary-user uinput access.
  • Primary-user systemd service ownership.
  • The LAN endpoint and nginx upstream.
  • Gamescope forced composition.
  • Full NixOS evaluation for the streaming host.
  • Full NixOS evaluation for both authoritative DNS hosts.
  • Moonlight presence in the evaluated laptop Home Manager package set.

Runtime verification covered:

  • The booted NixOS generation.
  • Kernel EDID parameters.
  • Connected DP-1 state.
  • Active DRM mode and framebuffer size.
  • A live gamescope process on the RTX 4070 Ti.
  • A single Sunshine process owned by the primary user.
  • H.264, HEVC, and AV1 Vulkan encoders.
  • Sunshine TCP reachability from the laptop.
  • Valid TLS and DNS for sunshine.kuhree.com.
  • PipeWire audio capture during a stream.
  • A successful Moonlight pairing.
  • Steam Big Picture visible through Moonlight.
  • Several games launched during an initial five-to-ten-minute session.

Problems found during deployment

ProblemCauseCorrection
Headless connector remained disconnectedNo EDID or forced mode existed at bootGenerate EDID, install it as firmware, and force DP-1 at boot
NVENC initialization failedStock nixpkgs package lacked CUDA supportSelect the verified Vulkan Video encoder
Controller emulation reported permission errorsPrimary user lacked the uinput groupAdd uinput to the primary user’s groups
Sunshine started twiceGlobal user unit also started under root’s user managerAdd ConditionUser for the primary user
Web UI rejected credential setupLAN URL was absent from allowed CSRF originsDeclare native and friendly LAN origins
Friendly URL returned 502Wrong Sunshine instance held the native streaming portsStop the duplicate instance and enforce service ownership
Steam overlay was absent from the captured gameDirect scanout separated the game plane from composed UIEnable gamescope forced composition

Observed result

The initial live session produced these observations:

  • Steam Big Picture was available immediately after connecting.
  • Several games launched and remained responsive.
  • The operator barely perceived input lag during the short test.
  • Video and audio were stable enough for normal play.
  • The XREAL glasses worked well as the laptop’s private display.
  • Steam overlay capture was the only reported graphical defect.

These observations are qualitative. No frame-time, encode-latency, decode-latency, packet-loss, or end-to-end input measurements have been collected.

Desktop and non-Steam software

The host has a real graphical session, but it does not have a conventional desktop environment. Gamescope owns the display and Steam owns the interface.

The current session supports:

  • Native Steam games.
  • Proton compatibility selection per game.
  • Custom Proton versions installed on the host.
  • Non-Steam executables added to the Steam library.
  • Host-side editing of Steam shortcuts, game files, and Proton prefixes.

Desktop-oriented tools remain awkward because the session lacks a general file manager, terminal window, and graphical file picker. Steam ROM Manager is an example: it must run on the host because it edits the host’s Steam library, but its configuration interface does not fit the current console-style session well.

A possible follow-up is a separate Sunshine application named Desktop. It would start a lightweight maintenance session while preserving Steam as the default experience. This is not implemented.

Current limitations and follow-up tests

  • Confirm that forced composition makes the Steam overlay visible in Moonlight.
  • Measure performance during longer sessions and more demanding games.
  • Verify controller behavior across several controller types.
  • Record Moonlight’s decode, network, and render latency statistics.
  • Evaluate whether full composition changes latency, frame pacing, or power use.
  • Decide whether a host-maintenance desktop is worth its extra session complexity.
  • Continue avoiding simultaneous gaming and local AI inference unless measured behavior supports it.

Implementation locations

Platform owns the declarative implementation:

services/sunshine/default.nix
services/sunshine/module.nix
services/sunshine/tests.nix
services/sunshine/README.md
hosts/srv-bldr-nv/default.nix
hosts/nixpad/default.nix
modules/home/desktop/moonlight.nix

The monitorless-display experiment and operator observations remain in the unpublished project record at Projects/monitorless-display-experiment.md.

Sources

Primary documentation

Relevant upstream issues

Local implementation record