Skip to content

Architecture

Rod Christiansen edited this page Oct 8, 2026 · 2 revisions

Architecture

How the module is put together, and how a request moves through the deployed system.

Module layout

The root module (main.tf) is pure wiring — it declares no resources of its own except a data "aws_caller_identity" lookup. Everything is in modules/.

Module Responsibility
networking VPC, public/private subnets, IGW, NAT, route tables, VPC endpoints, flow logs
monitoring CloudWatch log groups, SNS alert topic, cost budget, NAT egress alarm
database RDS PostgreSQL 16, subnet group, database security group
secrets Five Secrets Manager entries
storage SQS ingest queue + DLQ, general-purpose S3 bucket
auth Cognito user pool, hosted-UI domain, app client, groups, auth secrets
identity ECS task execution role and ECS task role
containers ECR repos, ECS cluster, ALB, target groups, task definitions, services, autoscaling
messaging API Gateway v2 WebSocket API for real-time events
maintenance Nightly database-cleanup task on EventBridge Scheduler
demo-loop Optional synthetic-data generator service

Dependency order

Terraform derives the order from references, but the shape is worth knowing because it explains a circularity you will notice reading main.tf:

  • networking feeds monitoring (NAT gateway ids), database, containers, maintenance and demo-loop.
  • database feeds secrets (the connection string), and secrets and auth feed identity (secret ARNs) and containers (secret ARNs to inject).
  • identity feeds containers, maintenance and demo-loop (the two ECS roles), and containers feeds identity the ECR repository URLs.
  • containers feeds database (the ECS tasks security group), messaging (the API URL), and maintenance and demo-loop (the cluster and security group).
  • storage stands alone.

database takes module.containers.ecs_tasks_security_group_id in its allowed_security_group_ids, and containers takes module.database.security_group_id. This does not deadlock only because the containers module never uses the database_security_group_id variable it is given — the database-side ingress rule is what actually opens port 5432.

Network topology

One VPC, 10.0.0.0/16 by default, across two availability zones.

  • Public subnets, cidrsubnet(vpc_cidr, 8, 0..N). Hold the ALB and the NAT gateways. Routed to the internet gateway.
  • Private subnets, cidrsubnet(vpc_cidr, 8, 100..100+N). Hold the ECS tasks and RDS. assign_public_ip = false on every task.
  • One NAT gateway by default, shared by both private route tables. With enable_multi_az_nat = true each AZ gets its own NAT and its own default route, which avoids cross-AZ NAT data transfer and survives an AZ outage.

There is always a free S3 gateway endpoint on the private route tables so S3 traffic does not traverse NAT. Interface endpoints for ecr.api, ecr.dkr, logs, and secretsmanager are opt-in via enable_vpc_endpoints, because each interface endpoint is billed per AZ per hour whether or not it saves NAT egress.

Request flow

  1. A browser or an endpoint agent resolves the ALB DNS name.
  2. Port 80 redirects to 443 with a 301. Port 443 forwards to the frontend target group by default.
  3. A listener rule at priority 100 matches /api/v1/* and forwards to the API target group instead.
  4. The API target group sends traffic to ECS task IPs on port 8000; the frontend target group on port 3000.
  5. The API task connects to RDS on 5432 across the private subnets. The database security group only accepts traffic referencing the ECS tasks security group.

Health checks: the API target group probes /api/v1/health, the frontend target group probes /. The API container additionally has a container-level curl health check with a 15s timeout, 5 retries, and a 90s start period — deliberately generous, because /api/v1/health opens a database connection and a cold start under database pressure would otherwise crash-loop the task.

Secrets flow

Nothing sensitive is put in a task definition's environment block. The module creates eight secrets; five of them reach containers through the ECS secrets mechanism, which means the task execution role fetches them from Secrets Manager at task start. db-password, cognito-client-id and cognito-client-secret are stored but not injected into any container.

Container Injected as
api DATABASE_URL, API_INTERNAL_SECRET, REPORTMATE_PASSPHRASE
frontend API_INTERNAL_SECRET, KIOSK_TOKENS, NEXTAUTH_SECRET
maintenance DATABASE_URL
demo-loop REPORTMATE_PASSPHRASE

The execution role's inline policy grants secretsmanager:GetSecretValue on exactly the ARNs the root module passes it — the five from secrets plus the three from auth.

NEXTAUTH_SECRET is generated by Terraform (random_password, 64 characters) rather than supplied. Without it NextAuth raises MissingSecretError and every /api/auth/session call returns 500, which gates the entire dashboard.

Authentication

Cognito, not Entra ID. The user pool uses email as the username attribute, requires 12-character passwords with all four character classes, has optional software-token MFA, and runs advanced security in ENFORCED mode. The web app client uses the authorization-code flow with a client secret and the openid, email, and profile scopes; access and id tokens last one hour, refresh tokens 30 days.

Two groups exist, admin and user. Terraform creates the groups but not the memberships.

Real-time events

modules/messaging creates an API Gateway v2 WebSocket API with $connect, $disconnect, and $default routes, all pointing at one HTTP_PROXY integration whose URI is <api_url>/v1/negotiate. The production stage auto-deploys and throttles at 50 requests/second with a burst of 100.

Note that the integration URI is built from module.containers.api_url, which is http://<alb-dns>/api, giving http://<alb-dns>/api/v1/negotiate — that path is matched by the /api/v1/* listener rule on the 443 listener. The port 80 listener has no rules and answers every request with a 301 to HTTPS, so the integration only reaches the API if that redirect is followed.

Scheduled maintenance

modules/maintenance builds a separate ECR repository and a Fargate task definition, and runs it once a day at 02:00 UTC via EventBridge Scheduler. The task gets EVENT_RETENTION_DAYS and DATABASE_URL, and runs in the private subnets with the ECS tasks security group. The scheduler has its own IAM role allowed only to ecs:RunTask on that task definition and to iam:PassRole on the two ECS roles.

The image is not built by this repo; the repository is created empty and tagged latest in the task definition.

Ingest queue

modules/storage creates an SQS queue named <project>-<env>-osquery-ingest with a dead-letter queue and a redrive policy at three receives. It is exported as an output but nothing in this module wires it into a container. Treat it as available plumbing, not an active path.

Cost guardrails

The module is unusually opinionated about cost visibility. With the default empty alert_email, the budget is not created and the NAT alarms publish to a topic with no subscriber:

  • VPC Flow Logs to a private S3 bucket with a 30-day expiry — on by default.
  • A CloudWatch alarm on each NAT gateway's hourly BytesOutToDestination, defaulting to 20 GB, publishing to the SNS alerts topic.
  • A daily AWS Budget — created only if alert_email is non-empty, since the budget notification needs a subscriber address.

The SNS topic policy explicitly allows budgets.amazonaws.com and cloudwatch.amazonaws.com to publish.

Autoscaling

Both the API and frontend services get an Application Auto Scaling target of min 1 / max 5 tasks with a target-tracking policy on ECSServiceAverageCPUUtilization at 70%, 60s scale-out cooldown and 300s scale-in cooldown. These bounds are hardcoded in modules/containers, not exposed as variables.

Tagging

The root provider block sets default_tags of Project = reportmate, Environment = var.environment, ManagedBy = terraform on everything the provider creates. Individual resources add a Name tag of <project_name>-<environment>-<thing>.

Clone this wiki locally