Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Headless Sway + Sunshine Game Streaming

DISCLAIMER: This is provided as-is with absolutely no warranty or guarantee. Use at your own risk. This may break your system, eat your configs, set your GPU on fire, or summon an elder god. The author(s) take no responsibility for anything that happens as a result of using this software. You have been warned.

Architecture Diagram

Stream games from a headless Sway session using Sunshine and Moonlight, without disrupting your main desktop session.

This setup runs a separate headless Wayland compositor (Sway) dedicated to game streaming. Your primary desktop (GNOME, KDE, etc.) continues running normally — audio, display, and input are fully isolated.

Why headless?

  • Stream games without taking over your main display
  • Dynamic resolution matching — the headless output adapts to your Moonlight client
  • Game audio routes only to the stream, host audio is unaffected
  • Works with NVIDIA GPUs using NVENC hardware encoding
  • Minimal overhead when idle (~420MB RAM, negligible CPU)

Requirements

  • OS: Linux with systemd user services (tested on CachyOS/Arch and Ubuntu 25.10)
  • GPU: NVIDIA with proprietary drivers (for NVENC)
  • Packages: sway, swaybg, pipewire, wireplumber, xdg-desktop-portal-wlr
  • Sunshine: LizardByte Sunshine v2026.226+ (deb for Ubuntu, sunshine AUR package for Arch)
  • Client: Moonlight on any device

Quick install

git clone https://github.com/daaaaan/sunshine-headless-sway.git
cd sunshine-headless-sway
./install.sh

The install script will:

  • Install missing dependencies (sway, swaybg, xdg-desktop-portal-wlr) via pacman or apt
  • Auto-detect your desktop environment (GNOME or KDE) for input isolation
  • Auto-detect your Sunshine installation path
  • Detect the correct Wayland display number and user ID
  • Template all config files with your system's paths
  • Install and enable the systemd services
  • Preserve any existing Sunshine config you already have

Manual setup

If you prefer to install manually, see the manual setup guide below.

Architecture

┌─────────────────────────────────────────────────────┐
│  Main Desktop (GNOME/KDE)          wayland-0        │
│  └─ Normal apps, browser, etc.                      │
│  └─ Audio → your speakers/headphones                │
├─────────────────────────────────────────────────────┤
│  Headless Sway                     wayland-1        │
│  └─ Games launched via Sunshine                     │
│  └─ Audio → sink-sunshine-stereo → Moonlight stream │
│  └─ Video → wlr-screencopy → NVENC → Moonlight     │
└─────────────────────────────────────────────────────┘

Two systemd user services manage the stack:

  1. sway-sunshine.service — runs a headless Sway compositor with no physical display
  2. sunshine-headless.service — runs Sunshine pointed at the headless Sway session

Adding games

Edit ~/.config/sunshine/apps.json to add Steam games. Find the app ID on SteamDB and add an entry:

{
  "name": "Game Name",
  "detached": [
    "swaymsg exec 'steam steam://rungameid/APP_ID'"
  ],
  "prep-cmd": [
    {
      "do": "~/.config/sway-sunshine/set-resolution.sh",
      "undo": ""
    }
  ]
}

Restart Sunshine after editing: systemctl --user restart sunshine-headless.service

How it works

NVIDIA + headless Sway renderer

The Sway service uses WLR_RENDERER=gles2 by default. Older wlroots versions have DRM format modifier incompatibilities with NVIDIA's headless backend when using the Vulkan renderer. This may be resolved in wlroots 0.18+, but gles2 remains the safe default.

Audio isolation

Game audio is routed exclusively to the Moonlight stream without touching your host audio:

  • A persistent PipeWire null sink (sink-sunshine-stereo) is created via config drop-in — it always exists, even when Moonlight is disconnected or backgrounded
  • PULSE_SINK=sink-sunshine-stereo is set in the Sway service environment, so apps launched in the headless session output to this sink
  • audio_sink = sink-sunshine-stereo in sunshine.conf tells Sunshine to capture from that sink
  • restore-default-sink.sh runs as a prep command to prevent Sunshine from hijacking your host's default audio sink — it detects the change and restores it within seconds
  • When Moonlight is backgrounded, game audio stays in the persistent null sink (silent) instead of reverting to your host speakers
  • Your main desktop audio continues through your normal output device

