NVIDIA GPU passthrough into an unprivileged Proxmox LXC

Zotac GeForce RTX 3060 graphics card, illustrative hardware photograph

Written by

in

Header photo: illustrative Zotac RTX 3060, Qurren / Wikimedia Commons, CC BY-SA 4.0.

I wanted the local LLM broker to use the GPUs without giving up its unprivileged LXC. What worked was keeping the NVIDIA driver on Proxmox, then passing its device nodes and user-space libraries through to the container.

The slightly confusing bit is what passthrough means here. With LXC, the container shares the host’s driver: the GPU stays bound to NVIDIA on Proxmox, and the container uses the exposed devices. There’s no VFIO or vGPU configuration involved.

WhereWhat belongs there
Proxmox hostNVIDIA kernel modules, matching PVE headers and driver libraries
Unprivileged LXCSelected GPU devices, read-only driver libraries, application runtime
Docker, if usedThe same devices and libraries passed through the second container boundary
GPU stack: physical NVIDIA GPU connects to the Proxmox host driver; device nodes and read-only libraries are shared with an unprivileged LXC and optionally Docker.
The host owns the driver. Device nodes and driver libraries cross the LXC boundary, then the Docker boundary if used. Click to enlarge.

The steps below are for Proxmox VE 9 on Debian 13, x86-64, using NVIDIA’s open kernel module. That covers Turing and newer GPUs; NVIDIA’s supported-device table lists the model names and PCI IDs.

1. Get the host driver working

My first snag was an older packaged NVIDIA driver that wouldn’t build against a newer PVE kernel. NVIDIA’s current packaged driver, with headers for the exact PVE kernel, fixed it. Start with an updated host, finish any pending kernel reboot, then stop the GPU containers and run these as root:

pveversion
uname -r
cat /etc/os-release
lspci -nn -d 10de:

Check that the OS reports VERSION_ID="13" and the kernel ends in -pve. Match the GPU’s PCI ID to the supported-device table above. The repository below is specifically for Debian 13; don’t add it to a Debian 12 host.

Enable contrib in the Debian repository’s Components: line, normally in /etc/apt/sources.list.d/debian.sources, preserving its other components. Then install the matching PVE headers and NVIDIA’s repository keyring:

apt update
apt install proxmox-default-headers "proxmox-headers-$(uname -r)" \
    dkms build-essential wget
wget https://developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64/cuda-keyring_1.1-1_all.deb \
    -O /tmp/cuda-keyring.deb
dpkg -i /tmp/cuda-keyring.deb
apt update
apt-cache policy nvidia-driver-cuda nvidia-kernel-open-dkms

Use the current compute driver from developer.download.nvidia.com. The Candidate: lines show the versions APT will install; the version table shows their repository. If an old pin or hold keeps APT on another branch, inspect apt-mark showhold and /etc/apt/preferences.d/ before proceeding. Don’t mix driver components from different branches.

apt -s install nvidia-driver-cuda nvidia-kernel-open-dkms
# Check the proposed transaction, then:
apt install nvidia-driver-cuda nvidia-kernel-open-dkms
dkms status
modinfo -k "$(uname -r)" -F version nvidia

You don’t need to pick a driver from a PVE-version lookup table here. APT selects the packaged release, and DKMS builds its kernel interface against your exact PVE headers. In dkms status, the NVIDIA entry must contain the full kernel string from uname -r and end in installed. That’s the build and installation checked. You still need to check that the module loads.

If it fails, read the make.log path printed by DKMS. Missing headers mean fixing the matching proxmox-headers-… package; compiler errors about changed kernel functions can mean an older driver against a newer kernel. Check the candidate version and NVIDIA’s driver issue tracker using the exact driver version, kernel and error. If the current packaged driver still won’t build, stop there; don’t reboot into an unbuilt module or silently downgrade PVE.

After a successful build, reboot the host in its maintenance window, then run nvidia-smi -L and lspci -nnk -d 10de:. The GPU should use nvidia. If another driver owns it, check the kernel journal; if Secure Boot rejects the module, follow Proxmox’s DKMS signing procedure. The package/repository steps follow NVIDIA’s Debian compute-only instructions.

2. Check UVM before starting the LXC

# Proxmox host
modprobe nvidia_uvm
ls -l /dev/nvidia0 /dev/nvidiactl \
      /dev/nvidia-uvm /dev/nvidia-uvm-tools

This caught me out: nvidia-smi can work while CUDA cannot initialise. UVM and its device nodes need to exist before the container starts. Loading the module afterwards won’t repair a missing bind mount in an LXC that’s already running.

3. Add the devices and libraries

Stop the unprivileged Debian LXC and back up /etc/pve/lxc/<CTID>.conf. The entries below go in that file, alongside its existing configuration. Run this in Bash on the Proxmox host to print the device entries:

