← Back to Blog
Infrastructure

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 pins

The 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.60 allows 5.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:

InputStagingProduction
desired_count16
instance_classt3.smallm6i.large
multi_azfalsetrue

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 reviewed

A few habits that pay off:

  • Run terraform fmt -recursive in CI and fail the build if it changes anything.
  • Run terraform validate on 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.