Terraform Layout
Context
A single-operator AWS demo of a RAG Pipeline
Tech Stack
- Lambda (FastAPI/Mangum)
- Aurora Serverless v2
- S3/CloudFront SPA
- Bedrock Haiku-class generation model
- Bedrock Guardrails
Environments
- QA and Prod
- separate VPCs under one account
Documentation Context
- RAG001: two-VPC model and named the module/root structure
- RAG010: what the VPC must not contain
- RAG011: Aurora connection
- This document records how the Terraform layout is structured to make those decisions structural rather than advisory.
Layout overview
Three tiers:
- a one-time bootstrap root
- a set of reusable child modules
- two thin per-environment roots that compose those modules
Five child modules — network, aurora, lambda, api_gateway, and secrets_iam — each module owns one concern and exposes only what downstream modules need. Module outputs are the only cross-module data path; no module reads another module’s state by lookup.
Environment root, per environment (infra/roots/qa/ and infra/roots/prod/): each composes all five modules. The only meaningful difference between them is the set of input values in terraform.tfvars. The infra/bootstrap/ root is separate: it provisions the state bucket, the GitHub Actions OIDC provider, and each environment’s deploy role, and is never part of the per-env plan/apply cycle. Remote state is backed by S3 with S3-native locking.
Environment roots
infra/roots/qa/ and infra/roots/prod/ have identical structure:
infra/
qa/
main.tf # module composition
variables.tf # env-specific input declarations
outputs.tf # surface useful ARNs / URLs
terraform.tfvars
backend.tf # S3 backend config, keyed to qa
prod/
(same structure)
Each root calls all five modules in dependency order: network → aurora → lambda → api_gateway → secrets_iam. Module outputs are passed as inputs to downstream modules directly as local values or module references.
QA and Prod differ only by their input values in terraform.tfvars:
| Input | QA | Prod |
|---|---|---|
vpc_cidr | 10.10.0.0/16 | 10.20.0.0/16 |
subnet_cidrs | ["10.10.1.0/24","10.10.2.0/24"] | ["10.20.1.0/24","10.20.2.0/24"] |
aurora_min_capacity_acu | 0 | 0.5 |
aurora_max_capacity_acu | 4 | 8 |
env tag | qa | prod |
Modules
Current and future modules follow the same pattern: create modules/<name>/, define inputs and outputs, call it from both roots with the same module-output wiring, add any new IAM actions to the deploy-role policy in the cicd module, and re-apply infra/bootstrap/ manually.
Each module is described in more detail here.
modules/network
Scope: VPC, two private subnets across two AZs, Aurora subnet group, Aurora security group. Nothing else.
- Inputs:
vpc_cidr,subnet_cidrs(list of 2),env(tag),aws_region - Outputs:
vpc_id,subnet_ids(list of 2),aurora_sg_id,aurora_subnet_group_name
The module contains no aws_internet_gateway, aws_nat_gateway, or aws_vpc_endpoint resources. For RAG010 - no IGW, no NAT, code review keeps IGW/NAT/endpoints out; a NAT Gateway or PrivateLink endpoint also trips the RAG016 Infracost ceiling.
The Aurora security group allows inbound traffic from the VPC CIDR. Nothing else runs in the VPC today, so the rule admits NO TRAFFIC; it’s in place for a future in-VPC workload.
modules/aurora
Scope: Aurora Serverless v2 cluster and instance, parameter group.
- Inputs:
subnet_group_name(from network),security_group_id(from network),min_capacity_acu,max_capacity_acu,db_name,master_username,env(tag) - Outputs:
cluster_arn,db_secret_arn(no endpoint output)
The module outputs cluster_arn and db_secret_arn only — no endpoint, reader_endpoint, or port. Downstream modules that receive only the ARN pair cannot construct a connection string, which enforces RAG011 - RDS Data API as the sole network path at the interface boundary. Prevents psycopg usage from application Lambdas (on purpose).
modules/lambda
Scope: Lambda function (API) and Lambda function (Authorizer), their execution roles, CloudWatch log groups, X-Ray tracing config.
- Inputs:
cluster_arn(from aurora),db_secret_arn(from aurora),api_image_uri,authorizer_image_uri,env,aws_region,account_id - Outputs:
api_function_arn,api_function_invoke_arn,authorizer_function_arn,authorizer_function_invoke_arn
The execution role for both the API and Authorizer Lambdas omits rds:Connect and any VPC-attachment permission (ec2:CreateNetworkInterface etc.), which would be required for psycopg from an in-VPC Lambda. This enforces RAG011 via IAM, complementing the topology-level enforcement in the aurora module.
modules/api_gateway
Scope: HTTP API, default stage with auto-deploy, Lambda authorizer attachment on authenticated API calls, route definitions, Lambda integration permissions.
- Inputs:
api_function_invoke_arn(from lambda),authorizer_function_invoke_arn(from lambda),env - Outputs:
api_endpoint_url,api_id
Caching is explicitly set to authorizer_result_ttl_in_seconds = 0 per RAG009 — the decrement-on-call model is incompatible with any cached allow. Hard-coded, so no environment root can override it.
modules/secrets_iam
Scope: Secrets Manager secrets for application config (not the DB credential, which the Aurora module owns) and IAM boundary policy. The OIDC deploy roles are not here; infra/bootstrap/ owns them (see State backend).
- Inputs:
cluster_arn,db_secret_arn,env - Outputs:
app_config_secret_arns
State backend
infra/bootstrap/ is applied manually, never by a workflow. It provisions:
- the S3 bucket that holds Terraform state: versioned, encrypted, public-access block, old versions expire after 90 days
- the GitHub Actions OIDC provider
- each environment’s deploy role and its permission policy, through the
cicdmodule
Bootstrap’s first apply uses local state in infra/bootstrap/terraform.tfstate, because the bucket does not exist yet. That state is then moved to S3 (spin-up guide, step 4).
The deploy role determines access to the state file: GitHub Actions assumes the environment’s role during plan/apply.
Structural guarantees
RAG010, RAG011, and RAG009 are structural constraints.
Enforcement:
- RAG010 (no IGW or NAT): the
networkmodule contains none of those resource types and has no input variable that conditionalizes their creation. A NAT Gateway or PrivateLink endpoint also trips the RAG016 Infracost ceiling. - RAG011 (Data API as sole application data path): enforced at two levels: the
auroramodule exposes no connection endpoint and thelambdamodule’s execution role grants no VPC-attachment permissions. - RAG009 (no authorizer caching): TTL is hard-coded to 0 in the
api_gatewaymodule.
Single-environment blast radius. QA and Prod use separate state keys (rag-sample/<env>/terraform.tfstate), separate S3-native lock files, and separate deploy roles. A terraform apply in infra/roots/qa/ reads and writes only QA’s state and lock, and changes only resources in QA’s state. The deploy roles do not enforce that split: each role’s policy covers the whole state bucket and most regional service actions on all resources, and includes s3:DeleteBucket. The shared state bucket is a dependency of both environments.
Accidental cross-env reads. No module uses terraform_remote_state or a data source keyed to the other environment’s state or resources. Module wiring is local: outputs from one module call become inputs to the next within the same root. There is no mechanism for infra/roots/prod/main.tf to read a value out of infra/roots/qa/’s state. This is a structural property of the layout, not a policy.
State file conflicts. S3-native locking (use_lockfile) prevents concurrent applies to the same environment. The GitHub Actions workflow runs plan and apply in serial steps within a single job; two simultaneous workflow runs for the same environment would race on the lock, and the losing runner exits with a lock error rather than silently corrupting state. The .tflock object includes the runner identity and timestamp, which the operator can use to force-unlock after a failed run if needed.
Related artifacts
- ADRs: RAG001 (env topology), RAG009 (API auth), RAG010 (no IGW/NAT), RAG011 (Data API only)
- Network topology — VPC, subnet, and Aurora security group detail this layout provisions
- Infrastructure — deployment topology and per-environment resource inventory
- Environments & CI — how these roots are planned and applied through the pipeline
architecture/_deployments.c4— LikeC4 deployment model- Issues: #19 (Feature), #20 state bootstrap, #21 VPC, #22 Aurora, #23 Lambda, #24 API Gateway, #25 Secrets/IAM
© 2026 Benjamin Arunski