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 coordinatesAZIMUTH_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.
| Variable | Maps to | Default | Notes |
|---|---|---|---|
AZIMUTH_API_URL | api.url | https://azimuth.day | Azimuth backend base URL |
AZIMUTH_API_KEY | api.key | (empty) | Required to register and earn; from the azimuth.day dashboard |
AZIMUTH_NODE_ID | node.id | (empty) | Leave empty — the node auto-registers and saves its assigned id |
AZIMUTH_NODE_LABEL | node.label | azimuth-docker-node | Human-readable node name |
AZIMUTH_HARDWARE_TYPE | node.hardware_type | tier1_rtlsdr | tier0_mobile / tier1_rtlsdr / tier2_gpsdo |
AZIMUTH_NODE_LAT | node.location.latitude | 0.0 | Set your real latitude |
AZIMUTH_NODE_LON | node.location.longitude | 0.0 | Set your real longitude |
AZIMUTH_NODE_ALT | node.location.altitude_m | 0.0 | Altitude in meters |
AZIMUTH_STATUS_BIND | daemon.status_bind | 0.0.0.0:8420 | Status server bind (keep 0.0.0.0 in a container) |
AZIMUTH_HEARTBEAT_SECS | daemon.heartbeat_interval_secs | 15 | |
AZIMUTH_SUBMIT_SECS | daemon.submit_interval_secs | 30 | |
AZIMUTH_SDR_SAMPLE_RATE | sdr.sample_rate | 2048000 | Hz |
AZIMUTH_SDR_GAIN | sdr.gain | -1 | -1 = automatic gain control |
AZIMUTH_REGION | region | AUTO | ISO country code (US, DE, JP, …) or AUTO for worldwide scanning. See Regions & Bands. |
AZIMUTH_CONFIG_PATH | (config file location) | /var/lib/azimuth/config.toml | Where 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:
- 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 gidgroup_add: - "46" # the numeric gid from above - Run as root — add
user: "0:0"to the service. - 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:
/statusis unauthenticated.docker-compose.ymlbinds it to127.0.0.1on 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
lsusbentry → cable/port problem (use a powered USB hub on Raspberry Pi). lsusbshows it but the container can't open it → the DVB kernel driver claimed it (see section 2) or the gid ingroup_addis 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_addmust be"0"on WSL2. The shipped"46"is the plugdev gid on Debian / Raspberry Pi OS; inside WSL2 the USB device nodes are ownedroot:rootwith mode 664, so the container user needs the root group (orprivileged: 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
/statusendpoint 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.ymlwith 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.