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.
| Layout | Environment isolation | State files | Best for |
|---|---|---|---|
| Single config + workspaces | Logical (one config, many states) | One backend, key per workspace | Small teams, near-identical envs |
| Per-environment directories | Physical (separate dirs) | One state per environment | Most teams; clear, explicit |
| Component / layered | Physical, split by lifecycle | One state per component per env | Large 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.
Recommended layout
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.tfvarsper directory rather thanterraform.workspaceconditionals 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=...overcdin 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.