Running an Azimuth Node in Docker

This guide covers running the Azimuth Tier 1 SDR node daemon as a container: load a prebuilt image tarball (or build from source), download the official docker-compose.yml, configure it, and start earning. Maintained by the Azimuth team.

1. Prerequisites

  • Docker Engine 20.10+ with the Docker Compose plugin (docker compose version)
  • A Linux host (bare metal, Raspberry Pi 4/5, or a VM with USB passthrough)
  • RTL-SDR USB dongle (RTL2832U-based, e.g. RTL-SDR Blog V3) — optional: without hardware the node runs in stub mode, useful for verifying the setup
  • An API key from the azimuth.day dashboard (required to register and earn)
  • Your node's coordinates (latitude / longitude / altitude — Tier 1 nodes are stationary)

2. Prepare the host

The Linux kernel's DVB-T driver will claim an RTL-SDR dongle before the container can. Blacklist it once on the host:

echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf
sudo modprobe -r dvb_usb_rtl28xxu   # unload it now (or reboot)

Optionally install the host tools to confirm the dongle is visible before involving Docker at all:

sudo apt install rtl-sdr
rtl_test -t        # should list "Realtek, RTL2838UHIDIR" or similar

rtl_test must be stopped (Ctrl-C) before starting the container — only one process can own the dongle.

3. Get the image

Option A — load a released tarball (fastest)

# amd64 (x86_64)
curl -LO https://azimuth.day/downloads/azimuth-node-docker-amd64.tar.gz
docker load < azimuth-node-docker-amd64.tar.gz

# arm64 (aarch64, e.g. Raspberry Pi 4/5)
curl -LO https://azimuth.day/downloads/azimuth-node-docker-arm64.tar.gz
docker load < azimuth-node-docker-arm64.tar.gz

Both tarballs load the tags azimuth-node:latest and azimuth-node:0.2.5 — pick the tarball for your CPU architecture; the image name is the same either way.

Verify the download against the published azimuth-node-docker-sha256.txt:

curl -LO https://azimuth.day/downloads/azimuth-node-docker-sha256.txt
sha256sum -c azimuth-node-docker-sha256.txt --ignore-missing

Option B — build from source

git clone https://github.com/Azimuth-Official/azimuth-daemon.git
cd azimuth-daemon
docker build -t azimuth-node:latest .

4. Get the Compose file

Download the official Compose file into an empty directory:

mkdir azimuth-node && cd azimuth-node
curl -LO https://azimuth.day/downloads/docker-compose.yml

5. Configure

Open docker-compose.yml and set at minimum:

  • AZIMUTH_API_KEY — from the azimuth.day dashboard (required to register and earn)
  • AZIMUTH_NODE_LAT / AZIMUTH_NODE_LON / AZIMUTH_NODE_ALT — your real coordinates
  • AZIMUTH_NODE_LABEL — a name you'll recognize in the dashboard

On first boot the container generates /var/lib/azimuth/config.toml from the variables below and persists it on the azimuth-data volume (later boots reuse the saved file — the daemon also writes its assigned node.id back to it). To regenerate from the environment, run docker compose down -v && docker compose up -d (warning: -v deletes the volume, including the node identity). The generated config enables the multi-signal [scanning] section by default — see section 8. To use the full config (including the advanced [ntrip] and [gps] sections), mount your own config.toml at AZIMUTH_CONFIG_PATH instead.