Dynamic resolution

When a Moonlight client connects, Sunshine runs set-resolution.sh as a prep command. This uses SUNSHINE_CLIENT_WIDTH, SUNSHINE_CLIENT_HEIGHT, and SUNSHINE_CLIENT_FPS environment variables to resize the headless output to match the client exactly. On disconnect, reset-resolution.sh reverts to 1080p.

Wayland display numbering

The headless Sway session typically gets wayland-1 (assuming your main desktop is wayland-0). The install script detects this automatically. To check manually:

ls /run/user/$(id -u)/wayland-*

IPC socket

Sway creates its IPC socket at the path specified by SWAYSOCK (/run/user/<uid>/sway-sunshine.sock). The service cleans up stale sockets on restart via ExecStartPre. All swaymsg commands in the apps and scripts reference this socket explicitly.

Troubleshooting

Blank display / error code -1

  • Check ~/.config/sunshine/sunshine.log for Frame capture failed
  • Ensure WLR_RENDERER=gles2 is set in sway-sunshine.service (not vulkan)
  • Verify Sunshine is connecting to the correct Wayland display

Input isolation

Input is fully isolated between your desktop and the streaming session. Sunshine creates virtual input devices (vendor 0xBEEF, product 0xDEAD) that must be hidden from your host desktop while remaining accessible to the headless Sway session.

The install script auto-detects your desktop environment and installs the appropriate udev rule. Both approaches install to /etc/udev/rules.d/85-sunshine-input-isolation.rules.

GNOME (Mutter)

Uses the mutter-device-ignore property — a targeted GNOME-specific mechanism that tells Mutter to skip specific devices while leaving them visible to other consumers:

ACTION=="add|change", SUBSYSTEM=="input", ATTRS{id/vendor}=="beef", ATTRS{id/product}=="dead", ENV{mutter-device-ignore}="1"

KDE (KWin)

KWin has no equivalent to mutter-device-ignore. Instead, the udev rule strips ID_INPUT tags so KWin never discovers the devices as inputs:

ACTION=="add|change", SUBSYSTEM=="input", ATTRS{id/vendor}=="beef", ATTRS{id/product}=="dead", ENV{ID_INPUT}="", ENV{ID_INPUT_KEYBOARD}="", ENV{ID_INPUT_MOUSE}="", ENV{ID_INPUT_TOUCHPAD}=""

Note: The KDE method also works for GNOME and other compositors, but is more aggressive — it hides the devices from all desktop tools (e.g., Settings panels). The mutter-device-ignore method is preferred for GNOME since it's more targeted.

How isolation works

  • The udev rule prevents the host compositor from claiming Sunshine's virtual inputs (method varies by DE, see above)
  • The headless Sway uses WLR_BACKENDS=headless,libinput with LIBSEAT_BACKEND=noop and runs under the input group via sg to access input devices without a logind seat
  • The Sway config disables all physical host devices and only enables Sunshine's passthrough devices, so your physical keyboard and mouse don't leak into the streaming session
  • Gamepads are read directly by Steam via evdev, bypassing the compositor entirely

Switching DE method manually

If you switch desktop environments, reinstall the appropriate rule:

# For GNOME
sudo cp udev/85-sunshine-input-isolation-gnome.rules /etc/udev/rules.d/85-sunshine-input-isolation.rules

# For KDE
sudo cp udev/85-sunshine-input-isolation-kde.rules /etc/udev/rules.d/85-sunshine-input-isolation.rules

# Reload
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=input

