Terraform Module Patterns: How I Structure IaC for Reuse
After building the AWS Landing Zone and multiple infrastructure projects, I've developed opinions about how to write Terraform modules that other people can actually use.
The Module Structure
Every module follows this structure:
modules/
└── vpc/
├── main.tf # Primary resources
├── variables.tf # Input variables with descriptions
├── outputs.tf # Output values
├── versions.tf # Provider version constraints
├── locals.tf # Computed local values
├── data.tf # Data sources
├── README.md # Usage examples
└── examples/
└── basic/
└── main.tf # Working example
Key rules:
- One module = one logical resource group (VPC, ECS service, S3 bucket + policy)
- No module should be more than 200 lines of HCL
- Every variable has a description AND a type constraint
- Every output has a description
Variable Naming Convention
I prefix variables by purpose:
# Naming: enable_* for feature flags
variable "enable_flow_logs" {
description = "Enable VPC flow logs to CloudWatch"
type = bool
default = true
}
# Naming: *_name for naming resources
variable "vpc_name" {
description = "Name tag for the VPC and related resources"
type = string
}
# Naming: *_cidr for network blocks
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
# Naming: tags for common tags (always last)
variable "tags" {
description = "Common tags applied to all resources"
type = map(string)
default = {}
}
The Output Contract
Outputs are the API of your module. I treat them like a public interface — they don't change once published:
# Every module outputs its primary resource ID
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
# And any ARNs needed for IAM policies
output "vpc_arn" {
description = "The ARN of the VPC"
value = aws_vpc.main.arn
}
# And any values other modules might need
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
Tags: The Non-Negotiable
Every resource gets tags. No exceptions:
locals {
common_tags = merge(var.tags, {
ManagedBy = "terraform"
Module = "vpc"
Environment = var.environment
})
}
Tags enable cost tracking, access control, and operational visibility. Untagged resources in a shared AWS account are a liability.
The Anti-Patterns
Don't hardcode regions. Use data sources:
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
Don't use count for complex logic. Use for_each with a map:
# Bad: count = var.enable_thing ? 1 : 0
# Good: for_each with explicit keys
variable "subnets" {
type = map(object({
cidr = string
az = string
}))
}
Don't put secrets in Terraform state. Use AWS Secrets Manager or SSM Parameter Store and reference them as data sources.
What I'd Do Differently
- Adopt OpenTofu for new projects. The license change makes Terraform a business risk for open-source modules.
- Use Terragrunt earlier. Managing multiple environments with raw Terraform workspaces is painful. Terragrunt's folder structure is cleaner.
- Write Terratest tests from day one. I added testing retroactively. Starting with tests prevents "it works on my machine" drift.
Good IaC is boring. It should be predictable, documented, and tested — just like good application code.