My journey to run Steam with video output in an unprivileged LXC container with Nvidia GPU passthrough.

Why Even Bother?

As the proud owner of an old CRT TV scavenged from my parents' garage, I wanted to use it for something more than booting Mario on the NES once a year. It occurred to me: why not reminisce about old times while watching classic cartoons and anime, and maybe even play some retro games? So I bought HDMI to SCART dongle and started to think.

The challenge was finding an efficient way to run gaming and media applications without dedicating an entire virtual machine to occasional use. This led me to explore running a full desktop environment within an LXC container with GPU passthrough.

Hardware Setup

My machine is rather powerful for this endeavor, featuring:

  • AMD EPYC 7551P
  • 128 GB ECC RAM
  • Nvidia RTX A400
  • Nvidia RTX 3090

The system runs Proxmox 8.4.5 with kernel 6.12-12-pve. Since I have two GPUs, the obvious solution would be to pass one through to a VM and easily achieve what I want. However, I won't be using this daily, so why waste processing power on an idle VM? You might suggest simply turning off the VM when it's not needed, but I'm too lazy for that – I want to have my cake and eat it too. So how do we accomplish this with an LXC container?

Setting Up the LXC Container

I've been daily driving Arch Linux for quite some time, so the obvious choice for me is the archlinux-base_20240911-1_amd64 template. You should go with whatever distribution you're familiar with, but keep in mind that group mappings and package names will differ.

Device Passthrough

Proxmox 8.4.5 introduces a new method for passing devices to LXC containers, which we'll use instead of the previously viable mountpoint approach.

Let's add devices to my LXC by editing /etc/pve/lxc/xxx.conf:

dev0: /dev/dri/card2,gid=985,mode=0666
dev1: /dev/dri/renderD129,gid=989,mode=0666
dev10: /dev/snd/hwC1D0,gid=996
dev11: /dev/snd/pcmC1D7p,gid=996
dev12: /dev/snd/pcmC1D8p,gid=996
dev13: /dev/snd/pcmC1D9p,gid=996
dev2: /dev/nvidia1,mode=0666
dev3: /dev/nvidiactl,mode=0666
dev4: /dev/nvidia-modeset,mode=0666
dev5: /dev/nvidia-uvm,mode=0666
dev6: /dev/nvidia-uvm-tools,mode=0666
dev7: /dev/fb1,gid=985,mode=0666
dev8: /dev/snd/controlC1,gid=996
dev9: /dev/snd/pcmC1D3p,gid=996

What do these mappings accomplish? We want devices to be usable within the container, which requires proper group mapping between the host and container.

To check groups in your container, run:

getent group render
# or
cat /etc/group

The important groups to map are:

render:x:989:
video:x:985:
audio:x:996:

In my case, I'm passing through my RTX 3090, which is my second GPU. That's why you see nvidia1, card2, and renderD129 instead of nvidia0, card0, and renderD128.

I Use Arch BTW

Once the LXC container is running, let's start by refreshing the pacman keys:

pacman-key --init
pacman-key --populate archlinux
pacman-key --refresh-keys

Now update packages and install git:

pacman -Syuu
pacman -S --needed git base-devel

Finally, create the user account:

groupadd drakkein
useradd -m -g drakkein -d /home/drakkein drakkein
passwd drakkein
usermod -aG sudo,render,video,audio,git drakkein

From now on, I'll use yay for package management. You can use it as well or stick with pacman.

Nvidia Driver Installation

My host system has kernel modules for driver version 575.64.03, so we'll use the same version in the container:

curl https://us.download.nvidia.com/XFree86/Linux-x86_64/575.64.03/NVIDIA-Linux-x86_64-575.64.03.run -o NVIDIA-Linux-x86_64-575.64.03.run
chmod +x ./NVIDIA-Linux-x86_64-575.64.03.run
sudo ./NVIDIA-Linux-x86_64-575.64.03.run --no-kernel-modules --silent
# The Nvidia installer will complain about missing Vulkan ICD loader
yay -S vulkan-icd-loader

Time for a test, and voilà! My GPU is detected by the driver:

[drakkein@tvbox-arch ~]$ nvidia-smi
Fri Aug  1 19:06:56 2025
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 575.64.03              Driver Version: 575.64.03      CUDA Version: 12.9     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 3090        Off |   00000000:21:00.0  On |                  N/A |
|  0%   53C    P8             19W /  420W |       8MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+

But how do we display something from the LXC container? Let's start by displaying some noise on the framebuffer:

cat /dev/urandom > /dev/fb1

Desktop Environment Setup

Since our GPU is working and displaying noise on the screen, it's time to install Xorg and Plasma. We'll follow the Arch Wiki for this. The installation might complain about existing files for mesa and libglvnd – the culprit is the Nvidia driver.

Additional packages I want to install are x11vnc steam firefox, so it's a good time to enable the multilib repository.

Time to prepare X11 by editing /etc/X11/xorg.conf and adding our Nvidia card to the configuration:

Section "ServerLayout"
    Identifier     "Layout0"
    Screen      0  "Screen0" 0 0
    InputDevice    "Keyboard0" "CoreKeyboard"
    InputDevice    "Mouse0" "CorePointer"
EndSection

Section "Files"
EndSection

Section "InputDevice"
    # generated from default
    Identifier     "Mouse0"
    Driver         "mouse"
    Option         "Protocol" "auto"
    Option         "Device" "/dev/mouse"
    Option         "Emulate3Buttons" "no"
    Option         "ZAxisMapping" "4 5"
EndSection

Section "InputDevice"
    # generated from default
    Identifier     "Keyboard0"
    Driver         "kbd"
EndSection

Section "Monitor"
    Identifier     "Monitor0"
    VendorName     "Unknown"
    ModelName      "Unknown"
    Option         "DPMS"
