Skip to content

Repository files navigation

NWS Alert Dashboard

A self-hosted National Weather Service alert monitor with three independent alert sources, cross-source deduplication, polygon maps, and flexible notifications, all in one Docker container.

Source Transport Latency Works offline? Needs
NWWS-OI XMPP push from the NWS Weather Wire Service seconds no free NWS credentials
NOAA Weather Radio RTL-SDR receiving NWR broadcasts (SAME/EAS decode) seconds yes ~$30 RTL-SDR dongle
NOAA REST API api.weather.gov polling ≤ poll interval no nothing

The same warning usually arrives on all three. The ingest core dedups them (VTEC event tracking plus SAME county/time heuristics), notifies exactly once via whichever source delivered first, and folds the richer details (headline, full text, storm polygon) into one alert record as they land. You get the alert as soon as the fastest source delivers it.

If your internet goes down, the radio path keeps working. Alerts are decoded off the air, the broadcast audio is recorded, and the map falls back to locally cached county boundaries. Nothing in the warning path depends on the cloud.

Push notifications still travel over your network, so a phone only receives them while it is on the same LAN as the container, and many phones drop a Wi-Fi network once they detect it has no internet. If you are counting on the radio path during an outage, pin a device to the local network or watch the dashboard directly, and keep a battery-backed weather radio as the real backstop.

Warning

Unofficial project. Not affiliated with, endorsed by, or supported by NOAA or the National Weather Service. Treat it as a supplementary monitor, never your only source of life-safety warnings. Software, hardware, radio reception, and network links can and do fail, delay, drop, or misreport alerts. Provided with no warranty. See Disclaimer and LICENSE.

Screenshots

Dark theme Light theme
Dashboard, dark theme Dashboard, light theme
Mobile push notification Installed PWA
Push notification on a phone Installed PWA on a phone

Features

  • Web dashboard (PWA): live-updating (SSE) active and historical alerts with source badges (RADIO / NWWS / API), full alert text, broadcast recordings, a live radio stream, searchable history, and interactive Leaflet maps served from a local tile cache. Light and dark themes, plus per-source health chips (tuned frequency, NWWS, API) that show at a glance when a source is down.
  • Animated radar: per-alert maps overlay time-correct NEXRAD radar for precip/convective events, looping across the event while it's active and replaying its timeframe afterward, with a pin marking your location. Online enrichment only; the offline notification maps are untouched.
  • Alert updates: in-place NWS revisions (reworded text, a tightened polygon, raised severity) update the alert and are flagged with an UPDATED badge and a collapsed revision history; optional re-notification on escalation (RENOTIFY_ON_UPDATE).
  • Broad event coverage: all SAME/EAS codes plus common non-EAS advisories (excessive heat, extreme cold, red flag, dense fog, wind, and so on) that only arrive via the API or NWWS sources. See the full event reference; tune the set in NOTIFY_EVENT_CODES.
  • Alert maps: a per-alert PNG rendered offline (Pillow over cached OSM tiles) and attached to notifications, with the storm polygon when available and county boundaries otherwise.
  • Notifications via Apprise: ntfy (with per-event priority and topic routing), Discord, Telegram, Pushover, email, and roughly 80 other services, plus browser web-push with per-device event filters.
  • MQTT publishing for Home Assistant automations.
  • Location auto-setup: set LOCATION=lat,lon and the county SAME code, UGC zones, and a radio frequency scan list are derived for you.
  • Frequency rotation with silence detection and primary failback, a rolling audio recorder, an Uptime Kuma heartbeat, and retention policies.

Requirements

Docker and Docker Compose on linux/amd64 or linux/arm64. The image builds natively on both, so a Raspberry Pi 4/5 running a 64-bit OS, an ARM VPS, or Apple silicon works the same as an x86 host. There is nothing to pick at build time and the compose file is identical, though the first build takes noticeably longer on a Pi, since multimon-ng is compiled in-image.

32-bit ARM (armhf / armv7l, e.g. Raspberry Pi OS 32-bit) is not supported: NumPy and Pillow ship no 32-bit ARM wheels, so pip falls back to long source builds needing toolchain packages the image does not carry. Reflash with the 64-bit OS; uname -m should print aarch64.

To cross-build on a faster x86 machine for a Pi, buildx with QEMU works:

docker buildx build --platform linux/arm64 -t nwsalertdashboard:arm64 .

Windows

Docker Desktop (WSL2 backend) runs the internet-only setup (NWWS-OI plus the REST API) with no changes to the compose file. Two things to get right up front:

  • Clone inside the WSL2 filesystem, not C:\. The alert store is SQLite in WAL mode, and WAL depends on file locking that is not reliable across the Windows drive mount (/mnt/c). Keep the repo somewhere like \\wsl$\Ubuntu\home\you\NWS-Alert-Dashboard, which is substantially faster too.
  • Line endings. Git for Windows defaults to core.autocrlf=true, and run.sh checked out with CRLF makes the container exit immediately with bad interpreter: /bin/bash^M. The repo ships a .gitattributes that forces LF, so a fresh clone is fine; fix an older one with git rm --cached -r . && git reset --hard.

