Overview
The most efficient way to capture and encode the video output of a NVIDIA graphics card is by using NVIDIA Framebuffer Capture (nvFBC) API and the NVIDIA Encoder (nvENC) SDK. As of this writing, the nvFBC technology only supports the X11 display server. The latest versions of popular desktop environments such as GNOME, KDE Plasma, Sway, and Hyprland do not support X11, favoring the newer Wayland display server.
To take advantage of nvFBC, we propose to create a LXC container with GPU passthrough, where we install X11 and the i3 tiling window manager, which we consider to be simple and lightweight. This way the host can be any Linux distro and its current desktop environment does not need to be replaced, downgraded, or even installed.
We show how to install LXD on an machine with Ubuntu Server, which comes pre-installed with Snap. The choice of Ubuntu is deliberate as it offers the stability we need for a host, and allows us to use the Hardware Enablement (HWE) metapackage to keep up with newer versions of the Linux kernel, which is shared by the containers. Using LXD, we setup a LXC container based on Arch Linux, which has a rolling release schedule that allows us to be on the bleeding edge of software without the need to use auxiliary package managers such as Flatpak, Homebrew, or Snap.
To stream a virtual screen on the container, we use the Sunshine project which supports nvFBC and NVENC out of the box.
Create and set up a container
Assuming Snap is installed, we install LXD as follows:
sudo snap install lxd
To initialize it you can run the following command and follow the prompts:
lxd init
Alternatively, you can load the following configuration:
cat <<EOF | lxd init --preseed
config: {}
networks: []
storage_pools:
- config:
source: /var/snap/lxd/common/lxd/storage-pools/default
description: ""
name: default
driver: dir
storage_volumes: []
profiles:
- config: {}
description: Default LXD profile
devices:
eth0:
name: eth0
nictype: macvlan
parent: enp4s0
type: nic
root:
path: /
pool: default
type: disk
name: default
projects:
- config:
features.images: "true"
features.networks: "true"
features.networks.zones: "true"
features.profiles: "true"
features.storage.buckets: "true"
features.storage.volumes: "true"
description: Default LXD project
name: default
storage: ""
network: ""
EOF
Here we instruct containers to create a virtual network card with its own MAC and IP addresses. The container can then be accessed remotely without accessing the host. You will probably want or need to change the parent network device (enp4s0 in this example). We also create a basic directory-based storage pool for containers.
Next, we create a privileged container with nesting, enforce the Linux kernel to load some virtual input modules on the host, enable GPU passthrough, and finally mount a virtual drive on the home directory of $USER in the container using a directory on the home folder of the current user on the host:
lxc launch images:archlinux "${CONTAINER_NAME}"
lxc config set "${CONTAINER_NAME}" security.privileged=true
lxc config set "${CONTAINER_NAME}" security.nesting=true
lxc config set "${CONTAINER_NAME}" linux.kernel_modules=uinput,uhid
lxc config device add "${CONTAINER_NAME}" gpu0 gpu
mkdir "${HOME}/.${CONTAINER_NAME}"
lxc config device add "${CONTAINER_NAME}" home disk source="${HOME}/.${CONTAINER_NAME}" path="/home/${USER}"
Execute lxc config edit "${CONTAINER_NAME}" and add the following to the configuration:
raw.lxc: |-
lxc.tty.max = 6
lxc.mount.auto = sys:rw
lxc.mount.entry = /dev/uinput dev/uinput none bind,optional,create=file
lxc.cgroup2.devices.allow = c 10:223 rwm
lxc.mount.entry = /dev/uhid dev/uhid none bind,optional,create=file
lxc.cgroup2.devices.allow = c 10:239 rwm
lxc.mount.entry = /dev/input dev/input none bind,optional,create=dir
lxc.cgroup2.devices.allow = c 13:* rwm
lxc.mount.entry = /dev/tty0 dev/tty0 none bind,optional,create=file
lxc.cgroup2.devices.allow = c 4:0 rwm
lxc.mount.entry = /dev/tty8 dev/tty8 none bind,optional,create=file
lxc.cgroup2.devices.allow = c 4:8 rwm
This configuration provides the following:
- It creates 6 virtual consoles on the container, which is required by the getty services.
- It mounts the sys directory write access, allowing the udev service to run on the container. This is required by Sunshine, so it can create virtual devices on-the-fly.
- It mounts virtual and regular input devices of the host on the container, giving it potential write access to users on the container.
- It mounts the tty0 and tt8 consoles of the host on the container, which is required by our LightDM and X11 configuration.
Finally, restart the container with:
lxc restart "${CONTAINER_NAME}"
Create a new user with SSH access
It is expected by PipeWire, LightDM, and i3 that they run as a non-root user. We create a new user on the container and enable SSH access for them.
First we login into the container as root:
lxc exec "${CONTAINER_NAME}" -- bash
We proceed to create a new user, set its password, and add it to groups that will enable the user to automatically login, access the GPU, and access virtual devices. Please replace username with the desired user name. As done before, we suggest using the current user name on the host ($USER):
useradd -m -s /bin/bash username
passwd username
groupadd -r autologin
groupadd -r nopasswdlogin
gpasswd -a username autologin,nopasswdlogin,video,render,input,sudo
Allow members of the sudo group to execute any command by running visudo and uncommenting the following line:
%sudo ALL=(ALL:ALL) ALL
Install a SSH server:
pacman -Syu openssh
We change the configuration of the SSH service to disable password authentication and enable public key authentication. Run the following command:
edit /etc/ssh/sshd_config
Now ensure the following settings are set:
PubkeyAuthentication yes
PasswordAuthentication no
Finally, enable the SSH service with:
systemctl enable --now sshd
To allow public key authentication of the new user run:
edit /home/username/.ssh/authorized_keys
And proceed to add your public key as a new line. Finally, set the correct permissions for this file:
chmod 600 /home/username/.ssh/.ssh/authorized_keys
chown username:username /home/username/.ssh/.ssh/authorized_keys
The rest of the guide assumes the commands are ran on the container under this newly created user. You can SSH to the container to achieve this.
Configure graphics card
The container and the host must have installed the exact same version of the driver. The Arch Linux package manager is usually up to date. You must ensure that you have the same means to be up to date on the host. If using Ubuntu, as in this article, we recommend using Nvidia’s APT repository.
Install Nvidia drivers, nvFBC, NVENC, and other utilities on the container as follows:
sudo pacman -Syu nvidia-utils cuda nvidia-settings
After restarting the container, we should be able to see the GPU on the container when running:
nvidia-smi
Configure desktop environment
We install X11, LightDM as a display manager, i3 as a window manager, and additional tools for a minimal i3 configuration:
sudo pacman -Syu xorg-server ligthdm accountsservice i3-wm i3status dunst xsel hsetroot rxvt-unicode
Manually link to Nvidia’s GLX module for better performance:
sudo mv /usr/lib/xorg/modules/extensions/libglx.so /usr/lib/xorg/modules/extensions/libglx.so.backup
sudo ln -s /usr/lib/nvidia/xorg/libglxserver_nvidia.so /usr/lib/xorg/modules/extensions/libglx.so
Set up X11 by creating the following file:
sudo edit /etc/X11/xorg.conf.d/10-headless.conf
And now adding the following:
Section "ServerLayout"
Identifier "Layout0"
Screen "Screen0"
EndSection
Section "Module"
Load "glx"
EndSection
Section "Monitor"
Identifier "Monitor0"
VendorName "Unknown"
ModelName "DFP-0"
HorizSync 28.0 - 55.0
VertRefresh 43.0 - 72.0
Option "DPMS"
EndSection
Section "Device"
Identifier "Device0"
Driver "nvidia"
VendorName "NVIDIA Corporation"
BoardName "Unknown"
Option "ConnectedMonitor" "DFP"
Option "UseDisplayDevice" "DFP"
Option "AllowEmptyInitialConfiguration" "True"
Option "NoPowerConnectorCheck" "True"
Option "ModeValidation" "NoVirtualSizeCheck,NoMaxPClkCheck,NoHorizSyncCheck,NoVertRefreshCheck"
EndSection
Section "Screen"
Identifier "Screen0"
Device "Device0"
Monitor "Monitor0"
DefaultDepth 24
Option "AllowEmptyInitialConfiguration" "True"
Option "UseDisplayDevice" "DFP"
Option "Stereo" "0"
Option "metamodes" "1920x1080 +0+0; nvidia-auto-select +0+0"
SubSection "Display"
Depth 24
EndSubSection
EndSection
This creates a Layout with a Screen that has a dummy virtual Monitor and the NVIDIA GPU as a Device. GLX is added a Module. The fixed resolution of the Screen is set to 1920x1080. Please change accordingly.
Next, we enable automatic login by LightDM. First we edit the following configuration file:
sudo edit /etc/lightdm/lightdm.conf
And add the following settings to the specified sections:
[LightDM]
minimum-display-number=8
minimum-vt=8 # Setting this to a value < 7 implies security issues, see FS#46799
logind-check-graphical=false
[Seat:*]
display-stopped-script=/bin/bash -c 'chvt 7'
autologin-user=username
autologin-user-timeout=0
autologin-session=i3
Change username accordingly. This configuration sets the specified username for autologin using i3. We set the minimum number of displays to 8 and disable a check for graphical devices to be ready, since it is not properly detected inside the container and it should always be ready anyway.
We need to allow the nopasswdlogin group to authenticate using the PAM systemd service. Edit the following file:
edit /etc/pam.d/lightdm
Add one line after #%PAM-1.0:
auth sufficient pam_succeed_if.so user ingroup nopasswdlogin
You might need to restart the container after this step. Finally, we can enable the LightDM service as follows:
sudo systemctl enable --now lightdm
Configure audio
PipeWire is the most modern audio solution available on Linux. Moonlight requires PulseAudio, which is supported by PipeWire. Their installation is straightforward:
sudo pacman -Syu pipewire pipewire-pulse
systemctl --user enable --now pipewire
systemctl --user enable --now pipewire-pulse
Configure Avahi
Avahi allows applications to automatically detect the Sunshine service by devices on the same network. Its installation is straightforward:
sudo pacman -Syu install avahi
sudo systemctl enable --now avahi-daemon
Configure Sunshine
We add the official Sunshine repository to Pacman by editing the following file:
sudo edit /etc/pacman.conf
Adding the following to the end:
[lizardbyte]
SigLevel = Optional
Server = https://github.com/LizardByte/pacman-repo/releases/latest/download
[lizardbyte-beta]
SigLevel = Optional
Server = https://github.com/LizardByte/pacman-repo/releases/download/beta
Next we install the development version of Sunshine and allow it to change process priorities:
sudo pacman -Sy
sudo pacman -S lizardbyte-beta/sunshine-git
sudo setcap 'cap_sys_nice=eip' /usr/bin/sunshine
Some of the package configuration will fail in a container, thus we manually set it up. The following file must be added to the udev service configuration:
sudo edit /etc/udev/rules.d/60-sunshine.rules
Use the following configuration:
# Allows Sunshine to access /dev/uinput
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
# Allows Sunshine to access /dev/uhid
KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess"
# Joypads
KERNEL=="hidraw*", ATTRS{name}=="Sunshine PS5 (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine X-Box One (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine gamepad (virtual) motion sensors*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine Nintendo (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine PS5 (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
The changes must be reloaded as follows:
sudo udevadm control --reload-rules
sudo udevadm trigger
Now we configure Sunshine in this new file:
edit ~/.config/sunshine/sunshine.conf
Adding this configuration:
upnp = enabled
csrf_allowed_origins = https://192.168.1.2
system_tray = disabled
capture = nvfbc
To enable Sunshine as a service, we copy an existing systemd service configuration for Sunshine and change WantedBy:
cp /usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service ~/.config/systemd/user/sunshine.service
edit ~/.config/systemd/user/sunshine.service
Change it to:
WantedBy=default.target
Then we enable the service with:
systemctl --user enable --now sunshine.service
Create a dynamic DNS
We enable a domain that points the device’s public IP address. We recommend using Duck DNS. We use ddclient to publi
sudo pacman -Syu ddclient
sudo edit /etc/ddclient/ddclient.conf
Set use=web and your token and domain in the Duckdns section.
Lastly, enable and start the service:
sudo systemctl enable --now ddclient