Skip to content

johncarmack1984/stormdeck

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

stormdeck

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
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, Open-Meteo, IEM radar, 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    # 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.yml

After 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/.

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 (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
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.

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 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.

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). Conditions: Open-Meteo (CC-BY 4.0).

MIT.

About

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

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors