Skip to content

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.

SurfaceDomainHostWhat it isAlways on?
Marketingwww.firepath.softwareCloudflare PagesStatic marketing site✅ Yes
Developer docsdocs.firepath.softwareCloudflare PagesStatic docs (this site)✅ Yes
Web appapp.firepath.softwareCloudflare WorkersThe app UI (static bundle)✅ Yes
API backendapi.firepath.softwareAWS (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.software is Cloudflare (the registrar is Spaceship). Each Pages/Workers project has a fixed origin, so the docs. / www. / app. custom-domain records are set once and never churn. Only api. 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 the api. 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.

ModuleOwns
networkingVPC, public / private-app / private-DB subnets, security groups, NAT, gateway endpoints
dataRDS Postgres + PostGIS, ElastiCache Redis (gated on enable_data_tier)
secretsSecrets Manager entries, IAM task/execution roles, GitHub OIDC
computeECS cluster, api-server + wind-worker services, ALB, autoscaling (gated on enable_compute)
observabilitySNS alert topic, CloudWatch alarms, ops dashboard (gated on enable_compute)
networking→interface endpointsoff 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):

FlagDefaultEffect
enable_data_tierfalseCreate RDS + ElastiCache + the data-coupled secrets (database-url, redis-url).
enable_computefalseCreate NAT + ECS cluster/service + ALB. Implicitly turns NAT on (ECS needs it to pull from ECR).
enable_wind_workertrueRun the dedicated WindNinja worker tier (terrain-refined wind, proven working). wakeup.sh builds + pushes the worker image.
enable_fire_clusteringtrueSets FIRE_CLUSTERING_ENABLED on the api-server (Phase 14 fire-tracking write path).
db_bootstrap_on_boottrueOn boot the api-server migrates (additively) + runs an idempotent seed against its DB - the default AWS-native wake path.
persist_datatrueKeep 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:

Terminal window
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
StageWhat’s runningIdle cost (approx)Woken by
COLDPersistent 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)
WARMCOLD + NAT~AUD $235/mowakeup.sh data
HOTWARM + ALB + ECS api-server + wind-worker + fire clustering~AUD $290/mo + ECS task-hourswakeup.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. Each wakeup.sh full creates a new ALB with a new hostname, so the api.firepath.software CNAME must be updated or clients hit a dead host (“hotspots gone after a wake”). wakeup.sh auto-updates it via the Cloudflare API when CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID are 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). Hibernate force_deletes it, so after a clean cycle it is absent from both; wakeup.sh reconciles this by creating-if-absent then attempting an idempotent terraform 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 destroy releases the NAT’s EIP, but if the NAT’s ENI is already gone the release errors InvalidNetworkInterfaceID.NotFound, the apply exits non-zero, and the EIP is left allocated (~AUD $3.6/mo). hibernate.sh sweeps 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-admin and unset AWS_DEFAULT_REGION, because a shell defaulting to an unrelated profile at a different SSO directory makes aws ecr get-login-password etc. 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 with aws sso login --profile firepath-admin.
  • WindNinja base image missing. wakeup.sh full fails loudly (docker image inspect) if the local WindNinja base isn’t present, rather than deploying a broken worker task. Rebuild/pull it per VENDOR.md before 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.