Skip to main content
RunBook Academy

Proxmox VEX · LXC ContainersConfiguration

Passing devices into containers: GPUs, dongles and the boundary you give up

Advanced⏱ ~24 minpct

What you'll learn

  • Pass a host device into a container with dev[n] and set its in-container ownership correctly
  • Explain what the cgroup device controller permits and why a device node alone is not enough
  • State honestly what passing /dev/dri costs in isolation terms
  • Diagnose a container whose device silently disappeared after a reboot or a replug

Prerequisites

Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12

Not yet marked complete on this device.

The single most common reason to pass a device into a container is hardware video transcoding: a media server in an unprivileged container that wants /dev/dri/renderD128 so the iGPU does the work instead of the CPU. The same mechanism carries serial dongles, TPMs, tape drives and anything else with a device node.

It is genuinely easier than the equivalent for a VM. Where a VM needs VFIO, an IOMMU group, a driver unbind and a reboot - the material in GPU passthrough - a container needs one config line, because there is only one kernel and the device is already bound to it.

That ease is exactly the thing to be careful about.

Two mechanisms, one of which you should use

The supported one: dev[n]

Configuration changepass the render node into a container
set -euo pipefail
CTID=200

# What the device is on the host, and who owns it there.
ls -l /dev/dri/
stat -c 'major=%t minor=%T owner=%U:%G mode=%a' /dev/dri/renderD128

# Pass it in. uid/gid/mode describe the node as it will exist INSIDE
# the container, in the container's own ID space.
pct set "$CTID" --dev0 '/dev/dri/renderD128,uid=0,gid=104,mode=0660'

pct reboot "$CTID"
pct exec "$CTID" -- ls -l /dev/dri/

The documented sub-options are few, and each does one thing:

OptionMeaning
path=Path to the host device to pass through.
uid=User ID assigned to the device node inside the container.
gid=Group ID assigned to the device node inside the container.
mode=Octal access mode set on the device node.
deny-write=1 denies the container write access to the device.

gid=104 above is not a magic number - it is whatever GID the render group has inside that container’s userland, which is a property of the container’s distribution, not the host’s. Read it, do not assume it:

Read-only / Safefind the GID the container's own group database uses
# pct exec 200 -- getent group render video
render:x:104:
video:x:44:

Illustrative output

Get this wrong and the device node appears with an unresolvable group, the application’s user is not in it, and every attempt to open the device returns permission denied - which reads in most application logs as “no hardware acceleration available” and nothing more specific.

The legacy one: raw lxc.* keys

Most forum answers written before PVE 8.2 use the low-level form, appended to /etc/pve/lxc/<vmid>.conf:

lxc.cgroup2.devices.allow: c 226:0 rwm
lxc.cgroup2.devices.allow: c 226:128 rwm
lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir

This still works - PVE passes lxc.* settings straight to the low-level LXC tools - and you will meet it in inherited estates. It does two separate things, which is worth understanding even if you never write it:

  • The devices.allow lines are a cgroup policy: they permit the container’s cgroup to open a character device with major 226 and the named minors.
  • The mount.entry line puts the device node into the container’s mount namespace so there is something to open.

You need both. A device node with no cgroup permission gives EPERM on open; a cgroup permission with no node gives ENOENT. That two-part structure is the reason “I added the device and it still does not work” is such a common report against the legacy method.

What you are actually giving up

Say it plainly, because the forum posts do not.

Passing /dev/dri/renderD128 into a container gives every process in that container direct access to the DRM driver’s ioctl interface for your GPU. That interface is large, is written in C, runs in the kernel, and is shared with every other container and with the host. A container that is unprivileged is still sharing one kernel with everything else on the box; passing a device hands it a specific, complex, attack-relevant part of that kernel.

DeviceWhat it exposesReasonable for
/dev/dri/renderD128GPU compute and video engines, no display controlA trusted media or ML workload
/dev/dri/card0The above plus modesetting and display controlAlmost nothing in a container
/dev/ttyUSB0, /dev/ttyACM0One serial deviceA home-automation controller
/dev/net/tunArbitrary tunnel interface creationA VPN container, with the network implications understood
/dev/sdX, /dev/nvme0n1A raw block device, bypassing the storage layerEffectively never - use a device mount point, and think hard

Devices pin a container to a node

A container with a dev[n] entry can only do its job on a host that has that device. Nothing in PVE enforces this.

  • Manual migration to a node without the device succeeds. The container starts. The device is absent inside it.
  • HA failover does the same, faster and unattended, at the worst possible moment.

If a device-bearing container is HA-managed, constrain it. A node-affinity rule naming the nodes that actually have the hardware is the mechanism, and it is covered in HA rules:

Configuration changepin a device-bearing container to nodes that have the device
set -euo pipefail
CTID=200

ha-manager rules add node-affinity ct-gpu-hosts \
--resources "ct:$CTID" \
--nodes 'pve-01,pve-02' \
--strict 1

ha-manager rules config --resource "ct:$CTID"

Strict is the right choice here and it is worth being explicit about why. A non-strict rule lets HA start the container on a node without the GPU, where it will run and be wrong. A strict rule leaves it stopped, which is visible, alarming and correct. Choosing “up but degraded” over “down and obvious” is almost always the wrong trade for hardware-dependent workloads.

Verification that can fail

Read-only / Safeprove the device is present, permitted and usable
set -euo pipefail
CTID=200
DEV=/dev/dri/renderD128

# 1. The node exists inside the container.
pct exec "$CTID" -- test -c "$DEV" && echo 'node present'

# 2. Ownership resolves and the application's user is in that group.
pct exec "$CTID" -- stat -c '%U:%G %a %n' "$DEV"
pct exec "$CTID" -- id www-data

# 3. It can actually be opened read-write, which is the only test that
#    exercises the cgroup device rule as well as the file mode.
pct exec "$CTID" -- runuser -u www-data -- \
sh -c "exec 3<>'$DEV' && echo 'open OK' && exec 3>&-"

Step 3 is the one that distinguishes the three failure modes from each other. ENOENT means the node is missing - a dev[n] path problem. EPERM as root means the cgroup rule is missing - a legacy-config problem. EACCES as the service user means ownership or mode is wrong - a uid/gid/mode problem. Steps 1 and 2 alone cannot tell those apart.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A container has a device node at /dev/dri/renderD128 inside it, owned root:render mode 0660, and the application user is in the render group. Opening it still fails with EPERM even as root. What is the most likely cause?

  2. Q2. After a host firmware update, a media container’s CPU usage triples every evening and users report slowness. The container is running and logs no errors. What should you check first?

  3. Q3. Which statements about passing devices into containers are correct? Select all that apply.

  4. Q4. A USB dongle that is unplugged and replugged will not reappear inside the container it was passed into until that container is restarted.

  5. Q5. Which pct.conf option denies a container write access to a passed-through device while still allowing it to read?

Passing score: 75%. Answers are checked in this browser.