Operations
Last reviewed against the codebase: 2026-07-25.
How the AWS API backend is operated day to day. The three Cloudflare surfaces (marketing, docs, web-app shell) run 24/7 and are covered in Infrastructure - this page is about the one surface that hibernates: the API + database.
Everything here is scripted. There is no click-ops: a wake, a deploy, and a hibernate are each a
single command whose behaviour is described below, grounded in the actual scripts under
infra/scripts/ and the GitHub Actions workflow under .github/workflows/.
The cost model - persist the data tier, hibernate compute
Only the COMPUTE tier (ECS + ALB + NAT) hibernates by default; the DATA tier (RDS + Redis)
stays up so real accounts and data survive a hibernate/wake. persist_data defaults true
(Terraform), and hibernate.sh / wakeup.sh default PERSIST_DATA=true. A small persistent tier
plus DNS, storage, and secrets is what survives between sessions.
- COLD (default resting state, ~AUD $35–40/mo) - compute destroyed (ECS + ALB + NAT); the persistent data tier (RDS + Redis) and the always-on baseline (VPC, DNS zone, TLS certs, secrets, Terraform state) remain up.
- HOT (~AUD $290–320/mo while up, + task-hours) - the whole stack: data tier + ALB + ECS api-server + wind-worker + fire clustering. End-to-end validation on real infrastructure.
The persistent tier is sized for cost-safe testing, not final HA: RDS db.t4g.micro, single-AZ,
20 GB gp3, and Redis single-node cache.t4g.micro. When persisting, RDS gets deletion protection
and takes a final snapshot on any destroy. Prod-grade HA is a later step (bump to db.t4g.medium +
multi-AZ + a Redis replica, ~AUD $180/mo). See DB-PERSISTENCE-PLAN.md for the full transition and
cost detail.
Why keep the data tier up rather than tear it fully down? The old fully-cold posture (~AUD $3/mo) destroyed RDS + Redis every hibernate, so every wake started from an empty, freshly-bootstrapped database. Keeping a small single-AZ data tier warm trades a modest idle cost for real durability - accounts and data now survive a wake instead of being reproduced from seed each time.
The full cost/mode reference lives in COLD-WARM-HOT-MODE.md in the repo root.
Waking the stack
Two wake modes, both via infra/scripts/wakeup.sh:
| Command | Brings up | Use for |
|---|---|---|
bash infra/scripts/wakeup.sh data | RDS + Redis only (data tier + NAT) | schema/data work without paying for compute |
bash infra/scripts/wakeup.sh full | the whole stack - data tier + ALB + ECS api-server + wind-worker + fire clustering | end-to-end validation on real infrastructure |
Both modes are feature-flag driven (enable_data_tier, enable_compute, enable_wind_worker,
enable_fire_clustering, persist_data), so a mode is just a set of Terraform variables - nothing is
hand-edited. The flags themselves are documented in Infrastructure.
The script is cost-gated: it prints exactly what it will create and its monthly cost, then waits
for a y/N confirmation before touching AWS. Reading that block before you type y is the
single best habit - it is the last checkpoint before spend.
What a full wake actually does, in order
The script narrates six steps. Understanding them makes failures diagnosable:
- Build the api-server image (
linux/arm64, fromFire-Path-AI/Dockerfile) and the wind-worker image (linux/amd64, fromFire-Path-AI/Dockerfile.worker, builtFROMthe local WindNinja base). Building both in the same run means enabling the worker tier can never point at a missing image. - Push both images to ECR (see the ECR-state gotcha below).
terraform applywith the HOT variable set - brings up the ALB, both ECS services, and turns fire clustering on.- Force a new ECS deployment so the api-server pulls the freshly-pushed
:latest. - Verify the DB self-bootstrapped by polling
/api/readyzon the ALB until it returns200. - Point
api.firepath.softwareat the new ALB (auto if Cloudflare creds are set, else printed instructions).
A data wake runs only the Terraform apply - no image build, no ECS, no DNS step.
Gotcha: the ECR-in-state reconcile
hibernate.sh deletes the api-server ECR repository (force-delete). After a clean hibernate the repo
is absent from both AWS and Terraform state. On the next full wake the push needs the repo to
exist in AWS, and the subsequent terraform apply needs it to be in state - otherwise the
apply tries to create a repo that already exists and aborts the whole wake with
RepositoryAlreadyExistsException. The script reconciles these two facts explicitly and idempotently:
create-if-absent in AWS, then terraform import (tolerating “already managed” as success). This was a
real recurring bug - an earlier version created the repo for the push but never imported it, so every
wake collided at apply. If a wake ever aborts at Step 2/3 on a RepositoryAlreadyExists error, the
import branch is the place to look.
Timing expectations
data- ~12 min wall on first stand-up (single-AZ RDS provisioning is the slow part); much faster once the persistent data tier is already up and only compute is being reconciled.full- ~15 min wall + ~5–8 min for the two Docker builds/pushes. Step 5 then polls/readyzfor up to ~10 min while the api migrates (additively) against the persistent RDS instance and the idempotent seed runs. A/readyzreturning503during this window is expected - it means “still provisioning/migrating”, not “broken”.
Self-bootstrap on boot - no manual DB step
The single most important operational property: the api task migrates and seeds its own database on
boot. When DB_BOOTSTRAP_ON_BOOT is set (the Terraform default), a freshly-woken task runs its
migrations and seed against the RDS instance itself. Migrations are additive and the seed is
idempotent (insert-if-absent) - there is no destructive re-seed in prod, so with the data tier
persisting, real accounts and data carry across a wake rather than being recreated. There is no SSM
tunnel and no manual seed command in the normal flow - a full wake goes from a hibernated stack to
a schema-complete, running backend unattended. Step 5 of the wake simply confirms it happened by
polling /readyz until it is 200.
Why self-bootstrap instead of the old tunnel flow? The predecessor bootstrapped the DB from the
operator’s laptop over an SSM port-forward through a running task (see the emergency fallback below).
That worked but coupled every wake to a local toolchain (session-manager-plugin, pnpm, correct
DATABASE_URL encoding) and to the operator being present. Self-bootstrap moves the whole thing into
the container that already has the code and the credentials, making a wake genuinely hands-off. It was
validated on the 2026-07-13 wake.
The seed admin’s password persists in Secrets Manager (firepath-ai/prod/seed-admin), so a fresh wake
produces a real admin login without any local .env or prompt. Prod bootstrap seeds without demo
data (seed({demoData:false})), so real users never see placeholder fires, assets, or places - this
was a fix after prod once seeded a fake fire into the shared default org. Real “what’s around me” places
come from the OSM/Overpass ingest (PLACES_INGEST_ENABLED, off by default) - an honest empty state
until it is enabled and validated on a wake; no hand-invented coordinates.
Auto-DNS - the ALB hostname changes every wake
Because compute hibernates, a wake destroys and recreates the ALB, so the load balancer gets
a brand-new AWS hostname every time (only the data tier persists - the ALB does not). Step 6 of a
full wake re-points api.firepath.software at the new ALB so the public API endpoint follows the
ephemeral load balancer automatically.
- If
CLOUDFLARE_API_TOKEN(scopedZone:DNS:Editonfirepath.software) andCLOUDFLARE_ZONE_IDare exported, the script updates theapiCNAME via the Cloudflare API (TTL 60s, DNS-only / proxy off). Cached clients self-heal within ~1 minute. Set these two vars once in a gitignored env file andsourceit before waking. - If they’re not set, the script prints the exact record to set by hand (Type
CNAME, Nameapi, Target = the new ALB hostname, TTL 60, Proxy off).
The three Cloudflare surfaces are never recreated, so their DNS never churns - only api. needs
attention on a wake. See Infrastructure.
Readiness checks
The api-server exposes two probes (defined in
Fire-Path-AI/artifacts/api-server/src/routes/health.ts). The distinction is load-bearing - see the
incident section:
GET /api/healthz- cheap liveness. Returns200 {status:"ok"}whenever the process is up, regardless of dependencies. It’s the ALB target-group health check and a fast “is the container running?” signal, and it is deliberately never written to the audit log.GET /api/readyz- readiness. Verifies dependencies (database reachable, and ingest freshness) before the task is considered ready to serve. Healthy is200 {status:"ready", db:"ok"};db:"down"or a503means a dependency is unhealthy. Afullwake verifies the task came up healthy via/readyzrather than assuming success.
/healthz staying 200 while /readyz is 503 is expected and correct - liveness is not
readiness. Do not “fix” a 503 by restarting a container that is passing liveness; find the failing
dependency.
You can watch both continuously with the record-keeping monitor:
# Rehearse the whole loop COLD against a local api-server (no spend, no AWS session needed):API_BASE=http://localhost:3000/api ITERATIONS=3 infra/scripts/wake-watch.sh
# During a real HOT wake, prod defaults + live log/alarm checks, infinite loop:infra/scripts/wake-watch.shwake-watch.sh probes /healthz + /readyz every INTERVAL seconds, greps the api-server log for
life-safety markers (AUDIT LOG INSERT FAILED, undelivered, pool error, ingest staleness, …),
counts CloudWatch alarms in ALARM, and writes a timestamped evidence file under wake-evidence/. It
degrades cleanly: endpoint probes always run, and the AWS log/alarm checks only run when an authed
firepath-admin session is present - otherwise they report “skipped (local)”. The design rule is
“no output = not checked”, so every check leaves a durable record rather than relying on eyeballing.
Deploying code to a HOT stack
The first image on a wake comes from wakeup.sh full itself. Subsequent code deploys during
the same HOT session go through the manual GitHub Actions workflow, .github/workflows/deploy.yml:
- Manual trigger only (
workflow_dispatch), because the stack is COLD by default - a deploy is a deliberate action taken during a HOT wake, never an auto-push. It requires typingdeployinto a confirm input, and a guard step fails the run if you don’t. - Credential-free - it assumes an IAM role via GitHub OIDC (trust pinned to the repo’s
mainref), so no long-lived AWS keys live in CI. - It builds + pushes an image tagged with the 12-char commit SHA (and
:latest), renders a new ECS task definition from the currently-deployed one, deploys withwait-for-service-stability: true, then runs a post-deploy smoke test curling/api/healthz(must contain"status":"ok") and/api/readyzon the live ALB.
Because every image is tagged with its commit SHA, rollback is just re-deploying a known-good tag - there is no separate rollback tooling to learn.
Observability
The Terraform observability module (compute-gated, so it has zero footprint while hibernated) provisions, on every HOT wake:
- An SNS alert topic that emails a configured address on any alarm.
- CloudWatch alarms across the stack, including metric-filter alarms per life-safety log
string -
AUDIT LOG INSERT FAILED,EMERGENCY ALERT UNDELIVERED,READINESS: ingest sources are stale,UNCAUGHT EXCEPTION/UNHANDLED PROMISE REJECTION- plus ALB 5xx and p99 latency, ALB unhealthy hosts, ECS CPU / memory / no-running-tasks, and (when the data tier is up) RDS CPU / free storage / connections. - A code-defined ops dashboard tying those signals together - the single place to correlate ALB / ECS / RDS signals.
RDS alarms are gated on the static data-tier flag (known at plan time), so a fresh wake’s plan stays deterministic.
The alarm-to-response mapping is the incident runbook, summarised next.
Incident response by alarm class
Each alarm maps to a likely cause and a fix. The authoritative version (with diagnosis commands) is
OPS-RUNBOOK.md in the repo root; the essentials:
- API down /
/readyz503 (db:"down"). RDS unreachable - security group, RDS not yet available, connection storm, or credentials rotated without a redeploy. Confirm RDS isavailableand its SG allows the ECS SG, verify the connection secret, force a new ECS deployment. Remember/healthzstaying 200 here is correct. - Ingest stale (
READINESS: ingest sources are stale). Most often an expired/over-quota NASA FIRMS key: rotateNASA_FIRMS_API_KEYin Secrets Manager and redeploy; the mirror endpoint is tried automatically. DEA is a best-effort secondary feed (FIRMS stays authoritative); an OpenMeteo failure keeps the last-good layer rather than half-wiping it. Every external call has a 15s timeout + retry, so a single blip self-heals - sustained staleness means an upstream outage or a credential problem. - Alerts not delivered (
EMERGENCY ALERT UNDELIVERED). Push provider outage, all device tokens stale, or web VAPID unset. Emergency Warnings never age out - they retry until delivered or the alert clears; stale tokens are auto-revoked; dedup is race-safe. If web push is silent, set theVAPID_*env vars. Email fallback is on by default (THREAT_EMAIL_FALLBACK_ENABLED=truein the prod api-server task def): it self-gates on the Resend key, so with noRESEND_API_KEYon the stack it is a safe no-op, and with the key it sends email as a backup channel when a push delivery fails. SMS fallback stays off (needs Twilio). - Audit trail dropping (
AUDIT LOG INSERT FAILED). A compliance/forensics gap - treat as high priority. Check theaudit_logtable exists and its schema matches; the log line reports how many events were dropped. - Process crash loop (
UNCAUGHT EXCEPTION). By design the process logs the stack and exits(1) so ECS replaces the task cleanly - continuing in an undefined state is worse for a life-safety system. A repeating crash is a deterministic bug: roll back to the last-known-good image tag. - Auth failure spike (500s on protected routes). A 500 (not 401) means a server-side problem -
a missing
JWT_SECRETor a DB outage during the token-revocation check. A spike of 401s instead is normal (expired tokens) and not an incident.
The alarm signals and their healthy values are enumerated in the runbook’s “Signals to watch” table.
Hibernating
infra/scripts/hibernate.sh tears compute back to COLD while leaving the data tier up (the
PERSIST_DATA=true default):
bash infra/scripts/hibernate.sh # prints what it will destroy, then asks y/N- Destroys (the compute cost) - NAT gateway + Elastic IP, the ECS services + ALB.
- Preserves - the persistent data tier (RDS + Redis + the
database-url/redis-urlsecrets), so real accounts and data survive; plus the baseline VPC + subnets + security groups + S3 gateway endpoint, the DNS hosted zone, ACM TLS certs, the KMS key + JWT/FIRMS secrets, IAM roles + OIDC, and Terraform state. When persisting, RDS keeps deletion protection and any destroy takes a final snapshot first.
A deliberate full teardown (PERSIST_DATA=false bash infra/scripts/hibernate.sh, or --ephemeral)
additionally destroys RDS, ElastiCache Redis, and the data-tier secrets - all data is lost, taking
a final snapshot first. That is the explicit opt-out, not the default.
The script clears known teardown blockers idempotently before the destroy - it disables ALB
deletion-protection (the live flag can drift from Terraform state) and empties the ECR repo so it can
be destroyed - then runs the destroy as a terraform apply with the tiers flagged off.
After a hibernate, the api.firepath.software CNAME points at a now-dead ALB hostname - requests
return DNS errors and the web-app SPA shows its “Service is paused” banner. That’s expected; cleaning
up the stale record is optional.
Known infra follow-up (non-blocking)
The Terraform S3 backend still locks state via a DynamoDB table, and Terraform now deprecates that in
favour of native S3 state locking (use_lockfile = true). It’s a warning, not an error - wakes
still succeed. The migration (bump required_version to >= 1.11, swap the lock line, terraform init -reconfigure, then decommission the DynamoDB table) should be done COLD, off the critical path of
a HOT wake, and every operator/CI running Terraform must switch at the same time (mixed checkouts
don’t share a lock). Full steps are in OPS-RUNBOOK.md.
Where the detail lives
- Infrastructure - hosting topology, the four surfaces, Terraform modules + feature flags, the hibernate/wake cost model at the architecture level.
- Configuration reference - every environment variable (generated
from the code), including
DB_BOOTSTRAP_ON_BOOT,JWT_SECRET,NASA_FIRMS_API_KEY, and theVAPID_*keys referenced above. - Overview & architecture - the application tiers, the ingest → predict → alert pipeline, and the fire-science model behind the wind-worker.