Terraform does not care what your files are called. It cares enormously about which directory they are in, and that distinction drives every layout decision you will make.
Topic 1: Files Do Not Matter, Folders Do
Terraform reads every .tf file in the working directory and concatenates them into one logical configuration. The filenames are then discarded.
/project
├── main.tf # these three
├── network.tf # are identical in effect
└── database.tf # to one big main.tf
Splitting main.tf into provider.tf and s3.tf changes nothing about the result. Ordering within a file means nothing either — Terraform builds a graph from references, not from position.
Subfolders are ignored by the root module. That is precisely what makes them available as modules. Put a .tf file in a subdirectory and Terraform will not read it unless something declares it as a module source.
Conventions worth following anyway, because humans read this:
/project
├── main.tf # providers and module composition
├── variables.tf # input declarations
├── outputs.tf # output declarations
├── versions.tf # required_version and required_providers
├── state.tf # backend configuration (convention only)
└── terraform.tfvars # values
Beyond a certain size, split by system area — network.tf, dns.tf, compute.tf. The industry practice is roughly one file per resource grouping: it makes the code navigable and keeps diffs readable.
Topic 2: The Four Environment Patterns
| Pattern | Isolates | Best for | Breaks down when |
|---|---|---|---|
| Workspaces | State only | Same topology, different parameters | Environments diverge architecturally |
| Directory per environment | State + config | Environments that genuinely differ | Duplication across directories |
| Shared modules + thin env roots | State + inputs, shared logic | Most production estates | Requires module discipline |
| Backend overrides | State | Layering onto any of the above | Config-only, no logic separation |
Directory per environment:
/environments
├── dev/
│ ├── main.tf
│ └── terraform.tfvars
├── staging/
└── prod/
Full separation. Easy to reason about, easy to wire into CI. The cost is duplication — a change to shared topology must be made three times, and eventually one copy is forgotten.
Module-driven — the usual production shape:
/modules
├── network/
├── compute/
└── database/
/envs
├── dev/ # each invokes shared modules with its own vars
├── staging/
└── prod/
Each environment root is thin: a backend block, provider config, and a few module calls with different inputs. Shared logic lives once in modules/. This is where most estates end up, and it is worth starting here rather than migrating later.
# envs/prod/main.tf
terraform {
backend "s3" {
bucket = "org-terraform-state"
key = "prod/network/terraform.tfstate" # distinct key per environment
region = "us-east-1"
}
}
module "network" {
source = "../../modules/network"
cidr = "10.0.0.0/16"
az_count = 3
}
Per-environment variable files:
terraform apply -var-file="prod.tfvars"
# prod.tfvars
environment = "prod"
instance_type = "t3.large"
desired_count = 3
Topic 3: Splitting State
One enormous state file gives you slow plans and unlimited blast radius — a mistake in one team’s code can destroy another team’s resources, because they are in the same apply.
Split along team and lifecycle boundaries: the boundaries that decide who can break what, and what changes at the same cadence.
A common split:
platform/network/ # rarely changes, everyone depends on it
platform/shared/ # DNS, certificates, shared buckets
team-a/app/ # changes daily
team-b/app/
Downstream state reads upstream outputs via a data source rather than managing those resources:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "org-terraform-state"
key = "platform/network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}
This decision is organisational as much as technical, and it is far cheaper to make early than to retrofit.
Topic 4: Protecting Production
The single most valuable guard rail in this whole topic: ensure production state cannot be overwritten by a dev run.
Layers that actually work:
- Separate backend keys per environment — a dev run physically cannot write prod’s state file.
- Separate credentials per environment. The dev pipeline’s role has no permissions in the prod account. This is the only guard that survives human error.
- Separate pipelines, so an environment is selected by which job runs rather than by a flag someone types.
prevent_destroyon stateful production resources as a last-resort backstop.
Relying on operators remembering to select the right workspace or pass the right -var-file is not a control. It is a convention, and conventions fail at 3am.
Topic 5: Naming and Tagging
Embed the environment in resource names and tags so that a console view is unambiguous:
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Owner = var.owning_team
StateKey = "prod/network"
}
}
The ManagedBy tag matters more than it looks — it tells the next person that hand-editing this resource will be reverted. The StateKey tag tells them which configuration to edit instead, which is the question they will actually have.
Try it yourself: Set up two environment directories sharing one module. Change the module and apply to dev only. Confirm prod is unchanged, then apply prod and watch the same change land. That is the whole value proposition of the module-driven layout in one exercise.
Common mistake: Branch-per-environment. Keeping dev, staging and prod as long-lived Git branches produces permanent merge conflicts and drift nobody can reason about, because the differences between environments are spread across a diff that never fully merges. Directory-per-environment makes those differences visible in one place.