VariableMaps toDefaultNotes
AZIMUTH_API_URLapi.urlhttps://azimuth.dayAzimuth backend base URL
AZIMUTH_API_KEYapi.key(empty)Required to register and earn; from the azimuth.day dashboard
AZIMUTH_NODE_IDnode.id(empty)Leave empty — the node auto-registers and saves its assigned id
AZIMUTH_NODE_LABELnode.labelazimuth-docker-nodeHuman-readable node name
AZIMUTH_HARDWARE_TYPEnode.hardware_typetier1_rtlsdrtier0_mobile / tier1_rtlsdr / tier2_gpsdo
AZIMUTH_NODE_LATnode.location.latitude0.0Set your real latitude
AZIMUTH_NODE_LONnode.location.longitude0.0Set your real longitude
AZIMUTH_NODE_ALTnode.location.altitude_m0.0Altitude in meters
AZIMUTH_STATUS_BINDdaemon.status_bind0.0.0.0:8420Status server bind (keep 0.0.0.0 in a container)
AZIMUTH_HEARTBEAT_SECSdaemon.heartbeat_interval_secs15
AZIMUTH_SUBMIT_SECSdaemon.submit_interval_secs30
AZIMUTH_SDR_SAMPLE_RATEsdr.sample_rate2048000Hz
AZIMUTH_SDR_GAINsdr.gain-1-1 = automatic gain control
AZIMUTH_REGIONregionAUTOISO country code (US, DE, JP, …) or AUTO for worldwide scanning. See Regions & Bands.
AZIMUTH_CONFIG_PATH(config file location)/var/lib/azimuth/config.tomlWhere the config is written/read

5b. Choosing Your Region

The daemon supports regional frequency scanning to optimize signal acquisition for your location. By default, AZIMUTH_REGION=AUTO scans worldwide frequencies. For faster convergence in your region, set it to an ISO country code:

environment:
  AZIMUTH_REGION: "US"  # or "EU", "JP", "DE", "AU", etc.

On first startup, the daemon generates config.toml with the region's LTE, DTV, and FM frequency lists. Later boots reuse the saved config; to change region, update the variable and regenerate:

docker compose down -v
docker compose up -d

Important: The V4 RTL-SDR hardware can tune from 500 kHz to 1.766 GHz maximum. LTE bands above 1.766 GHz (e.g. Band 3 at 1805 MHz) cannot be tuned by the hardware, even if your region lists them. The daemon includes all bands in its configuration but marks out-of-range bands untunable in the startup logs. See Regions & Bands for detailed band-by-region coverage and antenna guidance.

6. Start the node

docker compose up -d

7. What you should see

Follow the logs:

docker compose logs -f

A healthy first boot looks like:

[entrypoint] Generating /var/lib/azimuth/config.toml from AZIMUTH_* environment
INFO Config loaded from /var/lib/azimuth/config.toml
INFO SDR devices initialized devices=1 serials=["00000001"]
INFO Heartbeat loop started interval_secs=15
INFO Scanner observation loop started (interval 30s)
INFO Batch submitted submitted=4 accepted=4 total=4

On later boots the first line becomes [entrypoint] Using existing /var/lib/azimuth/config.toml (AZIMUTH_* env ignored on this boot). A node with an older persisted config (no [scanning] section) logs Legacy observation loop started instead — see section 8.

Check the status endpoint:

curl http://127.0.0.1:8420/status

Key fields in the JSON response: node_id, hardware_type, uptime_secs, observations_submitted, sdr_connected, device_count, scanning_enabled (true whenever observation sampling is enabled — in both modes, so not a mode indicator), signals_found, and config_error (non-null when the daemon started with a config problem — for example a missing API key).

8. Observation behavior in v0.2.5

Out of the box, a v0.2.5 Docker node runs the multi-signal scanner: FM demodulation with RDS detection and SNR, LTE cell detection (PSS/SSS correlation with physical cell ID), and ATSC DTV pilot detection, scheduled across signal types in a weighted round-robin (LTE-heavy by default — it is the most valuable signal for positioning). The config generated on first boot includes a [scanning] section with sensible defaults, so a new node shows Scanner observation loop started in the logs with zero tuning.

