Troubleshooting
Last reviewed against the codebase: 2026-07-25.
The failures most likely to trip up a new maintainer, why they happen, and how to recover. Each entry names the signal you’ll actually see, the underlying cause, and a runnable fix. Where a problem is inherent to the hibernate/wake cost model (rather than a bug), that is called out so you don’t chase a phantom.
Run everything from the repository root unless noted; the pnpm workspace lives in
Fire-Path-AI/, while docker-compose.yml and infra/ sit at the repo root.
App login returns 503 / 530
Signal: the web app loads its shell but every API call fails; the browser
console shows 503 (service unavailable) or Cloudflare 530 (origin
unreachable).
Why: the front end is a static SPA served by Cloudflare; it is almost always up. A 5xx here means the API origin is unreachable - the two front ends are decoupled on purpose so the marketing/app shell never depends on a hot backend.
Diagnose in order - each step isolates a layer:
-
Is the API process up? In local dev it listens on
:3000.Terminal window curl -s http://localhost:3000/api/healthz # liveness - process is upcurl -s http://localhost:3000/api/readyz # readiness - DB reachable tooA
200from/healthzbut a non-200 from/readyzmeans the process is alive but the database is not reachable - go to step 2. See Verify for what each probe asserts. -
Is Postgres up?
Terminal window docker compose up -d postgresdocker compose ps postgres # State should be "running (healthy)" -
Is the tunnel up (hosted origin only)? In local-against-
api.firepath.softwaredevelopment, the origin is Joel’s machine exposed viacloudflared. A530with the local api-server clearly running means the tunnel daemon is down - restart it, then retry.
Against the deployed environment, a 5xx almost always means the stack is hibernated or mid-wake - this is expected, not a fault. See Run it in production for the wake procedure.
Tests fail with DATABASE_URL / JWT_SECRET unset
Signal: vitest fails with an error tied to a missing environment variable.
Why: by design the suite passes with all production secrets unset - CI is never given real credentials, so tests inject dummies or mock the boundary. If a test fails on a missing variable, the usual causes are:
- a stale real value leaking in from your shell (a previously
exportedDATABASE_URLpointing at something that no longer exists), or - a missing dev default - you never created
.env.
Fix:
cd Fire-Path-AIcp .env.example .env # dev defaults point at the docker-compose PostgresEnsure JWT_SECRET is set to any non-empty dev string - auth fails hard without
it. To reproduce CI’s “no secrets” contract locally, unset the variable and run
the suite; it must still pass:
env -u DATABASE_URL -u JWT_SECRET pnpm --filter @workspace/api-server run testIf it fails only when the real value is unset, a test is relying on a live credential - that belongs to a HOT wake, not the unit suite.
Test logins don’t appear (local) or you fear they leak (prod)
Signal: the one-tap local test sign-in buttons are missing, or you’re worried they could be reachable on a deployed service.
Why / reassurance: ENABLE_TEST_LOGINS=true gates the convenience logins, and
the endpoint hard-404s whenever NODE_ENV === production. That means the flag
being accidentally left on cannot expose test logins on a deployed service - the
NODE_ENV guard fires first.
Fix (local): set ENABLE_TEST_LOGINS=true in your .env and restart the
api-server. See Run it locally for the full env setup.
Terrain wind shows “unavailable”
Signal: predictions render, but terrain-refined wind reports “unavailable” and the model falls back to the coarse synoptic wind field.
Why: terrain refinement (WindNinja) runs on a separate wind-worker ECS tier built from an amd64 image. It is not part of the default API deploy.
Fix: run the full production wake (see Run it in production) and confirm the worker tier and its Redis queue are up. Capability status is tracked on the architecture overview.
pnpm install is slow or refuses a package
Signal: install stalls, or a specific just-published package version won’t resolve.
Why: the workspace enforces a minimum release age on npm packages as a supply-chain defence - a brand-new release is deliberately held back until it has aged. This is a security control, not a failure.
Fix: none needed - wait, or pin to an already-aged version. Do not disable the setting to “unblock” an install; that removes the protection for everyone.
Docs-site: drift gate fails (check:drift)
Signal: pnpm run ci (or check:drift) fails in the docs-site with a
committed-vs-generated mismatch.
Why: the generated reference sections (/reference/*) are produced from the
codebase - the Drizzle schema, config.ts, the API spec, and a curated repo
tree. When source changes, the committed Markdown goes stale and the drift gate
catches it before publish.
Fix - regenerate, verify, then commit:
pnpm --filter @workspace/docs-site run gen # runs gen:datamodel/config/api/repotreepnpm --filter @workspace/docs-site run check:drift # should now passCommit the regenerated Markdown. Full guidance is in Update common things.
Docs-site: redaction or link gate fails
Signal: the docs build fails at check:redaction or check:links.
Why: check:redaction runs over the built dist/ and blocks publish if the
output contains secrets or personal data (absolute paths, account IDs, resource
IDs/ARNs, private emails, sample place names). check:links blocks broken
internal links. Both are hard publish gates in the build/ci scripts.
Fix: read the gate output - it names the offending file and the matched value. Remove the sensitive value (or fix the link target), then rebuild:
pnpm --filter @workspace/docs-site run build # gen → check:reference → build → redaction → linksBecause the gate runs on the compiled output, a value can slip past source review and still be caught here - that is the intended backstop.
Wake fails: ECR repo “not in Terraform state”
Signal: a production wake aborts complaining the container registry isn’t managed by Terraform, or re-creates on every wake.
Why: the image push needs the ECR repo to exist before Terraform runs, but
the repo must also live in Terraform state. Earlier wakes created the repo in
AWS for the push but never imported it into state, so every subsequent wake
tripped over it. wakeup.sh now imports the repo unconditionally and treats
“already managed” as success.
Fix: re-run wakeup.sh - the import step is idempotent. If it still aborts,
read /tmp/firepath-ecr-import.err; a genuine failure (not an “already managed”
no-op) is printed verbatim. See Run it in production.
Wake fails: type "citext" does not exist
Signal: the DB bootstrap fails on schema push with a missing-type error for
citext (or postgis).
Why: drizzle-kit push cannot itself CREATE EXTENSION. The extensions must
exist before the schema push, or the push references a type that isn’t there
yet.
Fix: bootstrap-prod-db.sh enables extensions first (idempotently) via the
init SQL, then runs push-force. If you’re bootstrapping by hand, enable
postgis + citext before pushing. The production bootstrap sequence lives in
Run it in production.
Sign-in doesn’t work after a fresh production wake
Signal: the stack is up and healthy, but you can’t sign in through the web UI.
Why: the prod admin needs a real email/password credential to sign in through the UI. The bootstrap resolves the seed-admin password in order: (1) an explicit env override, (2) a persistent Secrets Manager value (the recommended source - set once, survives every wake), (3) empty → a passwordless device-only admin that the web UI cannot log in as.
Fix: ensure the seed-admin password exists in Secrets Manager (or pass it as an env override to the bootstrap). A silently-empty password produces a device-only admin and a UI that rejects every login - check that first.
Wake succeeds but api.firepath.software still 5xx
Signal: the wake reports healthy (the ALB answers /readyz), yet the public
hostname is still unreachable.
Why: every wake produces a new ALB hostname, so DNS must be repointed each
time. The wake auto-updates the Cloudflare record when CLOUDFLARE_API_TOKEN and
CLOUDFLARE_ZONE_ID are exported (TTL 60s, DNS-only); without them it prints
manual instructions instead and DNS stays stale.
Fix: either export those two variables before waking so DNS updates automatically, or follow the printed manual repoint. Then confirm origin health directly against the ALB before blaming DNS:
curl -s https://api.firepath.software/api/readyz # after DNS propagates (~60s TTL)An AWS command hits the wrong account / SSO directory
Signal: wakeup.sh / hibernate.sh open a browser to an SSO start page that
isn’t the Fire Path one, or AWS calls target an unexpected account.
Why: a developer shell can default AWS_PROFILE to an unrelated profile at
a different SSO directory, and that leaks into the scripts. Both scripts hard-pin
AWS_PROFILE=firepath-admin and clear AWS_DEFAULT_REGION to defend against
this, but an already-broken SSO session in the environment can still confuse you.
Fix: if the browser opens the wrong directory, your AWS_PROFILE is leaking
through - open a fresh terminal and re-authenticate:
aws sso login --profile firepath-adminNever set AWS_PROFILE globally in your shell profile for this repo; let the
scripts pin it.
Hibernate leaves an orphaned Elastic IP (cost leak)
Signal: after hibernate.sh the monthly cost doesn’t drop to the ~AUD $3
baseline; a NAT Gateway or its Elastic IP is still billing.
Why: hibernate destroys the expensive ephemeral tier (NAT Gateway + EIP, RDS Multi-AZ, ElastiCache, ECS/ALB - roughly AUD $230/mo) and preserves the cheap always-on baseline (VPC, Route 53 zone, ACM certs, KMS + secrets, IAM). Live infra can drift from Terraform state, so a destroy mid-flight can leave a resource - an orphaned EIP still bills even when detached.
Fix: re-run hibernate.sh - its pre-destroy steps clear known blockers
idempotently. Then confirm nothing expensive survived (no running RDS/ECS, no
detached-but-allocated EIP). Note that data is intentionally lost on hibernate
(no final snapshot) - you re-seed on the next wake; that is by design, not data
loss to recover from. The full cost model is in
Run it in production.
Reading the health signals
GET /healthz- liveness: the process is up and answering.GET /readyz- readiness: the database is reachable and the service can serve.
When something is “up but not working,” check /readyz first - it is the single
signal that distinguishes a live process from a serving one, and it short-cuts
almost every diagnosis on this page. See Verify.