This note shows how to run Steam on a monitorless NixOS host and stream it to a Moonlight client. The host boots directly into Steam Big Picture under gamescope. Sunshine captures the rendered display, encodes it on the host GPU, and sends video and audio over the LAN. Moonlight displays the stream and sends input back to the host.

Tested configuration

The deployed system uses these values. The design does not depend on these exact models or dimensions.

ComponentTested value
Host OSNixOS
Host GPUNVIDIA GeForce RTX 4070 Ti
Host displayNo physical monitor
Virtual mode1920×1200 at 120 Hz
Sessiongreetd → gamescope → Steam Big Picture
Stream serverSunshine
Stream clientMoonlight Qt on NixOS
Video encoderVulkan Video
AudioPipeWire and Opus
InputLinux uinput
Network scopeLAN only

How the system fits together

flowchart LR
    subgraph Host["Headless game host"]
        EDID["Generated EDID"]
        Session["gamescope and Steam"]
        Sunshine["Sunshine capture"]
        GPU["GPU video encoder"]
        Audio["PipeWire"]
        UInput["uinput"]
    end

    subgraph Client["Client"]
        Moonlight["Moonlight"]
        Display["Display"]
        Controls["Keyboard, mouse, controller"]
        Speakers["Audio output"]
    end

    EDID --> Session
    Session --> Sunshine
    Sunshine --> GPU
    GPU -->|video| Moonlight
    Audio -->|audio| Moonlight
    Moonlight --> Display
    Moonlight --> Speakers
    Controls --> Moonlight
    Moonlight -->|input| UInput
    UInput --> Session

The host runs the game. The client decodes video, plays audio, and sends input. Steam state, Proton prefixes, saves, and game files stay on the host.

Four parts make the headless session work:

  1. A generated EDID gives the kernel a display mode without a monitor.
  2. Greetd starts gamescope and Steam Big Picture for the game user.
  3. Sunshine captures the DRM framebuffer and encodes it on the GPU.
  4. Moonlight decodes the stream and returns input to Sunshine.

Build the host configuration

The examples below are generalized from the deployed NixOS module. Adjust the connector, mode, user, LAN address, and encoder for the target host.

Generate a virtual display

Gamescope needs a DRM output. Sunshine needs a framebuffer to capture. A generated EDID supplies both without an HDMI dummy plug.

{ config, pkgs, ... }:
 
let
  user = "game";
  connector = "DP-1";
  width = 1920;
  height = 1200;
  refresh = 120;
  mode = "${toString width}x${toString height}";
  edidName = "headless120";
 
  edid = pkgs.edid-generator.overrideAttrs {
    clean = true;
    modelines = ''
      Modeline "${edidName}" 317.00 1920 1968 2000 2080 1200 1203 1209 1271 +hsync -vsync
    '';
  };
in
{
  hardware.nvidia.modesetting.enable = true;
  hardware.firmware = [ edid ];
 
  boot.kernelParams = [
    "drm.edid_firmware=${connector}:edid/${edidName}.bin"
    "video=${connector}:${mode}@${toString refresh}e"
  ];
}

The modeline and the video= parameter must describe the same mode. Generate another modeline when changing the resolution or refresh rate. The Linux kernel EDID documentation describes firmware-based EDID loading.

This change takes effect after a reboot. Check the connector after boot:

for connector in /sys/class/drm/card*-DP-1; do
  printf '%s: ' "$connector"
  cat "$connector/status"
done

A working connector reports connected. Check the active modes with:

cat /sys/class/drm/card*-DP-1/modes

Do not depend on a fixed DRM card number. Linux can assign a different cardN number after a reboot.

Start Steam under gamescope

The NixOS Steam module can create a gamescope session. Set the nested and output dimensions to the virtual display mode.

programs.steam = {
  enable = true;
  gamescopeSession = {
    enable = true;
    args = [
      "-W" (toString width)
      "-H" (toString height)
      "-w" (toString width)
      "-h" (toString height)
      "-r" (toString refresh)
      "-O" connector
      "--generate-drm-mode" "cvt"
      "--force-composition"
    ];
  };
};

--force-composition keeps the game and Steam overlay in the same composed frame. Without it, direct scanout can leave the overlay outside the frame captured by Sunshine.

On a host with more than one GPU, add --prefer-vk-device with the Vulkan PCI vendor and device ID for the game GPU:

programs.steam.gamescopeSession.args = [
  "--prefer-vk-device" "10de:2782"
];

