Writing Terraform Modules That Don't Suck
Practical patterns for reusable, maintainable, and testable Terraform modules
I’ve read a lot of Terraform that made me want to close my laptop and go work at a coffee shop. Over the years, I’ve collected patterns that separate good modules from painful ones.
The anatomy of a good module
A well-structured Terraform module looks like this:
module/
├── main.tf # Core resources
├── variables.tf # Input variables with validation
├── outputs.tf # Useful outputs
├── versions.tf # Provider version constraints
├── README.md # Auto-generated with terraform-docs
└── examples/
└── basic/ # Working example
This isn’t revolutionary — it’s what terraform-docs and the Terraform registry expect.
Variables: be explicit, not clever
Bad:
variable "config" {
type = any
}
Good:
variable "instance_type" {
type = string
description = "EC2 instance type for the web servers"
default = "t3.micro"
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "Must be a supported instance type."
}
}
Validations catch errors at plan time, not at 2am during an incident.
Outputs: expose what callers need
Don’t expose everything — just what downstream modules and humans actually need. Always include the resource ID and ARN at minimum.
output "bucket_id" {
description = "The S3 bucket ID"
value = aws_s3_bucket.main.id
}
output "bucket_arn" {
description = "The S3 bucket ARN"
value = aws_s3_bucket.main.arn
}
Testing with Terratest
Untested Terraform is a liability. Terratest lets you write Go tests that provision real infrastructure, run assertions, and tear it down.
func TestS3Module(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../examples/basic",
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
bucketID := terraform.Output(t, opts, "bucket_id")
assert.NotEmpty(t, bucketID)
}
Yes, real tests use real AWS resources. Yes, it costs a few cents. No, it’s not optional for production modules.
The versions.tf contract
Always pin provider versions with a range, not exact:
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
~> 5.0 allows 5.x but not 6.0. This prevents accidental breaking changes while still allowing patches.
Following these patterns consistently will make your modules a joy to use — even at 3am.