Repository navigation
Architecture
How the module is put together, and how a request moves through the deployed system.
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 |
Terraform derives the order from references, but the shape is worth knowing
because it explains a circularity you will notice reading main.tf:
-
networkingfeedsmonitoring(NAT gateway ids),database,containers,maintenanceanddemo-loop. -
databasefeedssecrets(the connection string), andsecretsandauthfeedidentity(secret ARNs) andcontainers(secret ARNs to inject). -
identityfeedscontainers,maintenanceanddemo-loop(the two ECS roles), andcontainersfeedsidentitythe ECR repository URLs. -
containersfeedsdatabase(the ECS tasks security group),messaging(the API URL), andmaintenanceanddemo-loop(the cluster and security group). -
storagestands 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.
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 = falseon every task. - One NAT gateway by default, shared by both private route tables. With
enable_multi_az_nat = trueeach 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.
- A browser or an endpoint agent resolves the ALB DNS name.
- Port 80 redirects to 443 with a 301. Port 443 forwards to the frontend target group by default.
- A listener rule at priority 100 matches
/api/v1/*and forwards to the API target group instead. - The API target group sends traffic to ECS task IPs on port 8000; the frontend target group on port 3000.
- 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.
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.
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.
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.
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.
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.
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_emailis 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.
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.
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>.
MIT licensed. — reportmate.app