EndSection

Section "Device"
    Identifier     "NvidiaCard"
    Driver         "nvidia"
    VendorName     "NVIDIA Corporation"
    BoardName      "GeForce RTX 3090"
    BusID          "PCI:33:0:0"
EndSection

Section "Screen"
    Identifier     "Screen0"
    Device         "Device0"
    Monitor        "Monitor0"
    DefaultDepth    24
    Option         "AllowEmptyInitialConfiguration" "true"
    Option         "Stereo" "0"
    Option         "nvidiaXineramaInfoOrder" "DP-5"
    Option         "metamodes" "1024x768_60 +0+0"
    Option         "SLI" "Off"
    Option         "MultiGPU" "Off"
    Option         "BaseMosaic" "off"
    SubSection     "Display"
        Depth       24
    EndSubSection
EndSection

How Do We Start It?

In an LXC container, we don't have virtual terminals (VT), so the typical graphical login with a greeter won't work. We need to start X manually and launch Plasma. However, this creates another problem: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR are missing, so none of our systemd user units will launch. We can set them manually:

export XDG_RUNTIME_DIR="/run/user/$(id -u)"
mkdir -p "$XDG_RUNTIME_DIR"
eval $(dbus-launch --sh-syntax --exit-with-session)

Unfortunately, it's still not working:

Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined (consider using --machine=<user>@.host --user to connect to bus of other user)

The solution is to either provide the machine parameter or use machinectl shell [email protected], which makes our user units work properly. Here is the Convinient script that I use to launch plasma.

Plasma running in LXC
Plasma successfully running inside the LXC container

What If Sound Isn't Working?

The likely reason is that no sink has been created for your audio device. In my case, I'm using the third HDMI port of my GPU, which corresponds to the second audio device.

To create a sink manually, create the file /etc/pipewire/pipewire.conf.d/99-hdmi-alsa-sink.conf:

context.objects = [
  {
    factory = adapter
    args = {
      factory.name    = api.alsa.pcm.sink
      node.name       = "alsa-hdmi-sink"
      node.description = "Manual HDMI Sink (hw:1,3)"
      media.class     = "Audio/Sink"
      audio.position  = "FL,FR"
      api.alsa.path   = "hw:1,3"
      api.alsa.format = "S16LE"
      api.alsa.rate   = 48000
      api.alsa.channels = 2
      priority.session = 1000
      node.exclusive = false
    }
  }
]

How to Remove System Policy Prompts

First, add your user to the network group. Then create a polkit rule at /etc/polkit-1/rules.d/50-org.freedesktop.NetworkManager.rules:

polkit.addRule(function(action, subject) {
  if (action.id.indexOf("org.freedesktop.NetworkManager.") == 0 && subject.isInGroup("network")) {
    return polkit.Result.YES;
  }
});

Gaming Setup

For gaming, I installed steam, gamescope, and rpcs3. However, we need input devices, so the first step is to pass them to the LXC container:

dev14: /dev/input/js0,gid=994
dev15: /dev/input/event13,gid=994

Remember to add your user to the input group.

Gamepad visible in Plasma
Gamepad successfully detected Plasma system settings

If you're wondering why my device shows as 8bitDo Virtual Gamepad, it's because Steam has a problem with reattaching disconnected devices. I decided to create a virtual device with uinput and mirror all events from my real device while ignoring disconnects. This also solves the issue with my 8BitDo Ultimate controller under Linux, where it immediately disconnects if nothing grabs its events.

Everything appears to be working, so I can finally create a systemd unit that will start Plasma after LXC launch:

[Unit]
Description=Start plasma after LXC start
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/startplasma-lxc

[Install]
WantedBy=multi-user.target

Gamescope Configuration

I want a Steam Deck-like experience using a gamepad, so my goal is to launch a gamescope session. After installing gamescope, mangohud, and lib32-mangohud, the first step is to create a new script at /usr/bin/gamescope-session. Besides launching Steam, we also move the cursor out of the way:

#!/bin/bash
xdotool mousemove 1023 767
gamescope --mangoapp -f -e -W 1024 -H 768 -- steam -gamepadui -steamos3 -steampal -steamdeck

Create /usr/bin/steamos-session-select to allow returning to Plasma:

#!/bin/bash
steam -shutdown

Create /usr/bin/steamos-update and /usr/bin/jupiter-biosupdate to mock SteamOS and Deck BIOS updates:

#!/bin/bash
exit 0;

For convenience, I also created a desktop shortcut for gamescope-session:

[Desktop Entry]
Exec=gamescope-session
Icon=steam
Name=Steam Gamescope
StartupNotify=true
Terminal=false
Type=Application
Gamescope running under LXC
Gamescope is starting in the LXC container
Steam welcome screen
Successful login to Steam

For now, we can exit Steam and continue with the setup.

EmuDeck Integration

The next step is to integrate emulation into Steam. For this, I'll use EmuDeck.

For RPCS3, we need to set a higher memory lock limit, so I need to update my LXC configuration. We also want to pin CPU cores for this LXC container:

lxc.prlimit.memlock: unlimited
lxc.cgroup.cpuset.cpus=0-15
RPCS3 in LXC
RPCS3 running successfully in the LXC container

Kodi Setup

Kodi setup is straightforward – all we need to install is kodi and the kodi-peripheral-joystick plugin. I self-host Jellyfin, so in my case, an additional dependency is the Jellyfin plugin.

All we need to do now is add a Kodi shortcut to Steam.

Final Configuration

Now we need to automatically launch our gamescope-session. I opted to do this via Plasma Settings → Startup and Shutdown → Autostart → Login Scripts.

CRT TV
Mission success! Steam is showing on CRT TV