Skip to content
Infrastructure as Code iac patterns 5 min read

Project Structure Patterns

How you arrange Terraform files on disk shapes everything that follows: blast radius, plan times, who can touch what, and how cleanly you can promote a change from dev to prod. There is no single correct layout — the right one depends on team size, environment count, and how much shared infrastructure you operate. This page catalogs the four patterns you will actually meet in the wild — flat, per-environment, component/layered, and monorepo — with the trade-offs of each and clear guidance on when to reach for which. Everything here applies equally to Terraform 1.5+ and OpenTofu, which share the same configuration language and file resolution rules.

How Terraform discovers files

Terraform loads every .tf file in the working directory (the directory you run terraform plan from) and merges them into a single configuration. It does not recurse into subdirectories — those are only pulled in when referenced as modules. This single rule drives all four patterns: a “structure” is really just a decision about where your root modules (the directories you apply from) live and what they share.

Flat layout

The simplest possible structure: one root module, all resources together. Files are split by concern only for human readability.

.
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
└── terraform.tfvars
# providers.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}
# main.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags                 = { Name = "main" }
}

resource "aws_s3_bucket" "assets" {
  bucket = "devcraftly-assets-${var.region}"
}

A flat layout is ideal for a demo, a proof of concept, or a tiny project with a single environment. It breaks down quickly: every change re-plans the entire infrastructure, the state file grows monolithic, and there is no way to give one team narrower access. Use it to start; expect to outgrow it.

Per-environment layout

The most common production pattern. Each environment gets its own root module and its own state file, so a prod apply can never accidentally touch dev. Shared logic lives in local modules consumed by every environment.

.
├── modules/
│   ├── network/
│   └── app/
└── environments/
    ├── dev/
    │   ├── main.tf
    │   └── backend.tf
    ├── staging/
    └── prod/
# environments/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "devcraftly-tfstate"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
}
# environments/prod/main.tf
module "network" {
  source   = "../../modules/network"
  cidr     = "10.10.0.0/16"
  az_count = 3
}

module "app" {
  source        = "../../modules/app"
  vpc_id        = module.network.vpc_id
  instance_type = "m5.large"
  desired_count = 6
}

You promote a change by merging it into modules/ and applying each environment in turn — dev first, prod last. The cost is duplication: main.tf is largely copy-pasted across environments, which is exactly the friction that DRY tooling like Terragrunt or tfvars-driven workspaces aims to remove.

Prefer separate state files (one per environment) over terraform workspace for environment separation. Workspaces share one backend key and one set of provider credentials, which makes least-privilege access and blast-radius isolation much harder to enforce.

Component / layered layout

As infrastructure grows, a single per-environment root becomes slow to plan and dangerous to apply. The component pattern splits each environment into independently-applied layers, typically ordered by lifecycle: networking changes rarely, data stores occasionally, and application services constantly.

environments/prod/
├── 10-network/      # VPC, subnets, route tables
├── 20-data/         # RDS, ElastiCache, S3
└── 30-app/          # ECS/EKS services, ALBs

Each layer has its own state file. Downstream layers read upstream outputs via a terraform_remote_state data source rather than direct module references:

# environments/prod/30-app/main.tf
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "devcraftly-tfstate"
    key    = "prod/10-network/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_ecs_service" "api" {
  name            = "api"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = 4

  network_configuration {
    subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
  }
}

Output:

data.terraform_remote_state.network: Reading...
data.terraform_remote_state.network: Read complete after 0s
aws_ecs_service.api: Creating...
aws_ecs_service.api: Creation complete after 21s [id=arn:aws:ecs:us-east-1:...:service/main/api]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The numeric prefixes (10-, 20-, 30-) document apply order at a glance. The payoff is small, fast, low-risk plans and the ability to scope CI permissions per layer. The cost is operational overhead — more state files to manage and the discipline to apply layers in dependency order.

Monorepo layout

A monorepo keeps all infrastructure — multiple stacks, shared modules, and CI configuration — in one repository, usually combining the per-environment and component patterns under a single root.

.
├── modules/                 # versioned, reusable building blocks
├── stacks/
│   ├── platform/
│   │   └── environments/{dev,prod}/
│   └── payments/
│       └── environments/{dev,prod}/
└── .github/workflows/

CI typically diff-scopes which stacks to plan based on changed paths, so a payments change does not trigger a platform plan. This is the natural endpoint for a platform team that wants atomic cross-stack changes and a single review surface. The trade-off is the classic monorepo-vs-multirepo debate: shared history and easy refactoring versus coarser access control and the need for path-aware CI.

Choosing a pattern

PatternBest forState filesBlast radiusMain drawback
FlatDemos, POCs, single env1Whole projectDoesn’t scale
Per-environmentMost production apps1 per envOne environmentConfig duplication
Component/layeredLarge, multi-team estates1 per layer per envOne layerOperational overhead
MonorepoPlatform teams, many stacksManyPer stack/layerCoarse access control, CI complexity

Best Practices

  • Start with the simplest layout that fits, and refactor toward layers only when plans get slow or risky — premature splitting adds overhead with no payoff.
  • Give every environment and every layer its own remote state file with locking; never share a backend key across environments.
  • Keep reusable logic in modules/ and pin module and provider versions so promotion between environments is reproducible.
  • Pass data between layers through terraform_remote_state (or a parameter store) rather than hard-coding IDs.
  • Use ordered prefixes (10-, 20-) or explicit READMEs to make apply order unambiguous for the next engineer.
  • Mirror the same directory shape across all environments so a change reviewed in dev applies identically in prod.
  • The same layouts work unchanged under OpenTofu — keep your structure tool-agnostic by avoiding wrapper-specific directory conventions until you actually adopt the wrapper.
Last updated June 14, 2026
Was this helpful?