2026-03-20 · 5 min read
Terraform Best Practices: From Chaos to Predictable Infrastructure
How to structure Terraform code for teams of 5-50 engineers, with real patterns that scale and prevent disasters.

Terraform Best Practices: From Chaos to Predictable Infrastructure
I've seen Terraform implementations go from beautiful, maintainable codebases to unmanageable spaghetti. The difference? Following proven patterns and best practices from day one.
After leading Terraform adoption across multiple organizations, I've developed a comprehensive approach that scales from 5 to 50+ engineers. This post covers the patterns that actually work in production.
The Foundation: Repository Structure
Monorepo vs Multi-Repo Debate
Monorepo (Recommended for most teams):
terraform/
├── environments/
│ ├── dev/
│ ├── staging/
│ └── prod/
├── modules/
│ ├── vpc/
│ ├── ecs/
│ ├── rds/
│ └── security/
├── shared/
│ ├── data.tf
│ └── providers.tf
└── scripts/
├── plan.sh
└── apply.sh
When to use Multi-Repo:
- Highly regulated industries
- Teams with different deployment cadences
- Strong organizational boundaries
Environment Separation
Each environment gets its own directory with:
main.tf- Environment-specific resourcesterraform.tfvars- Environment variablesbackend.tf- State backend configuration
Module Design Principles
1. Single Responsibility Principle
Each module should do one thing well. A VPC module shouldn't create EC2 instances.
Good Module Structure:
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md
└── examples/
└── basic/
2. Composition over Inheritance
Use module composition instead of complex inheritance patterns.
# Bad: Trying to do everything in one module
module "monolith" {
source = "./modules/everything"
create_vpc = true
create_rds = true
create_redis = true
# ... 20 more parameters
}
# Good: Compose simple modules
module "network" {
source = "./modules/vpc"
# Simple, focused parameters
}
module "database" {
source = "./modules/rds"
vpc_id = module.network.vpc_id
# Clear dependencies
}
State Management
Remote State with Locking
Always use remote state with locking. S3 + DynamoDB is the gold standard.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "environments/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
State File Organization
- One state file per environment
- Never share state between environments
- Use
terraform workspacefor variations within environments
Variable Management
Variable Definition Patterns
# variables.tf
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_count" {
description = "Number of EC2 instances"
type = number
default = 1
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
Environment-Specific Values
Use .tfvars files for environment-specific values:
# environments/prod/terraform.tfvars
environment = "prod"
instance_type = "m5.large"
instance_count = 3
# environments/dev/terraform.tfvars
environment = "dev"
instance_type = "t3.micro"
instance_count = 1
Resource Organization
Resource Naming Conventions
Consistent naming prevents conflicts and improves readability:
# Good naming
resource "aws_instance" "web" {
# environment-service-purpose-index
tags = {
Name = "${var.environment}-web-app-001"
}
}
# Consistent tagging
resource "aws_instance" "web" {
tags = merge(local.common_tags, {
Name = "${var.environment}-web-app"
Component = "application"
Owner = "platform-team"
CostCenter = "engineering"
})
}
Resource Dependencies
Be explicit about dependencies:
resource "aws_security_group" "web" {
# Security group rules depend on VPC
vpc_id = aws_vpc.main.id
}
resource "aws_instance" "web" {
# Instance depends on security group
vpc_security_group_ids = [aws_security_group.web.id]
depends_on = [aws_security_group.web]
}
Data Sources Best Practices
When to Use Data Sources
- Reference existing resources
- Get information from other Terraform configurations
- Access AWS account information
# Get current AWS account info
data "aws_caller_identity" "current" {}
# Reference existing VPC
data "aws_vpc" "existing" {
filter {
name = "tag:Name"
values = ["${var.environment}-vpc"]
}
}
Provider Configuration
Provider Version Pinning
Always pin provider versions:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Multiple Provider Configurations
# Primary region
provider "aws" {
region = "us-east-1"
}
# DR region
provider "aws" {
alias = "dr"
region = "us-west-2"
}
Error Handling and Validation
Pre-Conditions and Post-Conditions
resource "aws_instance" "web" {
instance_type = var.instance_type
lifecycle {
precondition {
condition = contains(["t3.micro", "t3.small", "m5.large"], var.instance_type)
error_message = "Instance type must be approved for production use."
}
}
}
Validation Rules
Use validation rules extensively:
variable "cidr_block" {
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "CIDR block must be valid."
}
}
CI/CD Integration
Automated Testing
# .github/workflows/terraform.yml
name: Terraform
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- run: terraform init
- run: terraform validate
- run: terraform plan -no-color
Automated Deployment
# Deploy on merge to main
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- run: terraform apply -auto-approve
Security Best Practices
Secrets Management
Never hardcode secrets:
# Bad
resource "aws_db_instance" "db" {
password = "supersecretpassword"
}
# Good
resource "aws_db_instance" "db" {
password = var.db_password
}
# Even better - use AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
}
Least Privilege IAM
Create minimal IAM policies:
resource "aws_iam_role_policy" "ec2" {
role = aws_iam_role.ec2.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ec2:DescribeInstances",
"ec2:DescribeImages"
]
Resource = "*"
}
]
})
}
Performance Optimization
Parallelism Control
# Control parallelism for large deployments
terraform apply -parallelism=10
Targeted Updates
# Only plan changes to specific resources
terraform plan -target=aws_instance.web
Documentation and Knowledge Sharing
README Files
Every module needs documentation:
# VPC Module
This module creates a VPC with public and private subnets.
## Usage
```hcl
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
environment = "prod"
}
Requirements
- AWS provider >= 4.0
Resources Created
- VPC
- Internet Gateway
- NAT Gateway
- Public/Private Subnets
- Route Tables
### Code Comments
```hcl
# This security group allows HTTP/HTTPS traffic from anywhere
# Used for load balancers and public-facing services
resource "aws_security_group" "web" {
# ...
}
Team Workflow Patterns
Branching Strategy
# Feature branch workflow
git checkout -b feature/add-redis-cluster
terraform plan # Test changes
terraform apply # Deploy to dev first
Code Review Checklist
- Variables validated
- Resources tagged consistently
- Security groups follow least privilege
- State backend configured
- Documentation updated
Monitoring and Alerting
Drift Detection
# Check for configuration drift
terraform plan -detailed-exitcode
Cost Estimation
# Estimate costs before applying
terraform plan -out=tfplan
terraform show -json tfplan | jq '.planned_values'
Migration Strategies
From Manual Infrastructure
- Start with low-risk resources (S3 buckets, IAM policies)
- Gradually migrate stateful resources
- Use
terraform importfor existing resources
Refactoring Existing Code
- Create new modules alongside existing code
- Migrate resources gradually
- Update state file carefully
Common Anti-Patterns to Avoid
1. Large Monolithic Configurations
Problem: Everything in one file, hard to maintain Solution: Break into modules and environments
2. Hardcoded Values
Problem: No flexibility, hard to reuse Solution: Use variables and locals
3. No State Locking
Problem: Concurrent modifications corrupt state Solution: Use DynamoDB for locking
4. No Version Control
Problem: No history, no collaboration Solution: Git with proper branching
Tools and Ecosystem
Essential Tools
- terraform-docs: Auto-generate documentation
- tflint: Linting for Terraform
- tfsec: Security scanning
- terragrunt: Wrapper for multi-environment management
IDE Support
- VS Code with HashiCorp Terraform extension
- IntelliJ IDEA with Terraform plugin
Measuring Success
Track these metrics:
- Deployment frequency
- Mean time to provision
- Infrastructure incidents
- Code review turnaround time
Final Thoughts
🔗 See it in action: My terraform-aws-reference-architecture repo applies every pattern in this post, modular structure, remote state, CI validation, and progressive examples from a single EC2 to a full VPC with private RDS. Clone it and adapt to your environment.
Terraform is powerful but requires discipline. The patterns above have helped teams scale from startup chaos to enterprise-grade infrastructure management.
Start with the basics: proper structure, consistent naming, and automated testing. Build from there.
The goal isn't perfect Terraform code, it's reliable, maintainable infrastructure that enables your business to move fast.
What's your biggest Terraform challenge? How do you structure your Terraform code? Share in the comments.