No input / can't control games

  • The xdg-desktop-portal-wlr package must be installed
  • Check that /dev/uinput is accessible to your user (Sunshine's udev rules should handle this)
  • Verify the libinput backend is active: SWAYSOCK=/run/user/$(id -u)/sway-sunshine.sock swaymsg -t get_inputs should show Sunshine passthrough devices with events: enabled

Games don't launch

  • Verify the Sway IPC socket exists: ls -la /run/user/$(id -u)/sway-sunshine.sock
  • Test manually: SWAYSOCK=/run/user/$(id -u)/sway-sunshine.sock swaymsg -t get_tree
  • If the socket is stale after a restart, the ExecStartPre cleanup in the service handles it

Audio bleeds to host

  • Verify audio_sink = sink-sunshine-stereo is in ~/.config/sunshine/sunshine.conf
  • Check PULSE_SINK=sink-sunshine-stereo is in sway-sunshine.service
  • Verify the restore-default-sink.sh prep command is in apps.json — without it, Sunshine sets sink-sunshine-stereo as the system-wide default, routing all host audio into the stream
  • Confirm your default sink after connecting: wpctl status | grep '\*'

UPnP port mapping failures

These errors (Failed to map UDP/TCP) are harmless if you're connecting over LAN or a VPN like Tailscale. They only matter for WAN connections through your router.

Manual setup guide

If you'd rather not use the install script:

1. Install dependencies

Arch / CachyOS:

sudo pacman -S sway swaybg xdg-desktop-portal-wlr

Ubuntu / Debian:

sudo apt install sway swaybg xdg-desktop-portal-wlr

2. Copy config files

# Sway config and scripts
mkdir -p ~/.config/sway-sunshine
cp sway-sunshine/config ~/.config/sway-sunshine/
cp sway-sunshine/set-resolution.sh ~/.config/sway-sunshine/
cp sway-sunshine/reset-resolution.sh ~/.config/sway-sunshine/
chmod +x ~/.config/sway-sunshine/*.sh

# Sunshine config
cp sunshine/sunshine.conf ~/.config/sunshine/sunshine.conf
cp sunshine/apps.json ~/.config/sunshine/apps.json

# PipeWire persistent audio sink
mkdir -p ~/.config/pipewire/pipewire.conf.d
cp pipewire/sunshine-null-sink.conf ~/.config/pipewire/pipewire.conf.d/
systemctl --user restart pipewire.service

# Systemd services
mkdir -p ~/.config/systemd/user
cp systemd/sway-sunshine.service ~/.config/systemd/user/
cp systemd/sunshine-headless.service ~/.config/systemd/user/

3. Edit paths

Update the following in the copied files to match your system:

  • sunshine-headless.service: set ExecStart to your Sunshine path, WAYLAND_DISPLAY to your headless display
  • sway-sunshine.service: update /run/user/1000/ to /run/user/$(id -u)/ if your UID isn't 1000
  • apps.json: update /home/YOUR_USER/ to your home directory
  • set-resolution.sh / reset-resolution.sh: update the socket path if your UID isn't 1000

4. Enable and start

systemctl --user daemon-reload
systemctl --user enable --now sway-sunshine.service
systemctl --user enable --now sunshine-headless.service

5. Pair with Moonlight

Open Moonlight, find your host, and pair using the PIN at https://YOUR_HOST:47990.

File structure

/etc/udev/rules.d/
└── 85-sunshine-input-isolation.rules  # Installed by install.sh (GNOME or KDE variant)

~/.config/
├── pipewire/pipewire.conf.d/
│   └── sunshine-null-sink.conf # Persistent audio sink (survives disconnect)
├── sway-sunshine/
│   ├── config                  # Headless Sway compositor config (input isolation)
│   ├── set-resolution.sh       # Dynamic resolution on connect
│   ├── reset-resolution.sh     # Reset resolution on disconnect
│   └── restore-default-sink.sh # Prevents Sunshine from hijacking host audio
├── sunshine/
│   ├── sunshine.conf           # Sunshine server config
│   └── apps.json               # Game/app entries for Moonlight
└── systemd/user/
    ├── sway-sunshine.service   # Headless Sway compositor service
    └── sunshine-headless.service # Sunshine streaming service

License

MIT — do whatever you want with it, but don't blame us if something breaks.

About

Stream games from a headless Sway session using Sunshine + Moonlight without disrupting your main desktop

Resources

Stars

60 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages