01 January 2026

Least-Privilege Radio Services Stack with RTL-SDR and Docker Compose

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