Find the device ID with:

lspci -nn | grep -Ei 'vga|3d|display'

Log the game user into the session

Greetd can start the generated steam-gamescope command directly:

services.greetd = {
  enable = true;
  settings.default_session = {
    user = user;
    command = "/run/current-system/sw/bin/steam-gamescope";
  };
};

This is a graphical session, not a desktop environment. Gamescope owns the display, and Steam provides the interface.

Configure audio and input

Sunshine creates virtual input devices through /dev/uinput. Add the game user to the required groups. PipeWire supplies the audio stream.

security.rtkit.enable = true;
 
users.users.${user}.extraGroups = [
  "input"
  "render"
  "uinput"
];
 
services.pipewire = {
  enable = true;
  alsa.enable = true;
  pulse.enable = true;
};

The user must start a new login session after a group change. A reboot is the least ambiguous way to activate both the EDID and the new groups.

Configure Sunshine

The NixOS Sunshine module supplies the service, firewall rules, KMS capability, and application list.

services.sunshine = {
  enable = true;
  capSysAdmin = true;
  openFirewall = true;
 
  settings = {
    bind_address = "192.168.1.50";
    encoder = "vulkan";
    sunshine_name = "game-host";
  };
 
  applications.apps = [
    {
      name = "Steam";
      "auto-detach" = "true";
    }
  ];
};

Replace 192.168.1.50 with the host’s LAN address. Do not bind the service to a public interface unless remote access has been designed and secured separately.

The tested nixpkgs package could not initialize NVENC because that build lacked CUDA support. Sunshine detected a working Vulkan Video encoder on the same GPU, so the deployed configuration selects vulkan. Check the service log rather than assuming that an encoder name means hardware encoding:

journalctl --user -u sunshine.service -b \
  | grep -E 'Found .* encoder|Vulkan encode using GPU|Error'

A working Vulkan setup reports codec encoders and names the GPU used for encoding.

Keep the user service under the game user

NixOS installs Sunshine as a global user service. A root user manager can start another copy unless the unit limits its owner. The duplicate process can occupy Sunshine’s ports and leave the intended user instance unhealthy.

systemd.user.services.sunshine.unitConfig.ConditionUser = user;
systemd.user.targets.graphical-session.wantedBy = [ "default.target" ];

Verify ownership after activation:

pgrep -a sunshine
ps -o user,pid,args -C sunshine

The process list should contain one Sunshine process owned by the game user.

Install Moonlight on the client

The client needs only Moonlight and working hardware video decoding. A Home Manager module can install the official Qt client:

{ pkgs, ... }:
 
{
  home.packages = [ pkgs.moonlight-qt ];
}

Open Moonlight, add the host, and start pairing. Enter Moonlight’s PIN in the Sunshine Web UI. The PIN is not entered through an SSH session on the host.

sequenceDiagram
    participant C as Moonlight client
    participant W as Sunshine Web UI
    participant S as Sunshine service
    C->>S: Request pairing and show PIN
    W->>S: Submit PIN
    S-->>C: Trust client certificate
    C->>S: Start Steam stream

Keep administration separate from streaming

Sunshine’s Web UI uses HTTPS on port 47990. Moonlight uses Sunshine’s native stream ports. A reverse proxy can give the Web UI a stable LAN hostname, but the proxy should not carry the game stream.

flowchart TB
    Browser["Browser"] -->|HTTPS management| Proxy["LAN reverse proxy"]
    Proxy -->|HTTPS 47990| WebUI["Sunshine Web UI"]
    Moonlight["Moonlight client"] -->|native stream ports| Sunshine["Sunshine service"]

A NixOS nginx location can proxy the management interface:

services.nginx.virtualHosts."sunshine.example.internal" = {
  forceSSL = true;
  enableACME = true;
 
  locations."/" = {
    proxyPass = "https://192.168.1.50:47990";
    proxyWebsockets = true;
  };
};

Allow the proxy URL in Sunshine’s CSRF configuration:

services.sunshine.settings.csrf_allowed_origins =
  "https://192.168.1.50:47990,https://sunshine.example.internal";

Keep the DNS record and the reverse proxy private to the LAN. A friendly management hostname does not require a public game-streaming endpoint.

Complete host module

The core pieces can live in one NixOS module:

{ config, pkgs, ... }:
 
