Live at stormdeck.live.
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), animated global wind, a global precipitation forecast (GFS composite reflectivity), and a global storm-potential overlay (GFS surface CAPE) — city list from GeoNames. Precipitation shows the live RainViewer global radar composite (IEM NEXRAD as fallback) while the timeline is parked at now, then swaps to the GFS forecast raster as you scrub forward, so the whole map moves on one timeline. Storm potential (off by default) shades where the atmosphere is primed for thunderstorms, alongside the live NWS alerts. 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, live radar lags by several minutes, the forecast precipitation is coarse 0.25° model output (it smooths over individual cells), 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/precip/cape PNGs"| bucket
| 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.
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.
# 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.ymlAfter 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.
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 versionA 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.
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.liveFor 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)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.
Supply chain: every GitHub Action is pinned to a commit SHA (Dependabot bumps them weekly), cargo-lambda is version-pinned, and the prebuilt martin binary is fetched from a pinned release and checksum-verified before it's packaged into the Lambda. The deploy workflow grants permissions per job, so only the AWS-touching jobs (infra, web) ever receive an OIDC token.
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.
| 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 |
6° |
| 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 / windtex / refc / cape 6 h |
The bbox sets the full-detail basemap region; outside it the map shows the coarse world tiles. Temperature, wind, the precipitation forecast, and storm potential are global (GFS), so they ignore it.
- martin-in-Lambda: martin ≥ v0.14 detects
AWS_LAMBDA_RUNTIME_APIand serves Lambda events natively. The zip is just the upstreamaarch64-muslbinary plus a two-linebootstrap. 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: temperature, wind, the precipitation forecast, and storm potential all come from NOAA GFS with no per-point API metering: the ingester pulls 0.25° UGRD/VGRD/TMP/REFC/CAPE fields from NOAA's public
noaa-gfs-bdp-pdsS3 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-planetlattice.json(the zoomed-out grid) plus per-city tiles (zoomed in), sampled from the same TMP fields, so the grid costs no extra fetches; the wind u/v PNGs (±40 m/s); the precipitation PNGs (composite reflectivity, a grayscale dBZ texture over −20…75 dBZ — GFS floors clear sky at ~−20 dBZ, so the web renders that transparent); and the storm-potential PNGs (surface CAPE, a grayscale texture over 0…5000 J/kg, faded out below ~250 J/kg). 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, wind, precipitation, and storm potential together.
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, wind, the precipitation forecast (composite reflectivity), and storm potential (surface CAPE): NOAA GFS via NOAA Open Data Dissemination (public domain). City list: GeoNames (CC-BY 4.0).
MIT.
