Docker Compose setup for a single Raspberry Pi 5 running an ADS-B flight tracking station. It feeds FlightRadar24 and FlightAware, and serves a local map and system graphs.
Built on the excellent images from sdr-enthusiasts.
No Docker Swarm — this is a plain
docker composedeployment on one host.
All services live in a single stack: flight-tracking/docker-compose.yml.
| Service | Image | Purpose |
|---|---|---|
ultrafeeder |
sdr-enthusiasts/docker-adsb-ultrafeeder |
ADS-B decoder, aggregator feeder, TAR1090 map, graphs1090 |
fr24 |
sdr-enthusiasts/docker-flightradar24 |
FlightRadar24 feeder |
piaware |
sdr-enthusiasts/docker-piaware |
FlightAware feeder |
airnavradar |
sdr-enthusiasts/docker-airnavradar |
AirNav Radar (ex-RadarBox) feeder |
planefinder |
sdr-enthusiasts/docker-planefinder |
Plane Finder feeder |
portainer |
portainer/portainer-ee |
Container management UI |
watchtower |
nickfedor/watchtower |
Automatic image updates + cleanup |
| Port | Service |
|---|---|
8080 |
Ultrafeeder web interface (TAR1090 map, graphs1090) |
8754 |
FlightRadar24 feeder status |
30053 |
Plane Finder web UI |
9443 |
Portainer UI (HTTPS) |
Only ultrafeeder touches the SDR. Every other feeder pulls from it over Beast
on the Compose network, so one dongle serves all of them.
Ultrafeeder feeds these natively, from one ULTRAFEEDER_CONFIG block — no
container each: adsb.fi, adsb.lol, airplanes.live, Planespotters, TheAirTraffic,
AVDelphi, FlyItalyADSB, ADSB Exchange.
The rest run their own container because they ship a closed feeder binary: FlightRadar24, FlightAware, AirNav Radar, Plane Finder.
Adding another native aggregator is one line in ULTRAFEEDER_CONFIG. Note that
ADS-B feeding is nearly free, but each mlat, line is a separate sync process —
CPU climbs with the MLAT count, not the ADS-B count.
fr24 is set to MLAT=no, which is what FR24 asks for when you feed other
networks. Two reasons it is the right call here:
- Timing. FR24's MLAT wants a direct, unmodified feed from the SDR for
accurate timestamps. In this stack
fr24reads a relayed Beast stream fromultrafeeder, so its MLAT solution would be degraded anyway. - Feedback loop.
READSB_FORWARD_MLAT_SBS=trueand the mlathub mean MLAT-derived positions from other networks are merged into ultrafeeder's output. If FR24 ran MLAT over that stream it would treat synthetic positions as real receptions and poison its own solver.
no is already the container's default; it is set explicitly so the intent is
obvious. There is no MLAT-without-gps variable in this container — that line
in FR24's docs refers to their own fr24feed.ini on a bare-metal install.
- Raspberry Pi 5
- RTL-SDR USB dongle + 1090 MHz antenna
Install the udev rules so the dongle is accessible without root:
sudo cp flight-tracking/60-rtl-sdr.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm triggerflight-tracking/temp.env is the template, committed with every key blank. On the Pi:
cd ~/git/Shipyard/flight-tracking && cp temp.env .envThen fill it in. .env is git-ignored; temp.env is not, so never put real
keys in the template.
Two of the values are generated rather than looked up:
# MULTIFEEDER_UUID and ADSBX_UUID - run once each, paste into .env
cat /proc/sys/kernel/random/uuidWhere the keys come from:
| Key | Source |
|---|---|
FR24_SHARING_KEY |
https://www.flightradar24.com/share-your-data |
FLIGHTAWARE_FEEDER_ID |
https://flightaware.com/adsb/piaware/ |
PLANEFINDER_SHARECODE |
https://planefinder.net/sharing/create-sharecode |
AIRNAV_SHARING_KEY |
generated on first run — see below |
AirNav Radar has no signup page for the key. Leave it blank, start the stack, and read the key the container generates on its first run:
docker compose logs airnavradar | grep -i keyPaste it into .env and docker compose up -d airnavradar again.
cd flight-tracking
docker compose pull
docker compose up -dUltrafeeder persists data to /opt/adsb/ultrafeeder/ on the host. The
containers create these directories on first run; to pre-create them:
sudo mkdir -p /opt/adsb/ultrafeeder/globe_history /opt/adsb/ultrafeeder/graphs1090Then open the map at http://<pi-address>:8080.
setup_remote.sh provisions the Pi from scratch: it clones this
repo to ~/git/Shipyard, installs ~/update_shipyard.sh, registers a daily
cronjob at 03:00 that pulls and redeploys, and runs the first deployment.
Run it on the Pi:
curl -fsSL https://raw.githubusercontent.com/TheQuietBlock/Shipyard/main/setup_remote.sh | bashThe script will not overwrite an existing flight-tracking/.env — copy yours
into place before or after the first run.
.
├── flight-tracking/
│ ├── docker-compose.yml # The whole stack
│ ├── 60-rtl-sdr.rules # RTL-SDR udev rules
│ ├── temp.env # Config template (committed, keys blank)
│ └── .env # Your config (git-ignored, cp from temp.env)
├── setup_remote.sh # Pi provisioning + auto-update cronjob
└── README.md
cd flight-tracking
docker compose ps
docker compose logs -f ultrafeeder
docker compose logs -f fr24
docker compose logs -f piaware- RTL-SDR not detected — check
lsusb, confirm the udev rules are installed, and make sure no host process (e.g.dump1090) is holding the dongle. - No aircraft on the map — verify the antenna connection and that
FEEDER_LAT/FEEDER_LONGare set; an unset location breaks MLAT. - Empty environment variables — Compose reads
.envfrom the directory you run it in, so rundocker composefrom insideflight-tracking/. - Auto-update log —
~/update_shipyard.logon the Pi.
This host boots from a microSD card, so the stack is tuned to write as little as possible:
- Capped container logs. Every service uses a shared
x-logginganchor —json-file, 5 MB × 2 files. Docker's default is unbounded, and a chatty container filling/var/lib/dockeris the fastest way to kill a card. LOGLEVEL=erroron ultrafeeder,DEBUG_LEVEL=0on airnavradar,VERBOSE_LOGGING=falseon fr24.tmpfsfor/var/logon every feeder, so in-container logs never touch the card at all.MAX_GLOBE_HISTORY=7trims aircraft trace retention.globe_historyis the busiest writer in the stack.
Two writers are deliberately left alone, because disabling them costs you real features:
graphs1090writes RRD data to/opt/adsb/ultrafeeder/graphs1090roughly every minute. You can move it to a tmpfs, but you lose all history on reboot.globe_historystill writes traces.READSB_ENABLE_TRACES=falseandREADSB_ENABLE_HEATMAP=falsewould stop it, at the cost of the heatmap and replay features on the map.
If you want to be properly safe about it, put /opt/adsb on an external SSD or
USB stick — that is a bigger win than any of the tuning above.
Upstream ships no hardening keys, so what is here is this repo's own:
no-new-privileges:trueon all five feeder containers.- Portainer exposes only
9443(HTTPS). Its plain-HTTP9000and the unused Edge agent port8000are not published —9000was an unencrypted admin interface on the LAN. - Portainer and Watchtower mount
/var/run/docker.sock, which is equivalent to root on this host. That is inherent to what they do; keep both off the public internet. /proc/diskstatsis mounted read-only.- No reverse proxy or TLS termination in this repo — everything is LAN-only.
Worth knowing: all images are pulled by floating tag (:latest or untagged)
and Watchtower auto-updates them nightly, so an upstream compromise reaches
this host without review. That is the normal trade for unattended patching, but
it is a trade.