DevOpsCraftDevOpsCraft
All posts
TerraformIaCBest Practices

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