Nodes that already have a persisted config.toml (for example, one generated by a v0.2.3 container) are unaffected by an image upgrade — the entrypoint only generates config when the file does not exist. A config without a [scanning] section runs the legacy observation loop instead: a single frequency (100 MHz, FM broadcast band) submitted as fm_rds observations, with Legacy observation loop started in the logs. To move an existing node to the scanner, append a [scanning] section to its persisted config and restart — or regenerate from the environment with docker compose down -v && docker compose up -d (warning: -v deletes the volume, including the node identity).

To customize scanning (mode, weights, frequency lists), edit the persisted config (docker compose cp azimuth-node:/var/lib/azimuth/config.toml ., edit, copy back, restart) or mount your own file. The generated defaults are:

[scanning]
mode = "both"          # "config" = only the lists below; "sweep" = auto-discover; "both"
interval_seconds = 30
weight_lte = 3         # scan-time weight per signal type (0 disables a type)
weight_dtv = 2
weight_fm = 1
retune_delay_ms = 100
capture_retries = 3
max_observations_per_cycle = 20

[scanning.fm]
frequencies = []                 # add local station frequencies in Hz, or rely on sweep
sweep_start = 87_500_000
sweep_end = 108_000_000
sweep_step = 200_000
sweep_threshold = -20.0
sweep_min_snr_db = 8.0

[scanning.lte]
# Low-band LTE only (RTL-SDR tops out ~1.766 GHz). Common US low bands:
frequencies = [631_000_000, 637_000_000, 737_000_000, 881_000_000]

[[scanning.lte.sweep_bands]]
start = 617_000_000
end = 652_000_000
step = 5_000_000

[[scanning.lte.sweep_bands]]
start = 729_000_000
end = 746_000_000
step = 5_000_000

[[scanning.lte.sweep_bands]]
start = 869_000_000
end = 894_000_000
step = 5_000_000

[scanning.dtv]
frequencies = []                 # ATSC UHF channels; empty = rely on sweep
sweep_start = 470_000_000
sweep_end = 608_000_000
sweep_step = 6_000_000
sweep_threshold = -45.0

To mount your own config instead, create the host file first, then add the mount and restart:

# The file MUST exist before `docker compose up` — if it doesn't, Docker
# creates an empty DIRECTORY in its place and the container refuses to start.
# Either start from the generated config:
docker compose cp azimuth-node:/var/lib/azimuth/config.toml ./config.toml
# ...or start empty (the entrypoint fills an empty file from AZIMUTH_* env):
touch ./config.toml
    volumes:
      - azimuth-data:/var/lib/azimuth
      - ./config.toml:/var/lib/azimuth/config.toml
docker compose up -d --force-recreate

If you see FATAL: /var/lib/azimuth/config.toml is an empty DIRECTORY in the logs, the host file was missing at first start: run docker compose down, rm -rf ./config.toml, touch ./config.toml, then docker compose up -d.

The mounted file must stay writable by the container user (uid 10001) — the daemon writes its assigned node.id back into it. The log line tells you which mode is running: Legacy observation loop started means the single-frequency default, Scanner observation loop started means the multi-signal scanner. (The /status field scanning_enabled does not distinguish the modes — it reflects whether observation sampling is enabled at all, and is true in both.)

9. USB / RTL-SDR passthrough

The container reaches the RTL-SDR through /dev/bus/usb; the compose file maps it:

devices:
  - "/dev/bus/usb:/dev/bus/usb"

Because the container runs as the non-root azimuth user (uid 10001), that user must be in the host's USB device group. The group name plugdev is unreliable across the host/container boundary (the numeric gid rarely matches), so use one of:

  1. Numeric gid (recommended) — find the host gid and pass it:
    getent group plugdev        # e.g. -> plugdev:x:46:
    ls -n /dev/bus/usb/001/     # the numeric group column is the device's gid
    
    group_add:
      - "46"                    # the numeric gid from above
    
  2. Run as root — add user: "0:0" to the service.
  3. Privileged — add privileged: true (broadest access, least isolation).

Without hardware the daemon runs in stub mode: /status reports sdr_connected: false, device_count: 0, and the daemon does not crash — useful for verifying the container itself.

