Build your own LoRa-APRS dashboard

Turn the syslog stream from your LoRa-APRS iGate into a live, self-hosted Grafana dashboard — coverage maps, signal quality, DX records, packet rates and telemetry — using Node-RED, InfluxDB and Grafana. Everything runs on your own LAN. No cloud, no accounts, no FQDN required.

Source: iGate syslog (UDP) Parse: Node-RED Store: InfluxDB 2.x Visualise: Grafana 100% local

§What you'll build

If you run a LoRa-APRS iGate (for example the popular CA2RXU_LoRa_iGate ESP32 firmware), it can emit a syslog feed: one UDP line for every packet it hears, transmits, or forwards. That feed is a goldmine — it contains the heard callsign, signal strength, SNR, frequency offset, decoded position, digipeat path and more.

This guide captures that feed and builds a per-iGate analytics dashboard, similar in spirit to lora-aprs.live but entirely under your control. You'll get:

  • Coverage & distance maps — every station you've heard, plotted, with reception lines back to your gateway colour-coded by SNR.
  • Link quality — RSSI / SNR per station, packet rate, CRC error rate.
  • DX records — your furthest direct and via-digipeater contacts.
  • Telemetry — battery of heard mobiles, SNR-vs-distance scatter, a raw packet log.
This guide assumes a single local iGate. We deliberately skip the multi-tenant, public-internet, self-service onboarding layer (reverse proxies, automation, invites, per-owner logins). You're building your dashboard, on your network.

Get the files

Three sanitised files power this build. Every credential, hostname and callsign in them is a placeholder — you'll replace them as you go.

parser.js — the Node-RED function nodered-flow.json — importable flow dashboard.json — the Grafana dashboard

§How it works

Four moving parts. Each does one job and hands off to the next:

iGate (ESP32) ──syslog/UDP──▶ Node-RED ──HTTP write──▶ InfluxDB ◀──Flux query── Grafana | | | | emits one line parses each line stores time-series draws panels per packet into structured data (measurement/tags/fields) from the data

Why this stack?

ComponentJobWhy this one
Node-REDReceive UDP, parse the messy syslog text into clean fields.A UDP listener + a JavaScript function node is all you need. Visual, easy to tweak, no compiling.
InfluxDB 2.xStore every packet as a timestamped point.Purpose-built time-series DB: fast range queries, retention policies, and Flux for the map/DX maths.
GrafanaQuery InfluxDB and render maps, charts, tables.Native geomap panels + a first-class InfluxDB/Flux datasource. The dashboard is just JSON you import.

The data model (learn this — everything downstream depends on it)

InfluxDB stores points. Each point has a measurement, some tags (indexed labels you filter/group by), some fields (the actual values), and a timestamp. Our model:

PartValue(s)Meaning
measurementaprs_syslogEverything lives here.
tag callsigne.g. G7XYZ-7The station that was heard.
tag gatewaye.g. N0CALL-10The iGate that received it (= your station).
tag categoryRX / TX / TCPIP / SYSTEMHeard on RF / sent by you / via internet / housekeeping.
fieldsrssi snr freq_offset battery lat lon distance_km digipeated message raw tocall path altitudeThe measured values for that packet.
Why callsign ≠ gateway: a packet from G7XYZ-7 heard by your iGate stores callsign=G7XYZ-7, gateway=N0CALL-10. Keeping them as separate tags is what lets the dashboard say "show me everything my gate heard" and later, if you add more gates, compare coverage between them.

A raw syslog line looks like this

…which the parser turns into this InfluxDB line

One packet → many field-rows at one timestamp. InfluxDB stores each field as its own row. A naïve count() therefore over-counts packets ~13×. Throughout the dashboard we count distinct timestamps instead — remember this when you write your own queries.

§Prerequisites

This guide picks up after the three services are installed. Each project has good install docs (Docker images exist for all three) — follow those, then come back. You need:

