Skip to content

Terraform Project Structure

How we organize Terraform repositories around account boundaries, root modules, and deployment lifecycles

Related Concepts: Terraform State Boundaries | Coupling and Cohesion | Continuous Delivery

Folder Structure

Our layout is loosely based on the Trussworks Terraform layout and codified in our platform infrastructure template. The top-level structure mirrors AWS account boundaries, where each directory represents one AWS account in an AWS Organization.

.
├── .github/workflows/          # CI pipelines
├── modules/                    # Project-specific reusable modules
│   ├── waf/
│   ├── bastion/
│   └── ...
├── org-infra/                  # Organization-wide shared infra (DNS, ECR, pipelines)
│   ├── admin-global/
│   ├── build-pipeline/
│   └── bootstrap/
├── dev/                        # Dev account
│   ├── admin-global/
│   ├── bootstrap/
│   └── ...
├── prod/                       # Prod account
│   ├── admin-global/           # Shared account resources (VPC, CloudTrail, ACM)
│   ├── cluster-infra/          # Shared application infra (ALB, ECS cluster, WAF)
│   │   └── tests/unit/         # Terraform native tests
│   ├── service-backend-prod/   # Per-service root modules
│   ├── email/
│   ├── file-storage/
│   └── bootstrap/
├── test/                       # Test helpers (backend overrides)
├── init.sh                     # Project initialization script
└── README.md

Accounts as Top-Level Directories

Each account directory (dev/, prod/, org-infra/) is fully self-contained. Everything needed to manage that account's infrastructure lives inside it. The init.sh script in the template renames orgname-* directories to match the project.

Root Modules Within Accounts

Each subdirectory within an account is a root module with its own state file:

  • bootstrap/ creates the S3 bucket and DynamoDB table for remote state. Applied once during account setup, then never touched again. State is committed to the repo.
  • admin-global/ holds account-wide shared resources: VPC, CloudTrail, ACM certificates, shared IAM roles. This is the stable foundation that other root modules depend on.
  • cluster-infra/ holds shared application infrastructure: ALB, ECS cluster, WAF, database. These are resources that multiple services share and that change more frequently than admin-global.
  • service-<name>/ holds per-service root modules for application-specific resources (ECS service, CodeDeploy, task definitions).
  • Domain-specific root modules (email/, file-storage/) hold infrastructure for specific concerns that have their own lifecycle.

The modules/ Directory

Project-specific modules that are reused across accounts or root modules live in modules/. If a module is generic enough to be used outside this project, it should be extracted into its own versioned repository (e.g. terraform-aws-alb, terraform-aws-ecs-service).

Root Module Dependency Order

Root modules within an account form a dependency tree connected by terraform_remote_state data sources. The typical deployment order is:

bootstrap → admin-global → cluster-infra → service-backend, service-worker, ... (parallel)

bootstrap is a one-time setup. admin-global is the stable foundation. cluster-infra is shared application infra. Services depend on both admin-global and cluster-infra but are independent of each other and can be deployed in parallel.

File Conventions Within a Root Module

prod/cluster-infra/
├── main.tf              # Provider config, remote state data sources, locals
├── load_balancer.tf     # One file per logical concern
├── waf.tf
├── ecs_cluster.tf
├── database.tf
├── outputs.tf
└── tests/
    └── unit/            # Terraform native test files

Split by logical concern. waf.tf contains the WAF module call, and load_balancer.tf contains the ALB together with its security groups and access logs. One concern stays in one file even when it spans several resource types.

Connecting Root Modules

Root modules read each other's outputs through terraform_remote_state data sources. Declare the data source in main.tf and lift the values into locals so the rest of the configuration refers to a local name.

hcl
data "terraform_remote_state" "admin_global" {
  backend = "s3"
  config = {
    bucket = "myapp-prod-tf-state"
    key    = "admin-global.tfstate"
    region = "us-west-2"
  }
}

locals {
  vpc_id          = data.terraform_remote_state.admin_global.outputs.vpc_id
  private_subnets = data.terraform_remote_state.admin_global.outputs.private_subnets
}

Each data source of this kind is a coupling point with a real cost, so add one deliberately. Terraform State Boundaries covers what the coupling costs, the signs that a root module has lost cohesion, the lifecycle differences that justify a new root module, and how plan and refresh time balance against the coupling a smaller root module introduces.

Reducing the Coupling Surface

When a division is justified, keep the surface between root modules small:

  • Narrow the outputs. Expose only what consumers actually need. Every output is a public contract.
  • Use data-only modules. A data-only module looks up shared infrastructure by tags or naming conventions, which decouples consumers from the producer's state backend. See Data-Only Modules for details.
  • Stabilize the interface. Treat outputs of foundational root modules like a public API. Avoid renaming or removing them without checking consumers.
hcl
# Tightly coupled: consumer knows the producer's state backend config
data "terraform_remote_state" "network" {
  backend = "s3"
  config  = { bucket = "...", key = "admin-global.tfstate", region = "..." }
}

# Loosely coupled: consumer discovers infrastructure by convention
module "network" {
  source      = "../../modules/network_lookup"
  environment = "prod"
}

Verify Assumptions Against the Application

Before writing infrastructure that references application routes, endpoints, or behavior, verify those assumptions against the actual application code. Check:

  • Backend route prefixes and controller paths
  • Whether a global prefix (e.g. setGlobalPrefix) is set
  • How the frontend constructs API URLs
  • What the ALB actually serves (API-only vs mixed traffic)

Read the code to confirm route patterns, since variable names and comments are unreliable sources for them. Infrastructure that doesn't match the application it supports fails in ways that are hard to diagnose.

Pull Requests

Structure PRs around the domain change. A PR that adds rate limiting reads as "add rate limiting" and describes itself in those terms, even when it touches several files across directories.

  • Branch from the commit that introduces the module if iterating on it
  • Reference related issues with "Related to" when the PR partially satisfies a requirement, and reserve "Closes" for a PR that satisfies it fully
  • Rebase onto latest main before opening the PR