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 plus two Open-Meteo conditions grids — a fine one over the home bbox and a 6° lattice covering the planet. 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 / 30 min / 6 h"]
end
nws["api.weather.gov"]
meteo["open-meteo.com"]
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
meteo --> ingest
ingest -->|"PUT JSON"| 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, Open-Meteo, IEM radar, 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 # deploy-infra 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-web which distribution to invalidate, then publish
gh variable set DISTRIBUTION_ID --body "$(just cdk output DistributionId)"
gh workflow run deploy-web.ymlAfter that, deploy-web republishes on any push that touches web/
(an S3 sync plus an index invalidation — hashed assets are immutable),
and deploy-infra redeploys on anything touching cdk/ or crates/.
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.
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 (fine grid + tile detail) |
.just/common.just / cdk/lib/stormdeck-stack.ts |
-98.2,31.8,-95.8,33.6 (DFW) |
nws_area |
same | empty (all US alerts) |
| Global lattice spacing | GLOBAL_STEP_DEG lambda env |
6° |
| Global/regional grid 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, grid 30 min, global 6 h |
| Grid density | GRID_COLS/GRID_ROWS lambda env |
8×6 |
Keep the three in sync: tile extract bbox, weather bbox, initial view.
- 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 two small JSON files, 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.
- Open-Meteo counts each lattice point as an API call, so the global job paces its batches 15s apart (their 600/min cap) and the default schedules add up to ~9k calls/day against their 10k non-commercial tier. Densify the lattice or speed up the schedules and you'll start seeing 429s — the lambda backs off and retries once, but budget first.
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). Conditions: Open-Meteo (CC-BY 4.0).
MIT.