A satellite control room from 100% open data, self-hosted in Europe

Live demo: overwatch.confinia.io — a MapLibre globe with 23 satellites moving in real time, each one clickable into Grafana dashboards showing battery voltages, temperatures and currents decoded from their actual radio beacons. Every byte of it comes from open data, and the whole thing runs on one rootless-podman server in Europe.

This post is about the four design decisions that made it work — and one upstream surprise that accidentally proved the project's whole point.

The stack in one diagram

CelesTrak ─┐                ┌─ MapLibre globe (Flask) ── reads cache only
SatNOGS  ──┤─►  ingest  ─►  Postgres                        │
           (the ONLY thing           └─ Grafana ────────────┘
            touching upstream)          embedded per-satellite dashboards

Orbits come from CelesTrak two-line elements, propagated locally with SGP4 every 15 seconds. Telemetry comes from SatNOGS, the Libre Space Foundation's global network of volunteer ground stations. The basemap is Sentinel-2 cloudless by EOX — real Copernicus imagery, served from Europe. Postgres caches everything; the browser and Grafana read only from that cache.

Decision 1: the caching boundary is the architecture

Exactly one service is allowed to call an external API. CelesTrak firewalls IPs that pull more than ~100 MB/day and asks you to fetch each dataset once per update — a naive "fetch on every map refresh" design gets you banned, and deserves to be. So the ingest service fetches elements on a 6-hour cadence, propagates positions locally (no network involved), and polls telemetry every 30 minutes with cursor pagination — "give me only frames newer than what I have." When the demo hit the front of a busy Slack channel, visitor traffic touched nothing upstream. Politeness and scalability turn out to be the same property.

Decision 2: decode the radio frames yourself

Mid-project, SatNOGS's API changed: it still serves raw frames — hex blobs like 60A060A0A66E60A4A660… — but the decoded values now live only in their internal InfluxDB. For a day, my dashboards were empty and the fix options were: scrape someone else's Grafana, or decode the physics myself.

The answer was satnogs-decoders: 161 community-written Kaitai Struct definitions, each describing one satellite family's beacon layout down to the bit — "bytes 17–18: battery voltage, big-endian, ×0.0075 volts." My ingest now runs Decoder.from_bytes(bytes.fromhex(frame)) and flattens every numeric leaf into Postgres rows.

That outage was the best thing that happened to the project. A platform that depends on someone else's decoded data is a proxy; one that decodes raw downlinks locally is infrastructure. If you care about data sovereignty, the difference is everything — and it's also why a satellite with no public decoder (looking at you, NEMO-HD) simply can't be on my map. The honest fix is a .ksy file, not a fake dashboard.

Decision 3: normalize the chaos, keep the chaos too

Every decoder names things differently: csp_data_payload_eps_vbatt, raw_frame_rt_tlm_batt_a_v, beacon_types_type_check_battery_voltage — and units vary between volts and millivolts. Dashboards written per-satellite don't scale past three satellites. So ingest derives canonical fields (battery_v, battery_i, battery_pct, with heuristic mV/mA scaling) next to the raw ones. One battery panel now works for the whole fleet, while the raw fields stay queryable for anyone who wants the real thing. A protocol-noise filter also drops AX.25 headers, SSIDs and checksums at ingest — nobody's health dashboard needs dest_ssid_raw_hbit.

Decision 4: fuse position and telemetry — that's the product

Two features fall out of having orbits and telemetry in the same database, and they're the two I'd show a satellite operator first:

Battery vs orbital sunlight. During propagation, a cylindrical Earth-shadow test against a low-precision solar ephemeris marks every position sample as sunlit or eclipsed. Overlaying that on battery voltage shows charge/discharge cycles tracking orbital daylight — spacecraft physiology, visible in one graph, from amateur radio receptions.

Who heard whom. Every SatNOGS frame names its receiving station, and most station names embed a Maidenhead grid locator (KM7DOS-CN87xi). Decode the locator and you can draw the reception network on the globe: this satellite, heard from Kitakyushu, Buenos Aires and a garden in Bavaria, with lines to where the satellite was at each reception. It's a live map of the volunteer ground-segment — and of its gaps.

Running it sovereign

The whole stack is rootless podman on one Debian dedicated server: Postgres, ingest, Flask, Grafana, an OpenTelemetry collector and Prometheus for access observability (public dashboards stay anonymous-viewer; the ops folder is admin-only). A shared Caddy edge terminates TLS with per-project vhost files. GitHub Pages hosts a static mirror of the frontend, but every byte of data it shows still comes from the European server — a CDN outage would take down the shell, not the data; a data-sovereignty audit has exactly one machine to look at.

Honest limits

Coverage is what volunteers make it: a satellite charts only when it broadcasts in the clear and someone's antenna heard it. Of ~1,700 alive satellites in the SatNOGS catalog, my weekly sweep found 23 currently meeting that bar — that number is the true state of open satellite telemetry, not a limitation of the stack. Ground tracks use a spherical Earth (fine for dots on a map, not for targeting). And satellites render at ground level for now; elevation-true rendering above the globe is next.

What's next

Multi-tenant embedding with proper short-lived-token auth (the anonymous viewer is a POC seam, deliberately), a small SDR ground station to stop depending exclusively on other people's antennas, and conversations with anyone who operates satellites — or any European team that wants live operational dashboards embedded in their product without a US hyperscaler in the data path. That last sentence is the actual business; the satellites are the proof it works.

*Clément Igonet — confinia.io · contact@confinia.io. Data: CelesTrak, SatNOGS/Libre Space Foundation, Copernicus/EOX. Built with MapLibre GL, Grafana, PostgreSQL, podman.*