Skip to content

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

state backend

composed into

Per-environment roots

qa/

prod/

Child modules · dependency order

network

aurora

lambda

api_gateway

secrets_iam

infra/bootstrap

S3 state bucket (S3-native locking)

GitHub OIDC provider + per-env deploy roles (cicd module)

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:

InputQAProd
vpc_cidr10.10.0.0/1610.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_acu00.5
aurora_max_capacity_acu48
env tagqaprod

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 cicd module

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 network module 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 aurora module exposes no connection endpoint and the lambda module’s execution role grants no VPC-attachment permissions.
  • RAG009 (no authorizer caching): TTL is hard-coded to 0 in the api_gateway module.

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.

  • 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