Run it locally
Last reviewed against the codebase: 2026-07-25.
This page takes you from a fresh clone to a running stack - Postgres, the API server, and the app - entirely on your machine, with no AWS and no cloud credentials. The local stack talks only to a Docker Postgres and (optionally) public data feeds. Waking the hosted environment is a separate, cost-bearing operation covered in Run it in production.
Mental model - what actually runs
Before the commands, know the shape of what you’re starting. Locally there are three long-lived processes plus one optional one, and they are independent:
| Process | Command surface | Port | Needed for a normal dev loop? |
|---|---|---|---|
| Postgres/PostGIS | docker compose | 5432 | Yes - everything reads/writes here |
| API server | @workspace/api-server dev | 3000 | Yes - serves the app + runs ingest schedulers |
| App (Expo web) | @workspace/firepath-ai web | 8081 | Yes - the UI you open in a browser |
| Redis + wind-worker | docker compose / worker-main | 6379 | No - terrain-wind simulation only; dark by default (see below) |
The API server is more than an HTTP endpoint: on boot it also starts the ingest schedulers (FIRMS, DEA Hotspots, OpenMeteo), so a locally-running API will populate your Postgres with real Australian data over the first few minutes even if you never touch the UI. That’s why the dashboard has live fires and a wind layer on a clean database - you didn’t seed them, the schedulers fetched them.
1. Install dependencies
corepack enable # activates the pinned pnpm (11.0.9)cd Fire-Path-AIpnpm installcorepack enable is not optional cosmetics: this is a pnpm workspace pinned via
packageManager: pnpm@11.0.9, and installing with a different pnpm - or with
npm/yarn - can resolve a different dependency graph. (A preinstall hook deletes
any stray package-lock.json/yarn.lock for exactly this reason.)
Expected: pnpm links every package under artifacts/* and lib/* into a
single node_modules. The first install is slow because of a deliberate
supply-chain guard - minimumReleaseAge: 1440 in pnpm-workspace.yaml refuses
any npm version published less than 24 h ago.
See Prerequisites & access for the exact tool versions this repo is pinned to.
2. Start Postgres (+ optional Redis)
Postgres/PostGIS and Redis are defined in the root docker-compose.yml
(postgis/postgis:16-3.4 and redis:7-alpine). Start from the repo root:
docker compose up -d postgres # PostGIS on :5432docker compose up -d redis # OPTIONAL - wind-worker queue onlyThe container names are firepath-postgres and firepath-redis, and each has a
Compose healthcheck, so you can wait for readiness deterministically instead
of guessing:
docker compose ps # STATUS should read "healthy", not just "running"docker logs firepath-postgres | tail -n 5Why PostGIS specifically, not plain Postgres? Fire spread, hotspot
clustering, and per-asset distance all run as spatial SQL (ST_ClusterDBSCAN,
ST_DWithin, geometry columns). The vanilla postgres image lacks the PostGIS
extension and the app’s boot migration will fail. Use the image as pinned.
3. Configure the environment
Copy the example env file. Its dev-only defaults already point at the Docker Postgres above, so the stack works with zero edits:
cd Fire-Path-AIcp .env.example .envThe values that matter locally (the full annotated list is the Configuration reference):
DATABASE_URL- pre-set to the docker-compose Postgres (postgresql://firepath:…@localhost:5432/firepath). Match this exactly to the credentials indocker-compose.ymlif you change either.JWT_SECRET- any non-empty dev string. If it’s blank the API boots but every authenticated request fails; this is the single most common “why is login broken locally” cause.ENABLE_TEST_LOGINS=true- enables a one-tap local sign-in endpoint (POST /auth/test-login) so you don’t need real OAuth to click around. It hard-404s unlessNODE_ENV !== productionand this flag is"true", so it can never be reached on a deployed service. Never set it in prod/staging.
Third-party keys can stay blank. FIRMS, Google/Microsoft OAuth, Resend,
Twilio, VAPID, and the AI-summary key all degrade gracefully when
unset - the relevant route returns 503 or the call becomes a logged no-op, and
the rest of the app is unaffected. You do not need any of them for local
development. (A blank NASA_FIRMS_API_KEY simply means the FIRMS scheduler
fetches nothing; DEA Hotspots and OpenMeteo need no key and will still populate
the map.)
4. Apply the database schema
The API server self-migrates a production database on boot, but locally the schema is created with Drizzle push (a diff-and-apply, no migration journal):
pnpm --filter @workspace/db run pushExpected: drizzle-kit connects using DATABASE_URL, prints the tables it is
creating/altering, and exits 0. If it stalls, it’s almost always Postgres not
being healthy yet (step 2) or a DATABASE_URL mismatch.
5. Run the API server
cd Fire-Path-AIset -a; source .env; set +a # export the .env vars into this shellpnpm --filter @workspace/api-server run devThe dev script sets NODE_ENV=development, builds with esbuild, then starts
dist/index.mjs on :3000. The source .env line is required because the dev
script reads config from process env, not by auto-loading the .env file -
skip it and the server falls back to defaults (or fails on a missing
JWT_SECRET).
Verify it’s actually serving before wiring up the UI - the two health endpoints answer different questions:
curl -s localhost:3000/healthz # liveness: always {"status":"ok"} if the process is upcurl -s localhost:3000/readyz # readiness: pings the DB + reports ingest freshness/healthz is intentionally dependency-free (the orchestrator uses it, and a
transient DB blip must not get the task killed). /readyz is the one that tells
the truth: it returns 503 with "db":"down" if Postgres is unreachable,
and lists each ingest source with its age and a stale flag. On a
just-started local stack the ingest list is empty or stale: true until the
first fetch cycle completes - that’s expected, not a fault.
See Verify for the full check suite (typecheck, vitest, the optional k6 smoke).
6. Run the app (web)
In a second terminal:
cd Fire-Path-AIpnpm --filter @workspace/firepath-ai run web # Expo web on :8081Open http://localhost:8081. With ENABLE_TEST_LOGINS=true the dashboard
auto-signs-in on first fetch and pulls real Australian data (FIRMS + DEA
Hotspots + OpenMeteo) from your local Postgres. If the map is empty for the first
minute, the ingest schedulers simply haven’t completed a cycle yet - leave the
API running and it fills in.
For a device or simulator instead of the browser, swap the last word:
pnpm --filter @workspace/firepath-ai run start # Expo dev menu (QR for Expo Go)pnpm --filter @workspace/firepath-ai run ios # iOS simulatorpnpm --filter @workspace/firepath-ai run android # Android emulator7. (Optional) Run the wind-worker
Terrain-shaped wind simulation (WindNinja) runs in a separate process from
the API - worker-main.ts, the same entrypoint the ECS wind-worker task uses in
production. It drains a BullMQ/Redis queue that the API enqueues into.
By default this is completely dark locally, and that is intentional. The
queue only activates when REDIS_URL is set (isQueueEnabled() checks for a
non-empty REDIS_URL), and REDIS_URL is not in .env.example. So even if
you started the Redis container in step 2, no jobs are enqueued and no worker
runs until you opt in by adding, e.g.:
# in .env - only if you want to exercise terrain wind locallyREDIS_URL=redis://localhost:6379Stop everything
- Dev servers -
Ctrl-Cin each terminal (API, Expo, and the worker if you started it). Expo may leave a Metro bundler port held; a secondCtrl-Cor closing the terminal releases it. - Containers - from the repo root:
docker compose down # stops Postgres/Redis, KEEPS your data volumedocker compose down -v # ALSO deletes ./.docker-data - a clean slateUse -v deliberately only: it wipes your local database, and your next start
must re-run the schema push (step 4) and re-ingest data from scratch.
Common local failures at a glance
| Symptom | Most likely cause | Fix |
|---|---|---|
| Every authed request 401/500s | JWT_SECRET blank | Set any non-empty dev string in .env, restart API |
/readyz returns 503 "db":"down" | Postgres not healthy / bad DATABASE_URL | docker compose ps; wait for healthy; match DATABASE_URL |
Schema push errors on citext | Old DB volume without the extension | docker compose down -v then re-push |
| Device app can’t reach the API | Origin not in CORS_ALLOWED_ORIGINS | Add the LAN origin, restart the API |
| No fires/wind on the map | Schedulers haven’t cycled yet | Wait a minute; check /readyz ingest ages |
| Wind simulation “does nothing” | REDIS_URL unset → queue dark | Expected - opt in per step 7, or ignore |
For anything not listed here, see Troubleshooting.