10. State & identity persistence

/var/lib/azimuth holds node-identity.json and the generated config.toml. Keep it on a named volume (the compose file does) so the node keeps its identity across restarts and does not re-register.

11. Health endpoint

GET /status returns JSON with SDR connection state, device count, uptime, signal counts, and any config_error. The image's HEALTHCHECK polls it, and the compose file repeats it so docker compose ps shows health.

Security: /status is unauthenticated. docker-compose.yml binds it to 127.0.0.1 on the host by default. Only expose it publicly behind an authenticating reverse proxy.

12. Troubleshooting

SDR not detected (sdr_connected: false with the dongle plugged in)

lsusb                        # on the HOST — look for "Realtek ... RTL2838"
docker logs azimuth-node     # look for device open errors
  • No lsusb entry → cable/port problem (use a powered USB hub on Raspberry Pi).
  • lsusb shows it but the container can't open it → the DVB kernel driver claimed it (see section 2) or the gid in group_add is wrong (getent group plugdev).
  • The dongle was replugged → restart the container (docker compose restart).

Permission denied on /dev/bus/usb — the group_add gid doesn't match the host's device group. Check ls -n /dev/bus/usb/001/ and use that gid. Quick test: set user: "0:0" temporarily; if that fixes it, it's the gid.

Container restarts in a loop

docker logs --tail 50 azimuth-node
docker images azimuth-node        # confirm the image actually loaded

config_error is set in /status — the daemon started but its config is invalid or incomplete (for example an empty API key, or a syntax error in a mounted config.toml). Fix the value, then docker compose down -v && docker compose up -d to regenerate (or fix your mounted file and docker compose restart).

USB pipe errors during scanning — increase retune_delay_ms to 150–200 in the [scanning] section.

Windows (Docker Desktop / WSL2)

Docker Desktop runs Linux containers inside WSL2, and the compose file works there — but USB passthrough needs extra setup, and RF capture has a hard limitation:

  • USB passthrough requires usbipd-win. Share the dongle once from an elevated prompt, then attach it to WSL:
    usbipd list                      # find the RTL-SDR's BUSID (VID 0bda)
    usbipd bind --busid <BUSID>      # requires Administrator, one-time
    usbipd attach --wsl --busid <BUSID>
    
  • group_add must be "0" on WSL2. The shipped "46" is the plugdev gid on Debian / Raspberry Pi OS; inside WSL2 the USB device nodes are owned root:root with mode 664, so the container user needs the root group (or privileged: true, which is less contained) to open the device.
  • RF capture is unreliable over USB/IP. The dongle enumerates and the tuner initializes, but the USB/IP transport's timing breaks tuning — expect PLL lock failures and pipe errors on frequency changes. The container, config generation, and /status endpoint all work, so Windows is fine for verifying a setup, but run production observation collection on native Linux (a Raspberry Pi or any x86 Linux box).

When a new image tarball is released:

curl -LO https://azimuth.day/downloads/azimuth-node-docker-amd64.tar.gz   # or -arm64
docker load < azimuth-node-docker-amd64.tar.gz
docker compose up -d --force-recreate

Your config and node identity live on the azimuth-data volume, so the updated container keeps the same node — no re-registration.

14. Removing everything

docker compose down                  # stop and remove the container
docker compose down -v               # ...and delete the volume (node identity is lost)
docker rmi azimuth-node:latest azimuth-node:0.2.5

15. Community Docker guides

The Azimuth community maintains independent Docker setups that take different approaches to running a Tier 1 node:

  • azimuth-d_compose — a community-maintained docker-compose.yml with a step-by-step installation guide, tested on a Raspberry Pi 4B with an RTL-SDR Blog V3/V4.

Community guides are independently maintained and not officially supported by the Azimuth team — for the official setup, follow the guide above. Built your own setup or guide? Reach out on Discord to have it listed here.