The RTL-SDR source is the hard part on Windows. Docker Desktop cannot pass a USB device straight through: there is no /dev/bus/usb on a Windows host for the override file to bind. The dongle has to be bridged into WSL2 first with usbipd-win (usbipd bind, then usbipd attach), and since Docker Desktop runs the engine in its own docker-desktop distro rather than your default one, the attach generally has to target that distro. It also has to be re-attached after every reboot and every replug, which is awkward for something meant to run unattended.

That path is untested here; the radio source is only verified on Linux hosts. If you want the offline radio path, a cheap Linux box or a Raspberry Pi is much less trouble. Otherwise run Windows with RADIO_ENABLED=false: NWWS-OI and the API need no hardware at all.

Quick start (no SDR needed)

git clone https://github.com/robwolff3/NWS-Alert-Dashboard.git
cd NWS-Alert-Dashboard
cp .env.example .env
# edit .env: set LOCATION=lat,lon, API_USER_AGENT contact, and a
# notification target (NTFY_* or NOTIFY_URLS); set RADIO_ENABLED=false
docker compose up -d --build

Open http://host:8082. To verify notifications and map rendering end to end, uncheck Hide test alerts in the Alert History header to reveal the Send test alert button, then click it.

Adding the radio source

  1. Plug in an RTL-SDR dongle (RTL2832U). Find it: lsusb | grep -i RTL. (On a Windows host, read Windows first: the dongle has to be bridged into WSL2 and that path is untested.)
  2. cp compose.override.example.yaml compose.override.yaml and set the device path; set RTL_DEVICE in .env
  3. Set RADIO_ENABLED=true. Leave RADIO_FREQUENCY blank to scan all seven NWR channels until one decodes, or set your transmitter's frequency (transmitter search)
  4. docker compose up -d --build --force-recreate

Reception notes: keep RADIO_SQUELCH=0 (squelch can swallow SAME bursts), use rtl_test -p for the PPM correction, and expect the weekly RWT test (Wednesdays) as your end-to-end confirmation.

The image pins rtl-sdr 0.6.0-3 on purpose. The newer RTL-SDR Blog fork silently ignores -E deemp, which breaks FM de-emphasis on NWR. Debian published that version for amd64, arm64 and armhf alike, and the build resolves the right one from dpkg --print-architecture, so the pin holds on ARM too.

Adding NWWS-OI

  1. Apply for credentials (free; can take 30+ days)
  2. Set NWWS_ENABLED=true, NWWS_USER, NWWS_PASS

The client connects to either NWWS server (College Park or Boulder), alternates hosts on reconnect, and keeps the session alive with XMPP pings. While NWWS is connected the REST poller relaxes to API_POLL_SECS; when it drops, polling tightens to API_POLL_SECS_DEGRADED automatically.

Home Assistant (MQTT)

Set MQTT_ENABLED=true and broker details. Each notified or updated alert publishes JSON to nws-alerts/alert, and a retained summary of active alerts to nws-alerts/active.

automation:
  - trigger:
      platform: mqtt
      topic: nws-alerts/alert
    condition: "{{ trigger.payload_json.priority >= 4 and not trigger.payload_json.is_test }}"
    action:
      service: notify.everyone
      data:
        title: "{{ trigger.payload_json.event_name }}"
        message: "{{ trigger.payload_json.headline }}"

ntfy on Android

Every alert carries a priority set by its event code (the NOTIFY_PRIORITY_*_CODES lists in .env.example), and the ntfy Android app maps that priority onto Android's notification channels, so alerts can behave very differently by severity:

  • Top-priority events (tornado warning, flash flood emergency) can pop up, vibrate, and sound an alarm. In the ntfy app you can set that subscription's channel to override Do Not Disturb, so a 3 a.m. tornado warning still wakes you even with the phone silenced.
  • Mid-priority events ring or buzz the way a normal notification does.
  • Low-priority advisories (heat, fog, frost) can come in as silent notifications that land in the shade without a sound, so routine statements do not nag you.

For finer control, route severities onto separate topics with NTFY_PRIORITY_*_TOPIC. Each topic is its own subscription in the app, so you can give each one its own sound, vibration, and DnD-override setting.

Configuration

Everything is set with environment variables in .env (copy it from .env.example, which documents every option inline). The settings you are most likely to change:

