A minimal Dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "index.js"]
Nothing serial-specific here — the binding compiles itself during npm install. The node:20 base already has the C++ toolchain.
Run with a device
$ docker run --device /dev/ttyUSB0 \
-e SERIAL_PORT=/dev/ttyUSB0 \
my-app
The --device flag is the magic. Without it, your app sees an empty /dev and SerialPilot.list() returns nothing.
Multiple devices
$ docker run \
--device /dev/ttyUSB0 \
--device /dev/ttyACM0 \
-e SERIAL_PORT=/dev/ttyUSB0 \
my-app
Repeat the flag for each device. There's no glob form — explicit beats implicit when only some tty* devices belong to your app.
udev rules for stable names
Linux assigns device paths in plug order, so /dev/ttyUSB0 can change between reboots. Pin a stable symlink with udev:
# /etc/udev/rules.d/99-serial.rules
SUBSYSTEM=="tty", ATTRS{idVendor}=="2341", ATTRS{idProduct}=="0043", SYMLINK+="arduino"
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="ftdi"
Reload udev (sudo udevadm control --reload + replug), and your container can now --device /dev/arduino.
Or skip paths entirely
Inside the container, find by VID/PID:
const [info] = await SerialPilot.findPorts({ vendorId: '2341' })
const port = new SerialPilot({ path: info.path, baudRate: 115200 })
Pair this with --device-cgroup-rule on the host to permit the container to see any device matching a class — useful when devices come and go.
docker-compose
services:
app:
build: .
devices:
- /dev/ttyUSB0:/dev/ttyUSB0
environment:
SERIAL_PORT: /dev/ttyUSB0
restart: unless-stopped
Gotchas
- Permissions: if the host has the device owned by
dialout, the container's UID needs that GID too. Easiest fix:--group-add dialoutondocker run. - Hot-plug:
--deviceis evaluated at start time — devices added later aren't visible. Restart the container after a replug, or mount the parent device class with cgroup rules. - Multi-arch builds: if you cross-build for ARM (Raspberry Pi etc), make sure
npm installruns inside the target image, not on your build host. The native binding has to match the runtime arch.
--privileged
It works, but it grants every device and capability. Stick with explicit --device + --group-add — narrower blast radius and the same outcome.