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.
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.
- 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)
- 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,
sunshineAUR package for Arch) - Client: Moonlight on any device
git clone https://github.com/daaaaan/sunshine-headless-sway.git
cd sunshine-headless-sway
./install.shThe 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
If you prefer to install manually, see the manual setup guide below.
┌─────────────────────────────────────────────────────┐
│ 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:
sway-sunshine.service— runs a headless Sway compositor with no physical displaysunshine-headless.service— runs Sunshine pointed at the headless Sway session
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
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.
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-stereois set in the Sway service environment, so apps launched in the headless session output to this sinkaudio_sink = sink-sunshine-stereoinsunshine.conftells Sunshine to capture from that sinkrestore-default-sink.shruns 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
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.
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-*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.
- Check
~/.config/sunshine/sunshine.logforFrame capture failed - Ensure
WLR_RENDERER=gles2is set insway-sunshine.service(notvulkan) - Verify Sunshine is connecting to the correct Wayland display
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.
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"
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-ignoremethod is preferred for GNOME since it's more targeted.
- 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,libinputwithLIBSEAT_BACKEND=noopand runs under theinputgroup viasgto 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
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- The
xdg-desktop-portal-wlrpackage must be installed - Check that
/dev/uinputis 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_inputsshould show Sunshine passthrough devices withevents: enabled
- 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
ExecStartPrecleanup in the service handles it
- Verify
audio_sink = sink-sunshine-stereois in~/.config/sunshine/sunshine.conf - Check
PULSE_SINK=sink-sunshine-stereois insway-sunshine.service - Verify the
restore-default-sink.shprep command is inapps.json— without it, Sunshine setssink-sunshine-stereoas the system-wide default, routing all host audio into the stream - Confirm your default sink after connecting:
wpctl status | grep '\*'
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.
If you'd rather not use the install script:
Arch / CachyOS:
sudo pacman -S sway swaybg xdg-desktop-portal-wlrUbuntu / Debian:
sudo apt install sway swaybg xdg-desktop-portal-wlr# 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/Update the following in the copied files to match your system:
sunshine-headless.service: setExecStartto your Sunshine path,WAYLAND_DISPLAYto your headless displaysway-sunshine.service: update/run/user/1000/to/run/user/$(id -u)/if your UID isn't 1000apps.json: update/home/YOUR_USER/to your home directoryset-resolution.sh/reset-resolution.sh: update the socket path if your UID isn't 1000
systemctl --user daemon-reload
systemctl --user enable --now sway-sunshine.service
systemctl --user enable --now sunshine-headless.serviceOpen Moonlight, find your host, and pair using the PIN at https://YOUR_HOST:47990.
/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
MIT — do whatever you want with it, but don't blame us if something breaks.