for dev in /dev/nvidia0 /dev/nvidiactl /dev/nvidia-uvm /dev/nvidia-uvm-tools; do
    if [ ! -c "$dev" ]; then
        echo "Missing character device: $dev" >&2
        break
    fi
    read -r major minor < <(stat -c '%t %T' "$dev")
    printf 'lxc.cgroup2.devices.allow: c %d:%d rwm\n' "$((16#$major))" "$((16#$minor))"
    printf 'lxc.mount.entry: %s %s none bind,create=file 0 0\n' "$dev" "${dev#/}"
done

Each device produces a cgroup permission and a bind mount. Check that all four devices produced entries before copying the output into the LXC configuration. UVM major numbers can change, so it’s worth reading them from your host rather than copying numbers from someone else’s configuration.

Find the host’s driver libraries and management binary:

ldconfig -p | grep -E 'libcuda.so.1 |libnvidia-ml.so.1 |libnvidia-ptxjitcompiler.so.1 '
command -v nvidia-smi

For the usual Debian x86-64 paths, add these read-only mounts. If the lookup above reports a different source path, change the first path on that line; keep the destination path as shown. All three libraries must be present.

lxc.mount.entry: /usr/lib/x86_64-linux-gnu/libcuda.so.1 usr/lib/x86_64-linux-gnu/libcuda.so.1 none bind,ro,create=file 0 0
lxc.mount.entry: /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1 usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1 none bind,ro,create=file 0 0
lxc.mount.entry: /usr/lib/x86_64-linux-gnu/libnvidia-ptxjitcompiler.so.1 usr/lib/x86_64-linux-gnu/libnvidia-ptxjitcompiler.so.1 none bind,ro,create=file 0 0
lxc.mount.entry: /usr/bin/nvidia-smi usr/bin/nvidia-smi none bind,ro,create=file 0 0

That gives the container CUDA’s driver interface, NVML, the PTX JIT compiler and nvidia-smi, all from the same host installation. These paths assume Debian x86-64 on both sides. Inside the LXC, you only need to install the application runtime; the kernel driver stays on the host.

There’s one more permission check: the application’s mapped UID/GID must be able to access the devices. The cgroup rule doesn’t grant filesystem permission on its own. Run the following checks as the actual application user.

4. Test CUDA inside the LXC

Start the LXC and run nvidia-smi -L. Then, from its CUDA-enabled PyTorch environment as the application user:

import torch

assert torch.cuda.is_available(), "CUDA is unavailable"
x = torch.ones(1024, device="cuda:0")
assert (x + x).sum().item() == 2048
print(torch.cuda.get_device_name(0), "OK")

This is the useful test: can the application allocate GPU memory and actually run a CUDA operation? A working nvidia-smi only gets you as far as driver telemetry.

Docker and later upgrades

Docker added another snag. The NVIDIA Container Toolkit didn’t pass the host driver libraries through my unprivileged LXC. Explicit device and library mounts fixed it. Enable nesting and keyctl for Docker on the LXC, then add these entries to the GPU service in its Compose file:

devices:
  - /dev/nvidia0:/dev/nvidia0
  - /dev/nvidiactl:/dev/nvidiactl
  - /dev/nvidia-uvm:/dev/nvidia-uvm
  - /dev/nvidia-uvm-tools:/dev/nvidia-uvm-tools
volumes:
  - /usr/lib/x86_64-linux-gnu/libcuda.so.1:/usr/lib/x86_64-linux-gnu/libcuda.so.1:ro
  - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:ro
  - /usr/lib/x86_64-linux-gnu/libnvidia-ptxjitcompiler.so.1:/usr/lib/x86_64-linux-gnu/libnvidia-ptxjitcompiler.so.1:ro
  - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro

Merge these with the service’s existing devices and volumes. Use an image with a compatible CUDA runtime and Linux user-space, recreate that Docker service, and repeat the compute test inside it. The source paths here are inside the LXC, where the host libraries were mounted in step 3.

Stop the GPU LXC before updating the host driver or kernel. Afterwards, verify the host driver and UVM nodes, recheck the device numbers and library paths in step 3, update any changed configuration entries, then start the LXC again. Old bind mounts can hang on to replaced library files, so don’t skip the stop/start. After a host reboot, check the whole path again too: UVM and its devices need to be ready before the LXC starts.

More than one GPU

The same arrangement works with multiple GPUs. Add its device node (for example /dev/nvidia1) to the loop in step 3 and to the Docker device list if used, then test CUDA on each visible device. That’s how my broker uses two cards. Their VRAM is still separate, though; splitting a model or assigning workloads to different GPUs is down to the application.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *