Proxmox VEX · LXC ContainersConfiguration
Passing devices into containers: GPUs, dongles and the boundary you give up
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
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]
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:
| Option | Meaning |
|---|---|
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:
# pct exec 200 -- getent group render videorender: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.allowlines are a cgroup policy: they permit the container’s cgroup to open a character device with major 226 and the named minors. - The
mount.entryline 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.
| Device | What it exposes | Reasonable for |
|---|---|---|
/dev/dri/renderD128 | GPU compute and video engines, no display control | A trusted media or ML workload |
/dev/dri/card0 | The above plus modesetting and display control | Almost nothing in a container |
/dev/ttyUSB0, /dev/ttyACM0 | One serial device | A home-automation controller |
/dev/net/tun | Arbitrary tunnel interface creation | A VPN container, with the network implications understood |
/dev/sdX, /dev/nvme0n1 | A raw block device, bypassing the storage layer | Effectively 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:
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
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
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?
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?
Q3. Which statements about passing devices into containers are correct? Select all that apply.
Q4. A USB dongle that is unplugged and replugged will not reappear inside the container it was passed into until that container is restarted.
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.