You haveReachable at (example)You can…
InfluxDB 2.xhttp://INFLUX_HOST:8086log into its web UI as a user that can create buckets & tokens
Node-REDhttp://NODERED_HOST:1880open the flow editor and install nodes
Grafana 11+http://GRAFANA_HOST:3000log in as an admin/editor
A LoRa-APRS iGateon your LANchange its syslog target IP/port in its web config
Replace the UPPERCASE_PLACEHOLDERS with your own values everywhere. INFLUX_HOST, NODERED_HOST and GRAFANA_HOST are usually LAN IPs like 192.168.1.50. N0CALL-10 is a stand-in for your iGate's callsign-SSID.
Tip — pin your services' IPs. Give each host a DHCP reservation (or static IP) so the URLs you configure don't break on reboot.
STEP 1

1InfluxDB — somewhere to put the data

We create an organisation, one bucket, and an API token Node-RED will use to write.

1.1 Create the organisation & bucket

In the InfluxDB UI:

  1. If this is a fresh install, the setup wizard creates your first organisation and user. Name the org aprs (or anything — just be consistent).
  2. Go to Load Data → Buckets → Create Bucket.
  3. Name it after your iGate's callsign-SSID, e.g. N0CALL-10. Set retention to whatever you like — 30 days is a sensible start.
Why name the bucket after the callsign? It keeps each gateway's data cleanly separated. If you ever run a second iGate, it gets its own bucket and the same dashboard works for it by changing a single variable (see Step 3). One gate today, painless to scale later.

1.2 Create an API token

  1. Go to Load Data → API Tokens → Generate API Token → Custom API Token.
  2. Give it Read + Write on your N0CALL-10 bucket.
  3. Copy the token now — InfluxDB shows it only once.
Treat the token like a password. It grants write (and read) access to your data. Don't paste it into screenshots, public gists, or anywhere outside your own config. In this guide it always appears as YOUR_INFLUX_TOKEN.

1.3 Confirm it works

From any machine on the LAN, write one test point. Substitute your host, bucket, org and token:

A 204 No Content response means success. You can delete the TEST-1 point later, or just let retention expire it.

STEP 2

2Node-RED — receive & parse the syslog

Node-RED listens on a UDP port, runs each line through a parser function, and POSTs the result to InfluxDB. You can import the ready-made flow, or build it node-by-node.

2.1 Point your iGate at Node-RED

In your iGate's web configuration, find the syslog settings and set the destination to your Node-RED host and a port of your choice (this guide uses 1234):

iGate syslog fieldSet to
Server / HostNODERED_HOST (the LAN IP running Node-RED)
Port1234
ProtocolUDP
It must be UDP. Syslog is UDP. If your iGate or any port-forward in between uses TCP, the connection may "succeed" but every packet is silently dropped. Double-check this — it's the single most common reason nothing shows up.

2.2 Import the flow

  1. In Node-RED, top-right ☰ menu → Import.
  2. Paste the contents of nodered-flow.json (or "select a file to import").
  3. Click Import. You'll get a tab with four nodes: udp inSYSLOG PARSERInfluxDB write (+ two debug nodes).

The importable flow:

2.3 Edit the two things you must change

  1. The parser function — double-click the SYSLOG PARSER node. At the very top are three values. Set them to your token, org and bucket:
const INFLUX_TOKEN = "YOUR_INFLUX_TOKEN";   // from Step 1.2
const ORG          = "aprs";                // your org name
const BUCKET       = "N0CALL-10";           // your gateway callsign = bucket name
  1. The write node URL — double-click InfluxDB write and change INFLUX_HOST in the URL to your InfluxDB IP. Leave the {{org}} / {{bucket}} mustaches as-is — the parser fills them in per message.
Click Deploy (top-right) after any change, or nothing takes effect.

2.4 The parser, explained

This is the heart of the system. It receives one raw syslog line in msg.payload and returns InfluxDB line protocol. Read the comments — every block earns its place:

