Skip to content
Infrastructure as Code iac environments 5 min read

Project Directory Structure

How you arrange .tf files on disk shapes everything that follows: blast radius, review velocity, state isolation, and how painful it is to onboard the next engineer. Terraform itself imposes almost no structure — it loads every .tf file in a single directory and treats them as one configuration — so the layout is a discipline you bring, not one the tool enforces. This page compares the three layouts teams actually use in production and recommends a default that scales from a side project to a multi-team platform.

The three common layouts

Most Terraform repositories settle into one of three shapes. They differ in how environments are separated and whether the configuration is split into independently-applied components.

LayoutEnvironment isolationState filesBest for
Single config + workspacesLogical (one config, many states)One backend, key per workspaceSmall teams, near-identical envs
Per-environment directoriesPhysical (separate dirs)One state per environmentMost teams; clear, explicit
Component / layeredPhysical, split by lifecycleOne state per component per envLarge estates, independent teams

Single configuration with workspaces

One root module, switched between environments with terraform workspace. The same .tf code produces dev, staging, and prod by branching on terraform.workspace.

locals {
  env      = terraform.workspace
  settings = {
    dev     = { instance_type = "t3.micro",  min_size = 1 }
    staging = { instance_type = "t3.small",  min_size = 2 }
    prod    = { instance_type = "m5.large",  min_size = 3 }
  }
  cfg = local.settings[local.env]
}

resource "aws_autoscaling_group" "api" {
  name             = "api-${local.env}"
  min_size         = local.cfg.min_size
  max_size         = local.cfg.min_size * 3
  desired_capacity = local.cfg.min_size
  vpc_zone_identifier = data.aws_subnets.private.ids

  launch_template {
    id      = aws_launch_template.api.id
    version = "$Latest"
  }
}
terraform workspace select prod || terraform workspace new prod
terraform apply

This keeps code perfectly DRY, but it has a real hazard: a single mistyped select and you apply dev changes to prod. The environments also share one backend configuration, so you cannot give prod a different state bucket, account, or access policy. Workspaces are covered in depth in Workspaces.

Warning: Workspaces give logical, not physical, isolation. Because every workspace shares one backend and one set of provider credentials, they are a poor fit for separating production from non-production. Use them for ephemeral or near-identical environments, not as a security boundary.

Per-environment directories

Each environment gets its own directory with its own backend, its own variable values, and its own state. Shared logic lives in modules/.

.
├── modules/
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── api/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── environments/
    ├── dev/
    │   ├── main.tf
    │   ├── backend.tf
    │   └── terraform.tfvars
    ├── staging/
    │   ├── main.tf
    │   ├── backend.tf
    │   └── terraform.tfvars
    └── prod/
        ├── main.tf
        ├── backend.tf
        └── terraform.tfvars

Each environment’s main.tf is thin — it wires modules together and passes environment-specific inputs.

# environments/prod/main.tf
module "network" {
  source   = "../../modules/network"
  vpc_cidr = "10.20.0.0/16"
  env      = "prod"
}

module "api" {
  source        = "../../modules/api"
  subnet_ids    = module.network.private_subnet_ids
  instance_type = "m5.large"
  min_size      = 3
  env           = "prod"
}
# environments/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "acme-tfstate-prod"
    key            = "infra/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks-prod"
    encrypt        = true
  }
}

You cd into the environment and run Terraform there:

terraform -chdir=environments/prod init
terraform -chdir=environments/prod apply

Output:

module.network.aws_vpc.this: Refreshing state... [id=vpc-0a1b2c3d]
module.api.aws_autoscaling_group.api: Refreshing state... [id=api-prod]

Plan: 2 to add, 0 to change, 0 to destroy.

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

The cost is duplication: the module {} wiring in each environment looks nearly identical. Techniques for trimming that boilerplate are covered in DRY environments.

Component / layered structure

For large estates, splitting each environment into independently-applied components (sometimes called layers or stacks) keeps blast radius small and lets teams own their slice. Slow-changing foundations sit below fast-changing application layers.

environments/
└── prod/
    ├── 10-network/      # VPC, subnets, transit gateway
    ├── 20-data/         # RDS, ElastiCache, S3
    ├── 30-platform/     # EKS cluster, IAM roles
    └── 40-services/     # the apps themselves

Each numbered directory is a separate root module with its own state. Upstream outputs are consumed downstream via a remote state data source — no shared mutable state, no giant single apply.

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

module "api" {
  source     = "../../../modules/api"
  subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
  env        = "prod"
}

This is the most scalable layout, but it adds orchestration overhead: applies must run in dependency order, and cross-component references are looser than in-module ones. Tooling like Terragrunt exists largely to manage this complexity.

For most teams, start with per-environment directories backed by shared modules, and split into components only when a single environment’s apply grows slow or risky. This gives you physical state isolation and per-environment backends from day one — the things that are painful to retrofit — while keeping the day-to-day model simple. Promote a foundation (network, data) into its own component the moment two teams start contending over the same plan.

All of these layouts work identically under OpenTofu: the directory conventions, backends, and terraform_remote_state data source are part of the configuration language OpenTofu implements, so you can swap the binary without touching your structure.

Best practices

  • Give every environment its own state backend (bucket/key and lock table) so a non-prod mistake can never write prod state.
  • Keep reusable logic in modules/ and keep environment roots thin — just module wiring and inputs.
  • Name component directories with numeric prefixes (10-, 20-) to encode apply order at a glance.
  • Pass environment-specific values through terraform.tfvars per directory rather than terraform.workspace conditionals when you need real isolation.
  • Pin provider and module versions in each root so environments drift on your schedule, not the registry’s.
  • Prefer terraform -chdir=... over cd in CI so the working directory is explicit and auditable.
  • Use remote state data sources for cross-component reads instead of hardcoding IDs or sharing one monolithic state.
Last updated June 14, 2026
Was this helpful?