Skip to content

johncarmack1984/stormdeck

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

40 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

stormdeck

Live at stormdeck.live.

stormdeck — live weather radar and NWS alerts on a deck.gl map

Live weather on a deck.gl map, served almost entirely from free tiers — the whole bill is thirteen dollars a year of vanity domain plus a few cents a month for Route 53 and SES email.

OpenStreetMap basemap tiles come from martin running inside AWS Lambda, reading PMTiles extracts straight from a private S3 bucket. A scheduled Rust lambda (cargo-lambda) snapshots US-wide NWS alerts and decodes NOAA GFS GRIB2 straight from NOAA's open-data S3 bucket into global 2 m temperatures — a planet-wide lattice when zoomed out, per-city point forecasts when zoomed in — plus animated global wind (city list from GeoNames). Radar is RainViewer's global composite (IEM NEXRAD as fallback). The web app is React + deck.gl + MapLibre, served from the same CloudFront distribution as the tiles and weather — one origin, no CORS, hashed assets cached immutable at the edge. Map views mirror into the URL hash, so any view is a link.

Not an official weather source. This is a hobby map on shoestring infrastructure: alerts refresh on a schedule, radar lags by several minutes, zone-based NWS alerts (no polygon geometry) are not shown, and any piece can fail silently with no on-map indication. For decisions involving life or property, use weather.gov and local emergency guidance.

flowchart LR
    viewer["stormdeck.live<br/>deck.gl · MapLibre · protomaps style"]

    subgraph aws["AWS — near-free tier"]
        cdn["CloudFront"]
        martin["martin<br/>Lambda function URL"]
        bucket[("S3 — private<br/>site/ · pmtiles/ · weather/")]
        ingest["weather-ingest<br/>Rust Lambda"]
        sched["EventBridge Scheduler<br/>5 min / 6 h"]
    end

    nws["api.weather.gov"]
    gfs["noaa-gfs-bdp-pds<br/>GFS GRIB2 — S3 open data"]
    radar["RainViewer<br/>IEM NEXRAD fallback"]

    viewer -->|"app · tiles · weather"| cdn
    viewer -->|"radar tiles"| radar
    cdn -->|"default → site/"| bucket
    cdn -->|"catalog · region* · world*"| martin
    cdn -->|"weather/*"| bucket
    martin -->|"range reads"| bucket
    sched -->|"invokes"| ingest
    nws --> ingest
    gfs --> ingest
    ingest -->|"PUT JSON + wind PNG"| bucket
Loading

What it costs

Piece Tier Limit
Lambda (martin + ingest) always free 1M requests + 400k GB-s / month
CloudFront always free 1 TB egress + 10M requests / month
EventBridge Scheduler always free 14M invocations / month
S3 free 12 months, then ~$0.02/GB-mo a metro extract is ~$0.01/mo after year one
stormdeck.live (Route 53) not free $13/yr + $0.50/mo hosted zone
NWS, NOAA GFS, IEM radar, GeoNames, protomaps builds free / open data be polite, attribute

CloudFront caches tiles hard (24h TTL), so martin invocations stay tiny.

Prereqs

mise install (mise) fetches the whole toolchain from mise.toml: node (LTS), pnpm, rust, just, cargo-lambda, the pmtiles CLI, and martin for local dev. (rust-toolchain.toml pulls in the arm64 cross target on first build.) Bring your own equivalents if you prefer. Either way you also need the aws CLI, authenticated.

Deploy

# 1. cut OSM extracts: full detail for your area (default: DFW;
#    bbox=... to change) plus a small z0-6 world for zoomed-out context
just tiles extract

# 2. package the martin lambda zip from the upstream prebuilt arm64
#    binary (weather-ingest compiles itself at deploy time, via CDK)
just build martin

# 3. one-time account setup; afterwards every push to main that touches
#    cdk/ or crates/ deploys via GitHub OIDC (no stored AWS keys)
just profile=<admin> cdk bootstrap
just profile=<admin> cdk deploy oidc
gh variable set AWS_DEPLOY_ROLE_ARN \
  --body "$(just profile=<admin> cdk output DeployRoleArn StormdeckGithubOidc)"
git push    # the deploy workflow applies the stack (or locally: just cdk deploy)

# 4. ship the tiles, prime the weather data
just tiles upload
just weather prime

# 5. tell deploy which distribution to invalidate, then publish
gh variable set DISTRIBUTION_ID --body "$(just cdk output DistributionId)"
gh workflow run deploy.yml

After that, the single deploy workflow republishes on any push to main: a change under cdk/ or crates/ redeploys the stack and re-primes the weather feeds, and a change under web/ publishes the app (an S3 sync plus an index invalidation — hashed assets are immutable). When a merge touches both, the web job waits for the infra job, so the app never goes live ahead of the backend (and data) it reads.

Development & releases

Work on a branch and open a PR — ci runs on every PR (web build + biome, Rust fmt/clippy/contract-drift, cdk typecheck + synth). Merging to main is what ships: the push triggers the deploy workflow (continuous deployment) — infra first (stack deploy + feed priming), then the web, so the app never publishes ahead of its backend.