let
  user = "game";
  connector = "DP-1";
  width = 1920;
  height = 1200;
  refresh = 120;
  mode = "${toString width}x${toString height}";
  edidName = "headless120";
  edid = pkgs.edid-generator.overrideAttrs {
    clean = true;
    modelines = ''
      Modeline "${edidName}" 317.00 1920 1968 2000 2080 1200 1203 1209 1271 +hsync -vsync
    '';
  };
in
{
  hardware.nvidia.modesetting.enable = true;
  hardware.firmware = [ edid ];
  boot.kernelParams = [
    "drm.edid_firmware=${connector}:edid/${edidName}.bin"
    "video=${connector}:${mode}@${toString refresh}e"
  ];
 
  security.rtkit.enable = true;
  users.users.${user}.extraGroups = [ "input" "render" "uinput" ];
 
  programs.steam = {
    enable = true;
    gamescopeSession = {
      enable = true;
      args = [
        "-W" (toString width)
        "-H" (toString height)
        "-w" (toString width)
        "-h" (toString height)
        "-r" (toString refresh)
        "-O" connector
        "--generate-drm-mode" "cvt"
        "--force-composition"
      ];
    };
  };
 
  services = {
    pipewire = {
      enable = true;
      alsa.enable = true;
      pulse.enable = true;
    };
 
    greetd = {
      enable = true;
      settings.default_session = {
        inherit user;
        command = "/run/current-system/sw/bin/steam-gamescope";
      };
    };
 
    sunshine = {
      enable = true;
      capSysAdmin = true;
      openFirewall = true;
      settings = {
        bind_address = "192.168.1.50";
        encoder = "vulkan";
        sunshine_name = "game-host";
      };
      applications.apps = [
        {
          name = "Steam";
          "auto-detach" = "true";
        }
      ];
    };
  };
 
  systemd.user.services.sunshine.unitConfig.ConditionUser = user;
  systemd.user.targets.graphical-session.wantedBy = [ "default.target" ];
}

Verify each layer

Check the stack from the kernel outward. A working Web UI does not prove that the display, encoder, audio, or input path works.

1. Check the virtual display

for connector in /sys/class/drm/card*-DP-1; do
  printf '%s: ' "$connector"
  cat "$connector/status"
  cat "$connector/modes"
done

Expected result:

connected
1920x1200

2. Check the graphical session

systemctl status greetd.service
pgrep -a gamescope
pgrep -a steam

The gamescope command line should contain the configured dimensions, refresh rate, connector, and composition flags.

3. Check Sunshine and the encoder

systemctl --user status sunshine.service
journalctl --user -u sunshine.service -b

Confirm all of these facts in the log:

  • Sunshine selected the intended GPU.
  • At least one hardware video encoder initialized.
  • KMS capture initialized without permission errors.
  • /dev/uinput opened without permission errors.
  • Sunshine bound to the intended LAN address.

4. Check the network from the client

nc -vz 192.168.1.50 47984
curl -kI https://192.168.1.50:47990

Port 47984 checks one native Sunshine service port. Port 47990 checks the Web UI. The Moonlight setup guide lists the GameStream ports used by Moonlight.

5. Test the full session

Pair Moonlight, start Steam, and launch a game. Verify video, audio, keyboard input, pointer input, controller input, and the Steam overlay. The deployed system passed the game-streaming path during a short test across several games. The operator reported barely perceptible input lag. That report is qualitative, not a latency measurement.

Problems encountered in the deployed system

SymptomCauseFix
The connector stayed disconnectedThe kernel had no EDID or forced modeInstall a generated EDID and force the connector at boot
Sunshine could not start NVENCThe packaged build lacked the required CUDA supportUse the verified Vulkan Video encoder
Controller creation failedThe game user could not open /dev/uinputAdd the user to the uinput group and start a new login session
Two Sunshine processes startedRoot’s user manager also started the global user unitAdd ConditionUser and stop the duplicate instance
The Web UI rejected requests behind a proxyThe proxy origin was missing from csrf_allowed_originsAdd the exact HTTPS origin
The Steam overlay was absentDirect scanout separated the game from the composed overlayAdd --force-composition to gamescope

What this session can run

The host has a graphical session but no conventional desktop. Steam can install native games, select Proton versions, set launch options, and add non-Steam executables. Games and compatibility data remain on the host.

Graphical maintenance tools can be awkward without a file manager, terminal window, or desktop shell. If those tools become necessary, add a separate Sunshine application that starts a lightweight desktop session. Keep Steam as the default application so the normal connection still opens directly into Big Picture.

Sources

Primary documentation

Relevant upstream issues

Deployed implementation