Variable Default What it does
LOCATION none Your lat,lon. Auto-derives the county SAME code, UGC zones, and the radio scan list for anything left blank below.
RADIO_ENABLED true Enable the NOAA Weather Radio (RTL-SDR) source.
NWWS_ENABLED false Enable the NWWS-OI push source (needs NWWS_USER / NWWS_PASS).
API_ENABLED true Enable the api.weather.gov polling source.
API_USER_AGENT none Contact string required by api.weather.gov; put your email here.
FILTER_SAME_CODES from LOCATION County SAME codes to watch (space-separated PSSCCC).
FILTER_ZONES from LOCATION UGC forecast zones for zone-based products (winter storms, etc.).
NOTIFY_EVENT_CODES all Event codes allowed to notify; others are stored but stay silent.
RTL_DEVICE none RTL-SDR USB device path (radio only; also set in compose.override.yaml).
RADIO_FREQUENCY scan NWR frequency in MHz; blank scans all seven channels.
NTFY_URL / NTFY_USER / NTFY_PASS none ntfy server shortcut with per-priority topic routing.
NOTIFY_URLS none Space-separated Apprise URLs (Discord, Telegram, email, …).
NOTIFY_PRIORITY_{5,4,3}_CODES see example Event codes mapped to each notification priority level.
MQTT_ENABLED false Publish alert JSON to MQTT for Home Assistant.
MAP_ENABLED true Cache tiles/boundaries and render per-alert maps offline.
RADAR_ENABLED true Animated NEXRAD radar overlay on dashboard maps for precip/convective alerts.
RENOTIFY_ON_UPDATE escalation Re-notify on in-place revisions: off, escalation (severity rise / PDS wording), or all.
WEB_PUSH_ENABLED true Show the browser web-push controls in the dashboard.
ALLOW_DISMISS false Allow hiding an alert from the dashboard. The app has no login; see Restricting management endpoints.
SITE_TITLE / SITE_SUBTITLE auto Dashboard heading; the subtitle auto-fills from LOCATION when blank.

See .env.example for the complete, commented list: radio tuning, poll intervals, dedup window, map zoom/buffer, retention, and more.

Choosing which events notify

NOTIFY_EVENT_CODES is an opt-in allowlist. Leave it blank (the default) and every event notifies; set it and only the listed event codes notify. Anything else is still ingested and shown on the dashboard; it just stays silent.

You rarely need to prune it by geography: api.weather.gov only returns alerts for the zones and county derived from your LOCATION, so an inland setup never receives marine or tropical alerts even with those codes left in the list. Trim it to silence event types you don't want, such as dropping the advisory-tier codes to keep just warnings. EVENTS.md lists every event code, its name, and its VTEC mapping, plus the routine informational products that are intentionally left unmapped.

Restricting management endpoints

The dashboard has no login of its own. Everything it serves is read-only except three endpoints:

Endpoint What it does Default
POST /api/test-alert Injects a demo alert through the real pipeline, firing notifications to every configured target always on
POST /api/alerts/<id>/dismiss Hides an alert from the dashboard (soft flag; the row is kept) off (ALLOW_DISMISS)
POST /push/subscribe · POST /push/unsubscribe Enrolls or removes a browser for web-push always on

ALLOW_DISMISS=false (the default) disables the dismiss route and hides its button. It is a runtime setting, so changing it needs only docker compose up -d --force-recreate, not a rebuild. The other two have no such switch, so gate them at the proxy if the dashboard is reachable beyond your LAN.

All of them require a JSON content-type, which forces a CORS preflight and blocks naive cross-site form or image POSTs. That is a CSRF guard, not authentication: anyone who can load the page can still call them.

With nginx, restrict the paths by client address. allow/deny match on $remote_addr, which at the TLS-terminating edge is the real client, so this needs no header trust and cannot be spoofed:

# Regex locations take precedence over the "location /" prefix match, so this
# wins wherever you put it in the server block.
location ~ ^/api/(test-alert|alerts/[^/]+/dismiss)$ {
    allow 192.168.0.0/16;
    allow 10.0.0.0/8;
    allow 127.0.0.1;
    deny  all;

    proxy_pass http://nwsalertdashboard:8082;
    include /etc/nginx/proxy_params;
}

On SWAG that whole allow/deny block is one line, include /config/nginx/client-allow.conf;, and the proxy_pass follows the same $upstream_app / $upstream_port pattern as the rest of the conf.

Gating the push endpoints the same way means a device can only enroll for notifications while it is on your network. Delivery is unaffected: the push service talks to the browser directly, so a phone that subscribed at home keeps receiving alerts anywhere. Restrict only subscribe and unsubscribe; the page needs /push/vapid-public-key and /push/info to render its notification controls.

To guard everything at once, put the whole vhost behind HTTP basic auth or a forward-auth provider instead. Service-worker registration under basic auth is occasionally unreliable on iOS, so check that push still subscribes after such a change.

