← Blog

Writing Terraform Modules That Don't Suck

Practical patterns for reusable, maintainable, and testable Terraform modules

terraformiacawsbest-practices

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.

Anatole HAGBE

Anatole HAGBE

DevOps & Cloud Engineer

Passionate about building resilient infrastructure, automating everything I can, and making systems scale beautifully. I live in the terminal and dream in YAML.