You pass --device /dev/ttyACM0 to your rootless Podman container. You check the device inside: crw-rw-rw-. It works. You walk away.
Ten seconds later, your Z-Wave stick is dead. The device node shows c---------. Zero permissions. Your home automation can’t reach it.
You didn’t change anything. The container is still running. The device permissions just vanished.
This is the story of how I spent three days debugging a problem that doesn’t exist in any Podman documentation — because it’s caused by a component most people don’t know is there.
The Setup
I was deploying a smart home server on Rocky Linux using rootless Podman containers managed by systemd quadlets. Everything was containerized: Home Assistant, Mosquitto, Zigbee2MQTT, and Z-Wave JS UI. The Z-Wave dongle (an Aeotec Z-Stick Gen5) appeared as /dev/ttyACM0 on the host.
The quadlet configuration looked straightforward:
[Container]
Image=docker.io/zwavejs/zwave-js-ui:latest
ContainerName=zwavejsui
AddDevice=/dev/ttyACM0
SecurityLabelDisable=true
GroupAdd=keep-groups
Network=host
The UDEV rule set MODE="0666" on the device. Everything should have worked.
It didn’t.
Symptoms
Inside the container, /dev/ttyACM0 existed but had permissions c---------. The Z-Wave JS UI application couldn’t open it. The logs said “permission denied.”
But when I ran the exact same command manually with podman run -d ... --device /dev/ttyACM0 ..., it worked! The device showed crw-rw-rw-.
Or so I thought.
What I Tried (And What Failed)
I went through the full checklist of reasonable approaches:
| Approach | Result |
|---|---|
AddDevice=/dev/ttyACM0 in quadlet | c--------- |
--device /dev/ttyACM0:/dev/ttyACM0:rwm | c--------- |
GroupAdd=keep-groups | c--------- |
--privileged | c--------- (rootless has no real privileges) |
--cap-add SYS_ADMIN | c--------- |
Systemd DeviceAllow= override | c--------- |
Systemd Delegate=no override | c--------- |
--cgroups=enabled instead of split | c--------- |
chcon -t container_file_t /dev/ttyACM0 (UDEV) | Broke things further |
| Plain systemd service instead of quadlet | Still c--------- |
Every single approach failed. Some were obviously wrong in retrospect (chcon applies the wrong SELinux type for device nodes). Others seemed like they should work but didn’t.
The Key Observation
The breakthrough came when I checked device permissions at different time intervals after starting the container manually:
$ podman run -d --name test --device /dev/ttyACM0 ...
$ podman exec test ls -la /dev/ttyACM0
crw-rw-rw- 1 nobody nobody 166, 0 Apr 29 16:33 /dev/ttyACM0
# ... 8 seconds later ...
$ podman exec test ls -la /dev/ttyACM0
c--------- 1 nobody nobody 166, 0 Apr 29 16:33 /dev/ttyACM0
The device started accessible and then became inaccessible. Something was changing the permissions after container creation.
The Root Cause: eBPF Device Filtering in cgroup v2
The culprit is crun, the default OCI runtime for Podman on RHEL and Rocky Linux.
When a container starts on a cgroup v2 system, crun applies an eBPF device program to the container’s cgroup. This program implements a default-deny policy: only devices explicitly listed via --device are allowed. All other devices get their permissions reset to 0000.
The program is applied asynchronously — it takes 5 to 10 seconds to take effect. This is why manual testing appeared to work: I was checking permissions before the eBPF program was applied. Under systemd, the service initialization takes long enough that the eBPF program is always in place by the time you check.
The --device /dev/ttyACM0 flag does add the device to the allow list in the eBPF program. But the cgroup device namespace is separate from the filesystem namespace. The device node exists in /dev (filesystem), but the BPF program controls access at the cgroup level — and it resets all permissions not in the explicit allow list. The interaction between these two namespaces is the source of the bug.
This behavior is not documented in Podman’s documentation. It’s a crun implementation detail that only manifests in rootless mode under systemd.
The Fix: Bind-Mount /dev Read-Only
The solution is to bypass the cgroup device namespace entirely by bind-mounting the host’s /dev directory into the container:
# In quadlet .container file:
Volume=/dev:/dev:ro
AddDevice=/dev/ttyACM0
# Or in podman run:
-v /dev:/dev:ro --device /dev/ttyACM0
When you bind-mount /dev from the host, the device nodes are accessed through the host’s filesystem namespace, not the container’s cgroup-restricted device namespace. The :ro flag prevents the container from modifying /dev.
The AddDevice=/dev/ttyACM0 is still useful — it tells Podman the device should exist, which helps with container initialization. But the real access comes from the bind mount.
After applying this fix, /dev/ttyACM0 shows crw-rw-rw- and stays that way indefinitely.
Why Doesn’t Group-Based Access Work?
You might wonder why UDEV rules with MODE="0660", GROUP="dialout" don’t work. The answer is user namespace mapping.
In rootless Podman, the container’s user namespace maps:
- Host user (UID 1000) → Container root (UID 0)
- Host subuid range (100000+) → Container UIDs 1+
- Unmapped UIDs →
nobody(65534)
The container’s dialout group is not the same as the host’s dialout group. Device nodes owned by host groups appear as nobody inside the container. Setting MODE="0666" (world-readable/writable) is the only reliable approach because it doesn’t depend on group membership.
UDEV Rules for Rootless Podman
For USB dongles, use these rules:
# Z-Wave dongle (Aeotec Z-Stick Gen5) — appears as /dev/ttyACM*
SUBSYSTEM=="tty", ATTRS{idVendor}=="0658", ATTRS{idProduct}=="0200", MODE="0666", SYMLINK+="zwave-dongle"
# Zigbee dongle (Sonoff) — appears as /dev/ttyUSB*
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0666", SYMLINK+="zigbee-dongle"
Key points:
MODE="0666"— not group-based permissions. Groups don’t map across user namespaces.- Don’t use
chconon device nodes.container_file_tis for files, not character devices. UseSecurityLabelDisable=truein the quadlet to handle SELinux. - Know your driver: Z-Wave dongles use the
cdc_acmdriver and appear as/dev/ttyACM*, not/dev/ttyUSB*.
Quadlet vs Plain Service: Did It Matter?
During debugging, I switched from a quadlet to a plain systemd service, hoping that removing the quadlet generator’s hidden flags (--cgroups=split, conmon, Delegate=yes) would fix the device access.
It didn’t.
The eBPF device filter is applied by crun regardless of how the container is launched. Quadlet, plain service, or manual podman run — same result after 10 seconds. The “improvement” I saw with the plain service was actually because I was testing the fix (Volume=/dev:/dev:ro) at the same time as switching service types.
Don’t conflate “I changed X and it worked” with “X was the problem.” When you make two changes at once, you can’t tell which one mattered.
The right debugging pattern is:
- Fix the actual problem first
- Then decide whether to simplify the service type
- Never do both at once
For the record: quadlets are the better default. They give you declarative configuration, automatic restarts, and podman-auto-update support. Plain services should only be used when you need full control over ExecStart.
Checklist: Rootless Podman + USB Device
Before deploying a container that needs USB/serial device access:
- [ ] UDEV rule sets
MODE="0666"(not group-based permissions) - [ ] UDEV rule does not use
chcon -t container_file_t - [ ] Quadlet/container includes
SecurityLabelDisable=true - [ ] Quadlet/container includes
GroupAdd=keep-groups - [ ] Quadlet/container includes
Volume=/dev:/dev:ro - [ ] Quadlet/container includes
AddDevice=/dev/ttyACM0(or your device) - [ ] Verify device path: Z-Wave =
/dev/ttyACM*, Zigbee =/dev/ttyUSB* - [ ] Test device access 10+ seconds after container start, not immediately
- [ ]
loginctl enable-lingeris set for the container user
References
- Podman Quadlet Documentation — official quadlet reference
- crun Source Code — the OCI runtime that applies the eBPF device program
- cgroup v2 Device Control — kernel documentation on cgroup device filtering