Terraform Module Design: From Beginner to Advanced Patterns
Writing Terraform code is easy. Writing maintainable, reusable Terraform modules is hard. Here’s how to do it right.
The Foundation: Input Variables Done Right
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all resources"
type = string
validation {
condition = length(var.name) <= 16
error_message = "Name must be 16 characters or less."
}
}
variable "cidr_block" {
description = "The CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "tags" {
description = "A map of tags to add to all resources"
type = map(string)
default = {}
}
Good variable design includes:
- Clear, actionable descriptions
- Sensible defaults when appropriate
- Type constraints (don’t use
any) - Validation rules
- No required variables without good reason
Outputs That Don’t Leak Implementation Details
# modules/eks/outputs.tf
# GOOD: Abstracted interface
output "cluster_endpoint" {
description = "Endpoint for EKS control plane"
value = aws_eks_cluster.this.endpoint
sensitive = true
}
output "kubeconfig" {
description = "kubectl config file contents"
value = local.kubeconfig
sensitive = true
}
# BAD: Exposing internal resource names
output "cluster_id" {
value = aws_eks_cluster.this.id # Implementation detail!
}
The Composite Module Pattern
Instead of giant monolithic modules:
# Root module structure
modules/
├── network/ # VPC, subnets, gateways
├── compute/ # EC2, ASG, load balancers
├── database/ # RDS, ElastiCache
└── security/ # IAM, security groups
Root module usage:
module "network" {
source = "./modules/network"
name = "prod"
cidr_block = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
module "compute" {
source = "./modules/compute"
name = "prod"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
instance_type = "t3.large"
depends_on = [module.network]
}
Testing Your Modules
# tests/integration/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
module "test_vpc" {
source = "../modules/vpc"
name = "test-${random_pet.this.id}"
cidr_block = "10.0.0.0/24"
tags = {
Environment = "test"
Terraform = "true"
}
}
resource "random_pet" "this" {
length = 2
}
Automated testing workflow:
# In CI/CD pipeline
cd tests/integration
terraform init
terraform plan -out=tfplan
terraform apply tfplan
# Run validation tests
./validate.sh
# Cleanup
terraform destroy -auto-approve
Advanced: Dynamic Blocks and Conditional Logic
# modules/security-group/main.tf
resource "aws_security_group" "this" {
name = var.name
description = var.description
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
dynamic "egress" {
for_each = var.allow_all_egress ? [1] : []
content {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
tags = merge(var.tags, {
Name = var.name
})
}
Versioning and Module Registry
# Using Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 3.0" # Pinned version!
name = "my-vpc"
cidr = "10.0.0.0/16"
}
# Internal registry (terraform apply -auto-approve)
module "internal_app" {
source = "git::https://github.com/my-org/terraform-modules.git//apps/web?ref=v1.2.0"
}
Anti-Patterns to Avoid
- The God Module - Does everything, understood by no one
- Hidden Dependencies - Implicit ordering that breaks
- Hardcoded Values - Region, account IDs, AMI IDs
- No Validation - Garbage in, production failure out
- Ignoring State - Forgetting remote state configuration
Conclusion
Great Terraform modules:
- Have clear, validated interfaces
- Hide implementation details
- Are composable and testable
- Follow semantic versioning
- Include documentation and examples
Start small, test often, and remember: terraform destroy is your friend when experimenting!