This guide walks through building a secure, maintainable radio services host using RTL-SDR devices and Docker Compose while maintaining least privilege at every layer.
The design supports:
- ADS-B reception (1090 MHz and 978 MHz)
- APRS receive-only decoding (2 m and 70 cm)
- APRS Internet-to-RF gating (separate Dire Wolf instance)
- Meshtastic
- A weather station
The key goals are:
- No containers running as root
- No applications running as root
- Stable, serial-based USB device naming
- Tightly scoped device access per container
- Composable services rather than a single monolithic container
Architecture Overview
Each radio function runs in its own container, but the stack is managed as a single logical unit using Docker Compose. This preserves isolation while keeping operations simple.
- ADS-B container: 1090 MHz + 978 MHz RTL-SDR receivers
- Dire Wolf RX container: APRS VHF + UHF receive-only
- Dire Wolf IS→RF container: gated, filtered Internet-to-RF traffic
- Meshtastic container: LoRa mesh device via USB serial
- Weather container: USB or RF-based sensors
Containers never see raw USB buses. Each container only receives the specific device node it requires.
Why Stable USB Naming Matters
RTL-SDR devices are indistinguishable by default. Enumeration order can change across reboots, kernel updates, or even simple re-plugs.
The correct solution is:
- Program a unique serial number into each RTL-SDR
- Use udev rules to create stable, human-readable device names
- Grant permissions by group, not by user or blanket device access
A detailed background discussion and rationale for this approach is available here:
Stable USB Names for RTL-SDR and Related Devices
Step 1: Create Service Users and Device Groups
Create one group to own SDR devices and separate non-login users for each service class. This avoids privilege sharing between unrelated services.
# Group that owns RTL-SDR device nodes
sudo groupadd --system rtlsdr
# Service users (no shell, no home directories)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid rtlsdr direwolf
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid rtlsdr adsb
sudo useradd --system --no-create-home --shell /usr/sbin/nologin meshtastic
sudo useradd --system --no-create-home --shell /usr/sbin/nologin weather
These users will never log in interactively. They exist solely to map numeric UID/GID values into containers.
Step 2: Assign Unique RTL-SDR Serial Numbers
Each RTL-SDR must have a unique serial number burned into EEPROM.
sudo apt-get update
sudo apt-get install -y rtl-sdr
# Verify detection
rtl_test -t
# Program serials (one device at a time, replug after each)
sudo rtl_eeprom -s ADSB1090
sudo rtl_eeprom -s ADSB978
sudo rtl_eeprom -s APRS2M
sudo rtl_eeprom -s APRS70
These serial values will be referenced directly in udev rules.
Step 3: udev Rules for Stable Device Names
Create a dedicated directory for SDR symlinks and bind permissions tightly.
sudo mkdir -p /dev/rtl-sdr/by-id
sudo tee /etc/udev/rules.d/20-rtl-sdr.rules >/dev/null <<'EOF'
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", \
GROUP="rtlsdr", MODE="0660"
SUBSYSTEM=="usb", ATTR{serial}=="ADSB1090", \
SYMLINK+="rtl-sdr/by-id/rtl_adsb1090"
SUBSYSTEM=="usb", ATTR{serial}=="ADSB978", \
SYMLINK+="rtl-sdr/by-id/rtl_adsb978"
SUBSYSTEM=="usb", ATTR{serial}=="APRS2M", \
SYMLINK+="rtl-sdr/by-id/rtl_aprs2m"
SUBSYSTEM=="usb", ATTR{serial}=="APRS70", \
SYMLINK+="rtl-sdr/by-id/rtl_aprs70"
EOF
sudo udevadm control --reload-rules
sudo udevadm trigger
Verify:
ls -l /dev/rtl-sdr/by-id/
Step 4: Block Kernel DVB Drivers (If Needed)
Some systems automatically bind RTL-SDR devices to DVB drivers, preventing userspace SDR tools from accessing them.
sudo tee /etc/modprobe.d/rtl-sdr-blacklist.conf >/dev/null <<'EOF'
blacklist dvb_usb_rtl28xxu
blacklist rtl2832
blacklist rtl2830
EOF
sudo update-initramfs -u
# Reboot recommended
Step 5: Rootless Docker
Docker itself should run without root privileges. Rootless Docker ensures containers cannot escape to host root even if compromised.
sudo apt-get install -y uidmap dbus-user-session slirp4netns fuse-overlayfs
dockerd-rootless-setuptool.sh install
loginctl enable-linger "$USER"
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
docker info
Device access still works because udev grants permissions to the owning group.
Step 6: Directory Layout
/opt/radio-stack/
docker-compose.yml
.env
direwolf/
rx/
direwolf.conf
inet2rf/
direwolf.conf
meshtastic/
config/
weather/
config/
Configuration files should be mounted read-only into containers.
Step 7: Docker Compose (Non-Root Containers)
services:
adsb:
image: ghcr.io/sdr-enthusiasts/docker-adsb-ultrafeeder
user: "UID_ADSB:GID_RTLSDR"
devices:
- /dev/rtl-sdr/by-id/rtl_adsb1090:/dev/rtl1090
- /dev/rtl-sdr/by-id/rtl_adsb978:/dev/rtl978
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
direwolf_rx:
image: ghcr.io/wb2osz/direwolf
user: "UID_DIREWOLF:GID_RTLSDR"
devices:
- /dev/rtl-sdr/by-id/rtl_aprs2m:/dev/rtl2m
- /dev/rtl-sdr/by-id/rtl_aprs70:/dev/rtl70
volumes:
- ./direwolf/rx:/etc/direwolf:ro
read_only: true
cap_drop: [ALL]
direwolf_inet2rf:
image: ghcr.io/wb2osz/direwolf
user: "UID_DIREWOLF:GID_RTLSDR"
volumes:
- ./direwolf/inet2rf:/etc/direwolf:ro
read_only: true
cap_drop: [ALL]
Additional containers (Meshtastic, weather) follow the same pattern: non-root user, only required devices, read-only filesystem.
Why This Is Better Than a Single Container
- Each service has a minimal device attack surface
- Failures are isolated
- Upgrades can be staged independently
- Security boundaries are explicit and auditable
A single container is technically possible, but it collapses these boundaries and undermines least-privilege design.
Conclusion
By combining stable USB naming, udev-based permissions, rootless Docker, and single-purpose containers, this architecture achieves:
- Predictable hardware behavior
- Strong containment
- Operational clarity
- Long-term maintainability
This approach scales cleanly as additional radios or services are added, without re-architecting the system.
No comments:
Post a Comment