The parts worth understanding

  • Category & heard callsign — read from the line prefix (- RX /, - TX /, ,TCPIP,). This is who was heard and how.
  • Coordinates are anchored to LATN / LONE.
    Why the " / " separator matters: APRS frames often relay other stations' or objects' positions. A loose "grab any number followed by N/E" regex will happily pin your fixed iGate wherever some passing relayed object happened to be. Real first-party positions in this firmware are wrapped as 52.34N / 0.12W; relayed ones lack that ` / ` structure, so anchoring to it skips them. This one detail is the difference between a clean map and your gateway scattered across the country.
  • Everything numeric is written as a FLOAT (no i suffix in line protocol).
    Why: InfluxDB refuses to store an integer and a float in the same field — you'd get a "field type conflict" the first time a value happened to be whole. The Grafana queries also do float() maths on these. Keep them all floats and you never hit it.
  • The gateway is the syslog HOSTNAME (3rd token of the RFC5424 line). We sanity-check it looks like CALLSIGN-SSID and return null otherwise — junk and obviously-spoofed lines never reach the database.

2.5 Verify data is flowing

  1. In the parser flow, enable the line protocol debug node (click its green button) and open the debug sidebar (🐞 top-right).
  2. Wait for your iGate to hear a packet (or beacon its own position). You should see aprs_syslog,... lines appear.
  3. The write result debug should stay quiet (a 204 has no body). Now check InfluxDB: Data Explorer → your bucket → aprs_syslog — points should be arriving.
No data? Jump to Troubleshooting — the usual suspects are TCP-instead-of-UDP, a firewall blocking the UDP port, or a wrong token/bucket.
STEP 3

3Grafana — the dashboard

3.1 Add the InfluxDB datasource

Go to Connections → Add new connection → InfluxDB and configure it for Flux (not InfluxQL):

Set the datasource UID to aprs_influxdb. The shipped dashboard.json references its datasource by the UID aprs_influxdb. After saving the datasource, you can set/confirm its UID on the datasource page (Grafana lets you choose a custom UID). Match it and the dashboard imports with zero edits; otherwise you'd have to find-and-replace the UID in the JSON.

Click Save & test — Grafana should report it can reach the bucket.

3.2 Create a service-account token (for the API import)

This dashboard uses Grafana's newer dashboard schema, which is imported via the API rather than the classic "paste JSON" box. You need a short-lived token:

  1. Administration → Users and access → Service accounts → Add service account.
  2. Role: Editor. Then Add service account token and copy it.
This token can edit dashboards. Keep it private; it appears here only as YOUR_GRAFANA_TOKEN. Revoke it when you're done importing if you like.

3.3 Push the dashboard

From the folder containing dashboard.json, run (substitute host + token):

A 201 Created means it's in. Open Grafana — you'll find the LoRa-APRS iGate Monitor dashboard. If your datasource UID and bucket name match, panels populate immediately.

Prefer clicking? Some Grafana builds also accept this JSON via Dashboards → New → Import. If your version rejects the schema in the UI, use the API command above — it's the reliable path.

3.4 The one variable that matters: ${bucket}

The dashboard has a hidden variable called bucket, preset to N0CALL-10. Every panel reads from from(bucket: "${bucket}"), and the gateway's own identity is derived from it (so "my own beacons" filters target the right station automatically).

If your bucket isn't literally N0CALL-10, edit the variable: Dashboard settings → Variables → bucket.

Set all three fields to your bucket name: the variable's Custom options / value, the current value, and the underlying query string. Grafana re-parses the options list on load; if you change only the displayed value but leave the option list at N0CALL-10, your selection isn't a valid option and the dashboard silently falls back to the default. Setting the bucket name in all three keeps them consistent. (Easiest: just name your bucket N0CALL-10 and skip this.)

§The dashboard, panel by panel

The dashboard is organised into sections — Maps & Coverage, Activity, Signal & RF, Telemetry, Logs. Here's what each panel tells you and the clever bits behind a few of them.