Architecture

RADIO: rtl_fm → recorder.py → multimon-ng → dsame3 ──→ notify.py ─┐
NWWS:  nwws_client.py (slixmpp MUC) → nwws_parse.py ──────────────┼→ ingest.py → SQLite
API:   api_poller.py (alerts/active) ─────────────────────────────┘      │
                            notify-once → Apprise (+map PNG) + web push + MQTT
web.py (Flask: SSE dashboard, Leaflet, /tiles) ←──────────────── SQLite
map_cache.py (one-time: zone GeoJSON + OSM tiles → ./alerts/mapdata)

Dedup: alerts carrying VTEC (NWWS, API) match on office.phen.sig.etn.year; radio SAME decodes match heuristically on event equivalence (SAME-to-VTEC code mapping) plus county FIPS overlap plus a DEDUP_WINDOW_SECS time window. The first source to land an alert claims the notification atomically; later arrivals enrich missing fields, and radio never overwrites richer text.

When NWS reissues an alert in place (reworded text, a tightened polygon, raised severity) the rich sources overwrite the changed content, keep prior versions as a revision history, and optionally re-notify on escalation (RENOTIFY_ON_UPDATE). A radio-first alert triggers an immediate API poll, so the headline and polygon usually merge in within seconds, and a one-time map follow-up notification fires once the polygon arrives.

Testing

# Dedup matrix + parser tests (in the running container)
docker exec nwsalertdashboard python3 /app/scripts/tests/test_dedup.py
docker exec nwsalertdashboard python3 /app/scripts/tests/test_nwws_parse.py

# Full radio-path E2E: synthesizes a SAME weekly test and pipes it through
# recorder → multimon-ng → dsame3 → notify (use a test ntfy topic!)
docker exec -e NTFY_TOPIC_DEFAULT=nws-test nwsalertdashboard \
  bash /app/scripts/tests/test_inject.sh

# Replay archived NWS text products through the NWWS ingest path
docker exec nwsalertdashboard python3 /app/scripts/nwws_client.py \
  --replay /app/scripts/tests/fixtures/TORDTX.txt --log-only

Notes

  • ntfy map attachments require attachment-cache-dir in your ntfy server config; without it the dashboard automatically falls back to text-only notifications.
  • The OSM tile cache is a small one-time regional fetch (roughly 10 to 15 MB, throttled) consistent with the OSM tile usage policy. Point MAP_TILE_URL at another provider or a self-hosted tile server if you need more.
  • api.weather.gov requires a contact address in API_USER_AGENT.

Disclaimer

This software is an independent, hobbyist project. It is not affiliated with, endorsed by, or supported by NOAA, the National Weather Service, or any government agency.

  • Not a primary warning source. Use it to supplement official life-safety channels, never to replace them: NOAA Weather Radio, Wireless Emergency Alerts (WEA), outdoor sirens, TV and radio broadcasts, and the official weather.gov.
  • No guarantee of accuracy, completeness, or timeliness. Alerts may be missed, delayed, duplicated, mis-prioritized, or rendered incorrectly. Radio reception depends on hardware, antenna, terrain, and transmitter status; the push and poll sources depend on third-party availability.
  • No warranty. As stated in the GPLv3 LICENSE, the software is provided "as is", without warranty of any kind. You assume all risk for how you deploy it and what you rely on it for.
  • Your responsibility: comply with the terms of each data source you enable, including the NWWS-OI usage guidelines, the api.weather.gov terms, and the OpenStreetMap tile usage policy.

Acknowledgements

Built on a lot of excellent open-source work:

  • dsame3 handles SAME/EAS message decoding (a Python 3 SAME decoder in the dsame lineage); it is cloned and patched at build time.
  • multimon-ng does the SAME/AFSK demodulation from the FM audio stream.
  • rtl-sdr (Osmocom) provides the RTL2832U SDR tools, including rtl_fm.
  • slixmpp is the XMPP client for the NWWS-OI feed.
  • Apprise fans notifications out to dozens of services.
  • Leaflet renders the interactive web maps.
  • OpenStreetMap supplies the base map tiles (© OpenStreetMap contributors, ODbL).
  • The Python stack: Pillow, Flask, waitress, pywebpush, and paho-mqtt.

Alert data comes from the U.S. National Weather Service and NOAA: api.weather.gov, the NWWS-OI feed, and NOAA Weather Radio broadcasts. NWS products are public-domain U.S. government works.

License

GPLv3 © 2026 Rob Wolff rob@borked.io

About

Self-hosted multi-source NWS weather alert monitor — NWWS-OI, NOAA Weather Radio (RTL-SDR), and the weather.gov API, deduplicated into one dashboard with offline polygon maps and push notifications. One Docker container.

Topics

Resources

Stars

21 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages