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

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.

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)

Modules

modules/network

Scope: VPC, two private subnets across two AZs, Aurora subnet group, Aurora security group. Nothing else.

Key inputsKey outputs
vpc_cidrvpc_id
subnet_cidrs (list of 2)subnet_ids (list of 2)
env (tag)aurora_sg_id
aws_regionaurora_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 inputsKey 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 inputsKey outputs
cluster_arn (from aurora)api_function_arn
db_secret_arn (from aurora)api_function_invoke_arn
api_image_uriauthorizer_function_arn
authorizer_image_uriauthorizer_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 inputsKey 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 inputsKey outputs
cluster_arnoidc_deploy_role_arn
db_secret_arnapp_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: networkauroralambdaapi_gatewaysecrets_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:

VariableQAProd
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

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.

  • 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