Docs ship with the change: any PR that alters data sources, behavior, costs, or architecture updates the README and the on-map attribution (web/src/App.tsx and web/src/basemap.ts) in the same PR. ci can't catch stale prose, so the PR checklist is the backstop — keep a source uncredited and you've shipped a licensing bug, not just a doc gap.

Every merge also cuts a patch release automatically (auto-release.yml): it bumps the latest vX.Y.Z tag, tags the merge commit, and creates a GitHub Release with notes auto-generated from the PRs merged since the last tag — so PR titles are the changelog (label them to sort into the sections in .github/release.yml). The first merge with no tags yet seeds v0.1.0. The deployed app stamps that same version next to the title and in a console banner (the deploy workflow computes it the same way), so the live label always reads the released vX.Y.Z — exactly what's live.

For a bigger bump, or to release by hand, use just release from a clean, pushed main:

just release minor      # or major — bump + push the tag yourself
just release 0.1.0      # an exact version

A manual tag is pushed with your own credentials, so it fires release.yml (the manual path) instead of auto-release. To skip the release for a trivial merge, put [skip release] in the PR title (the squash subject — only the subject line is checked). To roll back, revert via a PR and merge — CD redeploys and the next patch is cut.

Local dev

The quick way — just web dev runs the app against the live site's tiles + weather, so there's no local backend to stand up:

pnpm --dir web install   # once
just web dev             # http://localhost:5173, data from stormdeck.live

For offline / tile / basemap work, run the full local stack instead — martin serving local extracts, vite serving locally-primed weather:

just tiles extract  # once: cut the pmtiles
just weather local  # live weather → web/public/weather/
just dev            # martin :3030 + vite :5173 (local data, overrides the default)

IaC

CDK → CloudFormation: state lives in the account, and pushes to main deploy through the repo-pinned OIDC role (the StormdeckGithubOidc stack from step 3). just cdk synth works offline, and the profile= / region= variables (.just/common.just) thread through every infra recipe (cdk bootstrap, cdk deploy, cdk outputs, tiles upload, weather prime, …). Module justfiles live in their home folders, so e.g. just deploy from inside cdk/ works too.

One piece lives outside CloudFormation: the stormdeck.live certificate was requested once via the ACM CLI in us-east-1 (CloudFront only takes certs from there) and is pinned by ARN in the stack. Its DNS validation records are stack-managed, so renewals stay hands-off. Mind the CAA gotcha: ACM follows CAA policy through CNAMEs, so a record pointing at a host with restrictive CAA (github.io, say) blocks issuance for that name.

Configuration

Knob Where Default
bbox (tile extract detail) .just/common.just -98.2,31.8,-95.8,33.6 (DFW)
nws_area .just/common.just / cdk/lib/stormdeck-stack.ts empty (all US alerts)
Temp lattice spacing LATTICE_STEP_DEG in crates/weather-ingest/src/main.rs
Lattice ↔ city temp switch GRID_ZOOM_SPLIT in web/src/config.ts z6.5
Map start view web/src/config.ts (URL hash wins) world, z0
World context detail WORLD_MAXZOOM env for just tiles extract z0–6
Schedules cdk/lib/stormdeck-stack.ts alerts 5 min, temp 6 h, windtex 6 h

The bbox sets the full-detail basemap region; outside it the map shows the coarse world tiles. Temperature and wind are global (GFS), so they ignore it.

Notes

  • martin-in-Lambda: martin ≥ v0.14 detects AWS_LAMBDA_RUNTIME_API and serves Lambda events natively — the zip is just the upstream aarch64-musl binary plus a two-line bootstrap. The function URL is IAM-auth; only CloudFront (OAC SigV4) may invoke it.
  • No aws-sdk in the ingester: it only PUTs a handful of small objects — JSON snapshots plus the GFS wind PNGs — so it signs the request itself (SigV4, ~80 lines, test vector included). As of June 2026 the SDK also doesn't compile (aws-runtime 1.7.4 vs aws-smithy-runtime-api 1.12.3 skew) — check back later if you need more S3 surface.
  • Zone-based NWS alerts (no polygon geometry) are dropped; rendering them would mean shipping zone shapefiles. Counted in the lambda logs.
  • GFS straight from GRIB2: every temperature and the wind come from NOAA GFS with no per-point API metering — the ingester pulls 0.25° UGRD/VGRD/TMP fields from NOAA's public noaa-gfs-bdp-pds S3 bucket and decodes the GRIB2 itself, so one ~0.9 MB field covers the whole planet (1440×721) and any number of points sample for free. One pass writes a whole-planet lattice.json (the zoomed-out grid) plus per-city tiles (zoomed in) — sampled from the same TMP fields, so the grid costs no extra fetches — and the wind u/v PNGs (±40 m/s). All carry the model run's snapshot so a new run refetches cleanly, and all share one forecast-hour axis so the timeline scrubs grid, cities, and wind together.

Attribution

Map data © OpenStreetMap contributors, tiles via Protomaps builds (ODbL). Radar: RainViewer global composite (free tier, attribution required), falling back to NOAA NEXRAD via the Iowa Environmental Mesonet. Alerts: National Weather Service (public domain). Temperatures and wind: NOAA GFS via NOAA Open Data Dissemination (public domain). City list: GeoNames (CC-BY 4.0).

MIT.

About

Live weather on a deck.gl map, served from AWS free tiers

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors