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 workspacefor 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
| Pattern | Best for | State files | Blast radius | Main drawback |
|---|---|---|---|---|
| Flat | Demos, POCs, single env | 1 | Whole project | Doesn’t scale |
| Per-environment | Most production apps | 1 per env | One environment | Config duplication |
| Component/layered | Large, multi-team estates | 1 per layer per env | One layer | Operational overhead |
| Monorepo | Platform teams, many stacks | Many | Per stack/layer | Coarse 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.