PanelWhat it shows
Latest Distance & RSSI MapGeomap of each station's most recent position, sized/coloured by signal.
Coverage (heard positions)Every position you've received in the window — your real-world footprint.
Reception Lines (SNR)Lines from your gateway to each heard station, colour-coded by SNR.
Location over TimeRecency trail of moving stations within the selected time window.
Station Activity / Packet RateWho's busiest; packets-per-window over time.
Link Quality / Avg Link QualityRSSI & SNR per station (latest, and averaged as bar gauges).
Packet Category / CRC Error RateRF vs internet vs system mix; proportion of corrupt receptions.
Furthest Station (DX)Your record distance, split direct vs via-digipeater.
SNR vs DistanceScatter showing how signal degrades with range — your propagation fingerprint.
Last Heard / Recent Packets / BatteryFreshness table, raw packet log, and battery of heard mobiles.

Counting packets correctly

Remember one packet writes ~13 field-rows. The activity query counts unique timestamps per callsign, not rows:

Finding "my own" position safely (the map cold-start guard)

Map panels need to know where your gateway is. Since the gateway's callsign equals the bucket name, that's callsign == "${bucket}". But on a brand-new bucket, before your iGate has beaconed its position, naïvely calling findRecord() on an empty table errors the whole panel red. The fix is to union in a harmless sentinel row so there's always something to find:

Why bother: without this, a fresh install shows scary red errors on the map panels until the first position beacon. With it, panels just say "No data" until real data arrives — far less alarming, and self-healing.

Defensive coordinate fixing

You'll notice fixLat/fixLon helpers in the map queries that convert a value to degrees if |lat| > 90.

Why: raw APRS positions are historically DDMM.mm (degrees-minutes), not decimal degrees. Our parser already stores decimal, so for fresh data these helpers are no-ops — but they make the dashboard robust against any legacy or oddly-formatted point that slips through, and a DDMM value read as degrees produces absurd ~18,000 km distances.

§Troubleshooting

SymptomLikely cause & fix
No debug output in Node-RED at alliGate isn't sending, or sending to the wrong host/port, or it's using TCP not UDP. Confirm the iGate's syslog config; check a firewall isn't blocking UDP/1234 on the Node-RED host.
Parser shows lines, but InfluxDB is emptyWrong token/org/bucket, or the write URL still says INFLUX_HOST. Run the Step 1.3 curl test directly. A 401 = bad token; 404 = bad bucket/org.
"field type conflict" on writeA field was written as int once and float another time. Don't add i-suffixed integers; keep all numerics float (the supplied parser already does).
Grafana panels say "No data"Datasource UID ≠ aprs_influxdb, the ${bucket} variable doesn't match your bucket, or the selected time range predates your data. Check all three.
Map panels are red / erroredEmpty bucket before the first position beacon. The guarded query (above) avoids this; wait for your iGate to beacon, or widen the time range.
Your fixed iGate appears all over the mapYou're using a looser coordinate regex than the supplied parser. Use the anchored LATN / LONE match so relayed positions are skipped.
Distances of ~18,000 kmA DDMM coordinate was read as decimal degrees. The fixLat/fixLon helpers and the parser's |lat|>90 guard handle this.

§Going further

  • Keep your lora-aprs.live feed too. Many iGates only send syslog to one place. If yours points at a public aggregator and you don't want to lose that, add a second udp out node in Node-RED wired off the same udp in, forwarding the untouched packet to the aggregator's host:port. You then keep both feeds.
  • Tune retention. Bump the bucket's retention in InfluxDB if you want longer history (storage permitting). 30–90 days is plenty for trend analysis.
  • Add a second iGate. Give it its own bucket (named after its callsign), point its syslog at the same Node-RED, and clone the dashboard — changing only the ${bucket} variable. The same parser handles any number of gates, because it routes by the syslog hostname.
  • Terrain profiles. A natural next project: feed gateway↔station great-circle paths through an elevation API (e.g. a self-hosted OpenTopoData) and plot the terrain cross-section to explain why some links work and others don't.
That's it — you have a fully self-hosted LoRa-APRS analytics stack. Everything stays on your LAN, and every credential in these files is yours to fill in.