A Terraform Module Layout That Scales
Every Terraform codebase starts as one main.tf and stays that way for exactly as long
as one person is touching it. The moment a second engineer — or a second environment —
shows up, the cracks appear. This is the layout I reach for to keep things sane, and the
reasoning behind each decision.
#The shape
infra/
├── modules/ # reusable building blocks, no environment specifics
│ ├── network/
│ ├── ecs-service/
│ └── rds/
├── environments/
│ ├── staging/
│ │ ├── main.tf # wires modules together for staging
│ │ ├── backend.tf # remote state config
│ │ └── terraform.tfvars
│ └── production/
│ ├── main.tf
│ ├── backend.tf
│ └── terraform.tfvars
└── versions.tf # provider + Terraform version pinsThe core idea: modules describe what a thing is; environments describe how many and how big. A module never hard-codes a CIDR block or an instance size — those are inputs.
#Pin everything
Unpinned providers are how a Friday-afternoon terraform init turns into a Monday-morning
incident. Pin the Terraform core and every provider explicitly.
# versions.tf
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}Why
~>and not=: you want patch and minor updates (~> 5.60allows5.61) but never a surprise major version that changes resource semantics under you.
#Keep state separate per environment
Each environment gets its own remote state. Never let staging and production share a
state file — a single fat-fingered terraform apply shouldn't be able to reach across
the boundary.
# environments/production/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "production/terraform.tfstate"
region = "eu-south-1"
dynamodb_table = "tfstate-locks"
encrypt = true
}
}The dynamodb_table is the part people skip and regret — it's what gives you state
locking so two concurrent applies can't corrupt each other.
#Modules take inputs, return outputs, decide nothing
A good module is boring. It accepts variables, creates resources, and exposes outputs. It does not know which environment it's in.
# modules/ecs-service/variables.tf
variable "service_name" {
type = string
description = "Name of the ECS service."
}
variable "desired_count" {
type = number
default = 2
description = "Number of tasks to run."
}Then each environment supplies the sizing:
| Input | Staging | Production |
|---|---|---|
desired_count | 1 | 6 |
instance_class | t3.small | m6i.large |
multi_az | false | true |
Same module, two very different deployments — and the difference lives entirely in
terraform.tfvars, where it's reviewable.
#The workflow
Whatever the layout, the discipline is the same: plan, read the plan, then apply.
cd environments/staging
terraform init
terraform plan -out=tfplan # always review before applying
terraform apply tfplan # apply the exact plan you reviewedA few habits that pay off:
- Run
terraform fmt -recursivein CI and fail the build if it changes anything. - Run
terraform validateon every module in isolation. - Never apply from a laptop against production — that's what the pipeline is for.
terraform fmt -recursive -check
terraform validate#Wrapping up
The layout isn't sacred — what matters is the separation it enforces: reusable modules with no environment knowledge, per-environment state and sizing, and pinned versions so the ground doesn't shift under you. Get those three right and Terraform scales with your team instead of fighting it.