Terraform Module Best Practices for Teams
2026-01-20 · 7 min read
The problem with most Terraform codebases
Teams start with a single `main.tf` file. It works. Then they add more resources. Then environments. Then they copy-paste the whole thing for staging. Three months later, nobody wants to touch it.
Here's how to structure Terraform for teams from the start.
Module structure
terraform/
├── modules/ # Reusable modules
│ ├── eks/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── rds/
│ └── vpc/
└── environments/ # Environment-specific configs
├── staging/
│ ├── main.tf # Uses modules
│ ├── terraform.tfvars
│ └── backend.tf
└── production/
├── main.tf
├── terraform.tfvars
└── backend.tf
Module design principles
1. One resource type per module
Don't build a "platform" module that creates VPC + EKS + RDS. Build separate modules that compose well.
2. Always version your modules
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0" # Never use latest without pinning
}
3. Required vs optional variables
# variables.tf
variable "cluster_name" {
type = string
description = "Name of the EKS cluster"
# No default = required
}
variable "node_instance_type" {
type = string
description = "EC2 instance type for worker nodes"
default = "m5.large" # Has default = optional
}
4. Meaningful outputs
# outputs.tf
output "cluster_endpoint" {
description = "EKS cluster API endpoint"
value = aws_eks_cluster.main.endpoint
}
output "cluster_certificate_authority_data" {
description = "Base64 encoded CA certificate"
value = aws_eks_cluster.main.certificate_authority[0].data
sensitive = true
}
Remote state with locking
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/eks/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
Workflow for teams
# Always plan before apply
terraform plan -var-file=terraform.tfvars -out=tfplan
# Review the plan, then apply
terraform apply tfplan
# In CI/CD: use -detailed-exitcode to detect no-change
terraform plan -detailed-exitcode
# Exit 0 = no changes, Exit 2 = changes, Exit 1 = error
Use Terragrunt for DRY configs
Once you have multiple environments, Terragrunt eliminates the repetition:
# terragrunt.hcl (root)
generate "backend" {
path = "backend.tf"
if_exists = "overwrite"
contents = <<EOF
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-state-lock"
}
}
EOF
}
One backend config, all environments inherit it automatically.
Need help implementing this?
We set this up for teams every week. Book a free call and let's talk about your specific situation.
Book a Discovery Call