Sooner or later almost every Proxmox host grows a USB device that needs to talk to one specific guest. A Zigbee or Z-Wave radio for Home Assistant, a TV tuner for a media server, a serial adapter for a UPS, a security dongle for some old piece of software that still checks for one. Getting that device into the right VM or container is usually simple the first time and then mysteriously stops working after the next reboot, because the setup that worked was tied to something that wasn’t actually stable. Here’s how passthrough actually works for both VMs and LXCs, and where the stability problem comes from.

The core problem: USB paths aren’t stable

A USB device on Linux shows up in a couple of different ways, and which one you use for passthrough matters more than most guides let on.

  • Bus and device number (Bus 001 Device 007, from lsusb) changes every time the device is unplugged and replugged, and can change across a host reboot too if enumeration order shifts. Never build a passthrough config around this.
  • Vendor and product ID (0451:16a8, also from lsusb) is stable for a given device model, but if you own two identical dongles, both report the same IDs and Proxmox can’t tell them apart.
  • Device serial number, when the device has one, is the actually stable identifier. Most quality Zigbee/Z-Wave sticks and USB-serial adapters expose one. Cheap generic dongles sometimes don’t, which is worth checking before you buy one for a headless box you don’t want to babysit.
  • The physical port path (/dev/serial/by-id/... or a specific bus/port combination) is stable as long as the device stays plugged into the same physical port, but breaks the moment someone moves it to a different port during a case rebuild or troubleshooting session.

The practical takeaway: if a device has a serial number, key your passthrough config off vendor:product ID plus serial. If it doesn’t, key off the physical port and write yourself a note not to move it.

Passthrough to a VM

Proxmox gives you two ways to hand a USB device to a VM, and they behave differently enough to matter.

Device passthrough (the normal path). In the VM’s Hardware tab, Add > USB Device, then pick “Use USB Vendor/Device ID” and select the device from the list, or “Use USB Port” if you’d rather pin it by physical port. This adds a usbN: host=vendorid:productid (or host=bus-port) line to the VM config. The device appears live inside the guest as if it were plugged directly into it, and Proxmox’s own hotplug handling means unplugging and replugging the physical device generally reconnects it into the VM automatically, no VM restart needed.

  • Vendor:product ID passthrough follows the device across ports, which is what you want for something you might move around, but as noted above it can’t distinguish two identical dongles.
  • Port passthrough follows the physical USB port instead, which uniquely identifies a device even without a serial number, but silently starts passing through whatever gets plugged into that port later if someone doesn’t know the convention.

Full USB controller passthrough (the heavy option). Instead of passing one device, you pass an entire PCIe USB host controller to a VM, using the same PCI passthrough mechanism as a GPU (IOMMU has to be enabled and working, same as covered in the GPU passthrough case for this site). Every port on that controller then belongs exclusively to the VM, at the cost of the host losing access to those ports entirely. This is worth it only when you need guaranteed low-latency access (some audio interfaces and industrial USB hardware care about this) or you’re passing through a USB add-in card you bought specifically for this purpose and don’t need the ports for anything else. For a single Zigbee dongle, this is overkill, use device passthrough.

Passthrough to an LXC

Containers don’t get their own virtualized USB bus. Instead of “passing through” a device in the VM sense, you’re exposing the device node on the host filesystem directly into the container, which is a bind-mount plus a cgroup permission, not a virtualization feature.

The stable way to do this:

  1. Find the device’s persistent path. For most serial-style dongles (Zigbee sticks, UPS serial adapters) this is /dev/serial/by-id/usb-<vendor>_<product>_<serial>-if00-port0, created automatically by udev if the device has usable identifying strings. Confirm with ls -l /dev/serial/by-id/ on the host.
  2. In the container config (/etc/pve/lxc/<vmid>.conf), add a bind mount pointing the container at that path or the resolved /dev/ttyUSBx//dev/ttyACMx node it symlinks to:
    lxc.mount.entry: /dev/serial/by-id/usb-xxx dev/ttyUSB0 none bind,optional,create=file
    
  3. Grant the container access to the device’s major/minor number through the cgroup device allow list, since an unprivileged container is denied device access by default even with the bind mount in place:
    lxc.cgroup2.devices.allow: c 188:* rwm
    
    (188 is the major number for USB-serial devices in this example, confirm the actual major/minor for your device with ls -la on the resolved /dev/ttyUSBx node, it varies by device class.)
  4. Restart the container for the config to take effect. Confirm the device is visible and has the right permissions inside the container with ls -l /dev/ttyUSB0.

If the device doesn’t expose a by-id path (no usable vendor/product/serial strings for udev to build one from), fall back to a udev rule that pins a friendly, stable symlink name based on whatever identifying information the device does report, then bind-mount that symlink instead of a raw /dev/ttyUSBx node whose number can shift if other USB-serial devices come and go.

The Home Assistant case, worked through

This is the single most common reason people end up doing USB passthrough on a homelab Proxmox box, so it’s worth walking through directly. If Home Assistant runs as a VM (the HAOS appliance image), use standard VM USB device passthrough, vendor:product ID plus serial if the stick has one, port passthrough if it doesn’t. If Home Assistant runs in a Docker container inside an LXC (the more resource-efficient option covered in this site’s Home Assistant article), use the LXC bind-mount-plus-cgroup method above, and then pass that same device path through again from the LXC into the Docker container with --device=/dev/ttyUSB0 or the equivalent Compose devices: entry, two layers of passthrough stacked, both needing to agree on the same stable path.

Either way, resist the temptation to just grab whatever /dev/ttyUSB0 happens to be assigned today and hardcode it. The first time the host reboots with a different USB enumeration order, or someone adds a second USB-serial device to the box, that number can silently point at the wrong hardware, and Home Assistant either fails to start its Zigbee integration or, worse, connects to nothing and never complains.

Gotchas worth knowing before you build around this

  • USB hubs and power management. A dongle behind a powered or unpowered USB hub can drop out under load in ways that look like a Proxmox passthrough bug but are actually a power delivery problem. Prefer a direct root port for anything latency- or reliability-sensitive, especially Zigbee/Z-Wave radios that need to stay connected continuously.
  • Host-side USB autosuspend can put idle devices to sleep and introduce a reconnect delay when they wake, which shows up as intermittent dropouts in the guest. Disabling autosuspend for a specific known device (via a udev rule setting power/control to on) is a more surgical fix than disabling it globally.
  • Unprivileged LXCs need the cgroup allow entry every time, this is the step people forget when a bind mount “isn’t working,” the mount succeeds but the container still can’t open the device because the cgroup layer denies it by default.
  • Rebuilding a container or VM from a template drops the passthrough config. If you’re using cloud-init templates or any other reproducible-build workflow for your guests, the USB passthrough lines need to be reapplied (or scripted) after every rebuild, they don’t travel with the base template.
  • Document which physical port is “the Zigbee port.” A small label on the case or a line in your own notes saves the next troubleshooting session from becoming an archaeology project, especially once port-based passthrough is in the mix.

USB passthrough isn’t hard once you’ve done it once, but nearly every “it worked yesterday” complaint traces back to relying on an identifier that was never actually stable. Pick vendor:product ID plus serial when you can, pin to a physical port when you can’t, and the rest of the setup holds up across reboots, replugs, and rebuilds.