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.
| Component | Tested value |
|---|---|
| Host OS | NixOS |
| Host GPU | NVIDIA GeForce RTX 4070 Ti |
| Host display | No physical monitor |
| Virtual mode | 1920×1200 at 120 Hz |
| Session | greetd → gamescope → Steam Big Picture |
| Stream server | Sunshine |
| Stream client | Moonlight Qt on NixOS |
| Video encoder | Vulkan Video |
| Audio | PipeWire and Opus |
| Input | Linux uinput |
| Network scope | LAN 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:
- A generated EDID gives the kernel a display mode without a monitor.
- Greetd starts gamescope and Steam Big Picture for the game user.
- Sunshine captures the DRM framebuffer and encodes it on the GPU.
- 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"
doneA working connector reports connected. Check the active modes with:
cat /sys/class/drm/card*-DP-1/modesDo 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 sunshineThe 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"
doneExpected result:
connected
1920x12002. Check the graphical session
systemctl status greetd.service
pgrep -a gamescope
pgrep -a steamThe 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 -bConfirm 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/uinputopened 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:47990Port 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
| Symptom | Cause | Fix |
|---|---|---|
| The connector stayed disconnected | The kernel had no EDID or forced mode | Install a generated EDID and force the connector at boot |
| Sunshine could not start NVENC | The packaged build lacked the required CUDA support | Use the verified Vulkan Video encoder |
| Controller creation failed | The game user could not open /dev/uinput | Add the user to the uinput group and start a new login session |
| Two Sunshine processes started | Root’s user manager also started the global user unit | Add ConditionUser and stop the duplicate instance |
| The Web UI rejected requests behind a proxy | The proxy origin was missing from csrf_allowed_origins | Add the exact HTTPS origin |
| The Steam overlay was absent | Direct scanout separated the game from the composed overlay | Add --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
- Linux kernel EDID firmware documentation
- gamescope source and usage
- Sunshine configuration reference
- Moonlight setup guide for Sunshine
- NixOS Sunshine module
- NixOS Steam module
Relevant upstream issues
- nixpkgs #559144: Sunshine NVENC is compiled out by default
- Sunshine #3860: Steam overlay is absent from gamescope streams
- gamescope #1418: Steam overlay does not load under gamescope
- gamescope #835: gamescope breaks the Steam overlay