Infrastructure
Last reviewed against the codebase: 2026-07-25.
How the platform is hosted. Read top-to-bottom by someone new to the project, with the deeper mechanics called out as we go. This page covers the topology, Terraform, and the cost model; the day-to-day runbook (waking, bootstrapping, observing, hibernating) lives in Operations, and this page cross-links to it rather than repeating it.
The one thing to understand first
Fire Path AI is four separate deployment surfaces spread across two clouds. They are independent: taking one down does not take the others down. Most confusion about “is the site up?” comes from not knowing which surface you’re actually talking about.
| Surface | Domain | Host | What it is | Always on? |
|---|---|---|---|---|
| Marketing | www.firepath.software | Cloudflare Pages | Static marketing site | ✅ Yes |
| Developer docs | docs.firepath.software | Cloudflare Pages | Static docs (this site) | ✅ Yes |
| Web app | app.firepath.software | Cloudflare Workers | The app UI (static bundle) | ✅ Yes |
| API backend | api.firepath.software | AWS (ECS + RDS + ALB) | The server + database | ❌ Hibernates |
Only the AWS API backend hibernates. The three Cloudflare surfaces are static assets served from Cloudflare’s edge and run 24/7 at effectively no cost, regardless of what AWS is doing.
Why split across two clouds at all?
This is deliberate, not accidental. The static surfaces need to be cheap, global, and always up
even when the backend is asleep - Cloudflare’s edge does that for ~$0. The backend needs a spatial
database (PostGIS), long-running compute for WindNinja terrain-wind jobs, and AU data
residency - all of which AWS Sydney provides and Cloudflare’s edge does not. Splitting them also
means a maintainer can safely tear the expensive half down to ~$0 between work sessions without ever
touching the public-facing sites. The trade-off is one moving part on each wake - the api. DNS
record - covered under Gotchas.
Cloudflare surfaces (DNS + static sites)
- Marketing + Docs are static Astro builds on Cloudflare Pages; the web-app shell is
an Expo web export on a Cloudflare Worker (project
firepathai, SPA fallback). All three make no calls that keep them alive - they serve from the edge whether AWS is HOT or COLD. - DNS authority for
firepath.softwareis Cloudflare (the registrar is Spaceship). Each Pages/Workers project has a fixed origin, so thedocs./www./app.custom-domain records are set once and never churn. Onlyapi.requires attention on a wake, because the ALB behind it is ephemeral (see Operations). - The web-app shell always loads, but its data comes from
api.firepath.software. When AWS is COLD theapi.CNAME points at a hostname that no longer exists, so requests fail - the SPA detects this and shows a “Service is paused” / offline banner. It is asleep, not broken. - Docs is private + non-indexed (behind a Cloudflare Access login gate,
noindex), so it can be published while incomplete; marketing is public + indexed, and its release is human-gated.
AWS API backend - the architecture
The one surface with real running cost, in AWS Sydney (ap-southeast-2, chosen for AU data
residency - a Government/enterprise requirement). At a full wake it comprises:
Internet ──▶ ALB (public subnets) ──▶ ECS Fargate (private subnets) ├─ api-server tier (arm64) └─ wind-worker tier (amd64, WindNinja) │ │ RDS Postgres+PostGIS ◀─┘ └─▶ ElastiCache Redis (private DB subnets) (BullMQ queue/cache)
Private subnets reach the internet via a NAT gateway; images from ECR; config + credentials from Secrets Manager; logs + alarms in CloudWatch.- ECS Fargate - two tiers. The api-server (arm64) runs the Node/Express API; the
wind-worker (amd64) runs containerised WindNinja terrain-wind jobs off a Redis/BullMQ
queue. Two tiers = two CPU architectures = two images, built and pushed together on a full wake.
arm64 is cheaper for the always-warm API; the WindNinja base image is only published for amd64,
which forces that tier to amd64. The worker tier is feature-flagged (
enable_wind_worker) and only comes up when a real worker image is present. - ALB. An Application Load Balancer in the public subnets terminates TLS (an ACM cert) and routes to the ECS tasks in the private subnets; its target group health-checks the api liveness probe. It is the only internet-facing entry point - the ECS tasks and database have no public IPs.
- RDS PostgreSQL + PostGIS. The spatial database, in dedicated private DB subnets, reachable only
from the app security group. It persists across hibernate by default (
persist_data = true), sized for cost-safe testing at db.t4g.micro, single-AZ, 20 GB gp3, with deletion protection and a final snapshot on any destroy. Prod-grade HA (db.t4g.medium, multi-AZ) is a later bump - see the persistence note below. - ElastiCache Redis. A single-node cache.t4g.micro (a replica is added at the prod-HA step), backing the BullMQ job queue and cache. It persists alongside RDS.
- NAT gateway. Egress for the private subnets (external feeds, image pulls) - a cost item, so it
only exists while compute is up (
enable_nat_gateway = var.enable_compute). - ECR. Registries for the two images (
firepath/api-server,firepath/wind-worker). - Secrets Manager. Holds the database + Redis connection URLs, the JWT signing secret, the NASA FIRMS key, and the email-provider key - injected into tasks at runtime, never baked into images. See Security for the secret map and Configuration reference for what each one drives.
Startup, health probes, and the operational runbook for this backend are documented in Operations.
Terraform as IaC
Everything above is Terraform (infra/terraform), with remote state in S3. The root wires a
small set of modules and drives them by feature flags rather than by editing resources - the same
.tf files describe a persistent-data COLD stack and a full HOT stack; only the variables change.
| Module | Owns |
|---|---|
| networking | VPC, public / private-app / private-DB subnets, security groups, NAT, gateway endpoints |
| data | RDS Postgres + PostGIS, ElastiCache Redis (gated on enable_data_tier) |
| secrets | Secrets Manager entries, IAM task/execution roles, GitHub OIDC |
| compute | ECS cluster, api-server + wind-worker services, ALB, autoscaling (gated on enable_compute) |
| observability | SNS alert topic, CloudWatch alarms, ops dashboard (gated on enable_compute) |
| networking→interface endpoints | off by default (option B) - see the design note below |
Conditional modules use Terraform count (count = var.enable_compute ? 1 : 0), which is why the
compute/data/observability outputs are null when their flag is off - downstream references
guard with length(module.x) > 0.
The flags - the whole cost dial
The wake modes are just presets of these root variables (infra/terraform/variables.tf):
| Flag | Default | Effect |
|---|---|---|
enable_data_tier | false | Create RDS + ElastiCache + the data-coupled secrets (database-url, redis-url). |
enable_compute | false | Create NAT + ECS cluster/service + ALB. Implicitly turns NAT on (ECS needs it to pull from ECR). |
enable_wind_worker | true | Run the dedicated WindNinja worker tier (terrain-refined wind, proven working). wakeup.sh builds + pushes the worker image. |
enable_fire_clustering | true | Sets FIRE_CLUSTERING_ENABLED on the api-server (Phase 14 fire-tracking write path). |
db_bootstrap_on_boot | true | On boot the api-server migrates (additively) + runs an idempotent seed against its DB - the default AWS-native wake path. |
persist_data | true | Keep the data tier (RDS + Redis) up across hibernate, with RDS deletion protection + a final snapshot on any destroy. The full teardown is the opt-out. |
The cost model - COLD / WARM / HOT
The backend rests in COLD by default (compute torn down, persistent data tier up) and is woken to
WARM or HOT only for a work session. The stage Terraform output is the single source of truth
for which state the flags currently describe - run it before any apply as a sanity check:
cd infra/terraform && terraform output stage# COLD (~AUD $35–40/mo idle) ← default resting state (persistent data tier up)# WARM (~AUD $235/mo idle) ← enable_data_tier=true, enable_compute=false# HOT (~AUD $290/mo idle) ← both true# ABNORMAL - compute without data tier ← a misconfiguration guard| Stage | What’s running | Idle cost (approx) | Woken by |
|---|---|---|---|
| COLD | Persistent RDS (db.t4g.micro, single-AZ) + Redis (cache.t4g.micro) + VPC, subnets, SGs, DNS zone, TLS certs, KMS + JWT + FIRMS secrets, TF state | ~AUD $35–40/mo | (resting default) |
| WARM | COLD + NAT | ~AUD $235/mo | wakeup.sh data |
| HOT | WARM + ALB + ECS api-server + wind-worker + fire clustering | ~AUD $290/mo + ECS task-hours | wakeup.sh full |
The biggest idle line items are now the persistent RDS db.t4g.micro and Redis cache.t4g.micro;
the biggest when fully up are NAT + Elastic IP (~$50/mo), ALB (~$25/mo) and ECS tasks
(~$30/mo each tier). Because compute is the on-demand cost, hibernate destroys exactly it and keeps
the small persistent data tier up. Prod-grade HA later (db.t4g.medium, multi-AZ, a Redis replica) is
~AUD $180/mo - single-AZ now is deliberate for cost-safe testing, not final HA; see
DB-PERSISTENCE-PLAN.md. The :::caution banners in the scripts print this breakdown and require a
y confirmation before any apply - a deliberate warn-before-wake control.
Gotchas & failure modes a new maintainer will hit
These are the real ones that have bitten this stack; each is now handled in the scripts, but you need to recognise them when they resurface.
api.DNS is re-pointed every wake. Eachwakeup.sh fullcreates a new ALB with a new hostname, so theapi.firepath.softwareCNAME must be updated or clients hit a dead host (“hotspots gone after a wake”).wakeup.shauto-updates it via the Cloudflare API whenCLOUDFLARE_API_TOKEN+CLOUDFLARE_ZONE_IDare set (TTL 60s, DNS-only); otherwise it prints the manual step. If the app can’t reach the backend right after a wake, check this record first.- ECR “RepositoryAlreadyExists” at apply. The api-server ECR repo must be both present in AWS
(for
docker push) and tracked in Terraform state (so apply treats it as managed, not new). Hibernateforce_deletes it, so after a clean cycle it is absent from both;wakeup.shreconciles this by creating-if-absent then attempting an idempotentterraform import, tolerating “already managed”. A historic bug created the repo but never imported it, so every subsequent wake collided. - Orphaned Elastic IP after hibernate.
terraform destroyreleases the NAT’s EIP, but if the NAT’s ENI is already gone the release errorsInvalidNetworkInterfaceID.NotFound, the apply exits non-zero, and the EIP is left allocated (~AUD $3.6/mo).hibernate.shsweeps any project-tagged, unassociated EIP and releases it, then re-runs the destroy to reconcile state. - AWS profile leaking from another shell. Both scripts hard-pin
AWS_PROFILE=firepath-adminandunset AWS_DEFAULT_REGION, because a shell defaulting to an unrelated profile at a different SSO directory makesaws ecr get-login-passwordetc. silently hit the wrong account with confusing “token does not exist” errors. If the SSO browser opens the wrong directory, open a fresh terminal. Re-auth withaws sso login --profile firepath-admin. - WindNinja base image missing.
wakeup.sh fullfails loudly (docker image inspect) if the local WindNinja base isn’t present, rather than deploying a broken worker task. Rebuild/pull it perVENDOR.mdbefore waking with the worker tier on.
Where the detail lives
- Operations - wake/hibernate runbook, DB self-bootstrap, auto-DNS, readiness, observability, incident response.
- Configuration reference - every environment variable (generated from the code).
- Security - the secret map, transport/TLS, IAM roles, data residency.
- Overview & architecture - the application tiers and fire-science model.