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
The Terraform configuration is organized around three tiers: a one-time bootstrap root, a set of reusable child modules, and two thin per-environment roots that compose those modules.
Five child modules — network, aurora, lambda, api_gateway, and secrets_iam — each own one concern and expose only what downstream modules need. Module outputs are the only cross-module data path; no module reads another module’s state via terraform_remote_state or data sources. This keeps each module’s interface explicit and prevents wiring errors from resolving against the wrong environment.
Two per-environment roots (infra/roots/qa/ and infra/roots/prod/) each compose 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 S3 bucket, runs once with local state, and is never part of the per-env plan/apply cycle. Remote state is S3-backed with S3-native locking (use_lockfile), with state keys namespaced per environment so QA and Prod state files cannot collide. The OIDC deploy role and its permission policy live in the secrets_iam module and are updated in the same PR as any resource that adds a new IAM action requirement.
Modules
modules/network
Scope: VPC, two private subnets across two AZs, Aurora subnet group, Aurora security group. Nothing else.
| Key inputs | Key outputs |
|---|---|
vpc_cidr | vpc_id |
subnet_cidrs (list of 2) | subnet_ids (list of 2) |
env (tag) | aurora_sg_id |
aws_region | aurora_subnet_group_name |
The module contains no aws_internet_gateway, aws_nat_gateway, or aws_vpc_endpoint resources, and no input variable that would conditionally create them. This enforces RAG010 by absence — a reviewer adding egress infrastructure must add it here, where the constraint is visible in one place and the PR diff makes the intent explicit.
The Aurora security group inbound rule sources from var.vpc_cidr (intra-VPC). In the current topology nothing else lives in the VPC, so the rule admits no traffic from any active source. This is intentional: the rule is pre-positioned for a future in-VPC workload, not an active permission today.
modules/aurora
Scope: Aurora Serverless v2 cluster and instance, parameter group, enable_http_endpoint = true, min_capacity = 0.
| Key inputs | Key outputs |
|---|---|
subnet_group_name (from network) | cluster_arn |
security_group_id (from network) | db_secret_arn |
min_capacity_acu | — (no endpoint output) |
max_capacity_acu | |
db_name, master_username | |
env (tag) |
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 at the interface boundary. A module author who needs psycopg access from application code would have to add an output explicitly, where the violation is reviewable.
modules/lambda
Scope: Lambda function (API) and Lambda function (Authorizer), their execution roles, CloudWatch log groups, X-Ray tracing config. Does not create the API Gateway or its routes.
| Key inputs | Key outputs |
|---|---|
cluster_arn (from aurora) | api_function_arn |
db_secret_arn (from aurora) | api_function_invoke_arn |
api_image_uri | authorizer_function_arn |
authorizer_image_uri | authorizer_function_invoke_arn |
env | |
aws_region, account_id |
The execution role for both the API and Authorizer Lambdas is scoped to rds-data:ExecuteStatement, rds-data:BatchExecuteStatement, rds-data:BeginTransaction, rds-data:CommitTransaction, rds-data:RollbackTransaction on the cluster_arn input, and secretsmanager:GetSecretValue on the db_secret_arn input. Neither role holds rds:Connect or 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 POST /query, route definitions, Lambda integration permissions.
| Key inputs | Key outputs |
|---|---|
api_function_invoke_arn (from lambda) | api_endpoint_url |
authorizer_function_invoke_arn (from lambda) | api_id |
env |
The authorizer is wired with authorizer_payload_format_version = "2.0" and enable_simple_responses = true. Caching is explicitly set to authorizer_result_ttl_in_seconds = 0 per RAG009 — the decrement-on-call model is incompatible with any cached allow. This is a hard-coded value, not a variable, so no per-env root can override it.
modules/secrets_iam
Scope: Secrets Manager secrets for application config (not the DB credential, which the Aurora module owns), the GitHub Actions OIDC deploy role, and IAM boundary policy.
| Key inputs | Key outputs |
|---|---|
cluster_arn | oidc_deploy_role_arn |
db_secret_arn | app_config_secret_arns |
github_org, github_repo | |
env | |
permitted_actions (map of service → action list) |
The OIDC deploy role’s permission policy is generated from var.permitted_actions, which each root populates by aggregating the action sets each module requires. When a module adds a new IAM action, the root’s permitted_actions map must be updated in the same PR. This keeps the deploy role’s permissions in sync with module changes without requiring a separate permissions audit pass.
Environment roots
Both 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 — no data source lookups.
What differs between QA and Prod is exclusively input values in terraform.tfvars:
| Variable | 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 |
What must not differ: module versions, provider versions, resource naming conventions, tag schema keys, IAM action sets. These are defined once in the modules, not per-root. A root that overrides module behavior must do so via an explicit input variable — not by duplicating resource blocks.
Adding a new module follows the same pattern: create modules/<name>/, define inputs and outputs, call it from both roots with the same module-output wiring, and update permitted_actions in each root’s tfvars for any new IAM actions the module needs.
State backend
The infra/bootstrap/ root is a one-time, manually applied root that provisions:
- An S3 bucket with versioning enabled, server-side encryption (SSE-S3), public-access block, and a lifecycle rule to expire non-current versions after 90 days. State locking is S3-native (
use_lockfile = true), so no separate lock table is provisioned.
This root uses local state (.terraform/terraform.tfstate committed to .gitignore, stored locally by the operator). It cannot use remote state because it is creating the remote state backend.
Per-env backend configuration in backend.tf:
terraform {
backend "s3" {
bucket = "rag-sample-terraform-state"
key = "rag-sample/<env>/terraform.tfstate" # e.g. rag-sample/qa/terraform.tfstate
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
The key pattern rag-sample/<env>/terraform.tfstate ensures QA and Prod state files land in separate S3 objects with separate S3-native lock files (rag-sample/<env>/terraform.tfstate.tflock). A terraform plan run against infra/roots/qa/ can never acquire or modify the rag-sample/prod/terraform.tfstate lock.
State file access follows the OIDC deploy role: GitHub Actions assumes the role during plan/apply; the role has s3:GetObject, s3:PutObject, s3:DeleteObject on rag-sample-terraform-state/rag-sample/<env>/* only, not on the other environment’s prefix. This is enforced by a resource-based condition in the role policy, not by a bucket policy alone.
Structural guarantees
The layout treats RAG010, RAG011, and RAG009 as structural constraints rather than conventions. Each is enforced at the module boundary where it belongs, so a violation requires an explicit, reviewable change to the module itself — not just a policy override.
RAG010 (no IGW or NAT) holds because the network module contains none of those resource types and has no input variable that conditionalize their creation. RAG011 (Data API as sole application data path) is enforced at two levels: the aurora module exposes no connection endpoint and the lambda module’s execution role grants no VPC-attachment permissions — a psycopg Lambda would fail at both deployment and runtime. RAG009 (no authorizer caching) is enforced by the hard-coded TTL of zero in the api_gateway module, which no per-env root can override.
Single-environment blast radius. QA and Prod use separate S3 state key prefixes, separate S3-native lock files, and separate OIDC role policies scoped to their own prefix. A terraform apply in infra/roots/qa/ cannot write Prod state and cannot acquire Prod’s lock. An error that destroys QA resources does not touch Prod’s state file or its AWS resources. The common backend S3 bucket is a shared dependency — bucket deletion would affect both — but the deploy role does not hold s3:DeleteBucket.
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