Terraform looks simple when you start. You write some HCL, run terraform apply, and infrastructure appears. Six months later, you have a monolithic state file, no modules, and everyone's afraid to touch anything. Here's how to avoid that.
Structure Your Code for Scale from Day One
The worst Terraform I've seen is a single directory with one giant main.tf. Everything in one state, no separation, no reusability.
The structure I recommend for most teams:
infrastructure/
├── modules/
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── s3-bucket/
├── environments/
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── production/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars
└── global/
└── iam/
Modules are reusable building blocks. Environments consume them. Global resources (IAM roles, S3 buckets for state) live separately.
Remote State with Locking
Never use local state in a team environment. Use S3 with DynamoDB locking on AWS:
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "production/eks/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
The encrypt = true is important. State files often contain sensitive data — database passwords, private keys, API tokens.
Pin Your Provider Versions
Provider updates can break your code. Always pin:
terraform {
required_version = ">= 1.6.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Update providers deliberately, not accidentally.
Write Outputs and Use Them
Output values are how modules communicate. Don't hard-code IDs across modules — use outputs:
# In vpc module
output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "IDs of private subnets"
}
# In eks module
module "cluster" {
source = "../eks"
subnet_ids = module.vpc.private_subnet_ids
}
This creates explicit dependencies and makes your code readable.
Separate State Per Environment, Per Component
One state file for everything is a disaster waiting to happen. A failed plan for one component shouldn't block changes to another.
I split by:
- Environment (staging, production)
- Component (networking, compute, database, monitoring)
This also limits blast radius. If something goes wrong with a Terraform operation, only one component is affected.
Use terraform plan Output in CI/CD
Every pull request should show the Terraform plan as a comment. Engineers shouldn't be applying infrastructure changes without a reviewed plan.
My typical GitHub Actions workflow:
terraform fmt -check— formatting checkterraform validate— syntax validationterraform plan— output the plan as PR comment- Manual approval for production
terraform applyon merge to main
Never auto-apply to production without human review.
Drift Detection
Infrastructure drift — when the real state diverges from your Terraform state — is a constant problem. Schedule a regular terraform plan run in CI/CD, even with no code changes. Alert if there's a diff.
# In CI/CD schedule job
terraform plan -detailed-exitcode
# Exit code 2 means changes detected (drift)
Avoid count for Resources That Change
Using count to create multiple resources creates an indexed list. Remove the first item and everything shifts. Use for_each with a map instead:
# Bad — order-dependent
resource "aws_subnet" "private" {
count = length(var.private_cidr_blocks)
cidr_block = var.private_cidr_blocks[count.index]
}
# Good — key-based
resource "aws_subnet" "private" {
for_each = toset(var.private_cidr_blocks)
cidr_block = each.value
}
Tag Everything
Every resource should have tags for:
Environment(staging, production)TeamorOwnerManagedBy= "terraform"Project
Use a local variable for consistent tagging:
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = var.project_name
}
}
These small habits save hours when you're trying to identify what a resource does or who owns it.
