The Problem
Your Terraform codebase has grown to 3000 lines in a single directory. Every team copies and pastes VPC configurations. When a security policy changes, you update the same resource block in 15 places. Terraform modules encapsulate reusable patterns, but poorly designed modules create worse problems than no modules at all.
Module Structure
modules/vpc/
├── main.tf # Core resources
├── variables.tf # Input variables with validation
├── outputs.tf # Output values
├── versions.tf # Provider version constraints
├── locals.tf # Computed local values
└── README.md # Usage documentation
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
variables.tf with validation
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
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."
}
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 AZs required for high availability."
}
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
Calling Modules
Local modules
module "vpc" {
source = "../../modules/vpc"
environment = "production"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = {
Project = "payment-platform"
}
}
module "api_service" {
source = "../../modules/ecs-service"
service_name = "payment-api"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
container_image = "123456789.dkr.ecr.us-east-1.amazonaws.com/payment-api:v2.3.1"
cpu = 512
memory = 1024
}
Registry modules with version pinning
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = "production-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}
Git repository modules
module "vpc" {
source = "git::https://github.com/company/terraform-modules.git//modules/vpc?ref=v2.1.0"
}
Versioning Strategy
Semantic versioning
v1.0.0 -> v1.1.0 (new feature, backward compatible)
v1.1.0 -> v1.2.0 (new optional variable added)
v1.2.0 -> v2.0.0 (breaking change: renamed variable)
Git tags
git tag -a v2.1.0 -m "Add multi-AZ NAT gateway support"
git push origin v2.1.0
Version constraints
module "vpc" {
source = "app.terraform.io/company/vpc/aws"
version = "~> 2.1" # Allows 2.1.x but not 2.2.0
}
Composition Patterns
Root module composition
environments/
├── production/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── staging/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── modules/
├── vpc/
├── ecs-service/
└── rds/
Wrapper modules with company defaults
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = "${var.project}-${var.environment}"
cidr = var.vpc_cidr
# Company defaults
enable_nat_gateway = true
single_nat_gateway = var.environment != "production"
enable_dns_hostnames = true
enable_flow_log = true
tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
}
Feature toggles
variable "enable_monitoring" {
type = bool
default = true
}
resource "aws_cloudwatch_metric_alarm" "cpu_high" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "${var.service_name}-cpu-high"
comparison_operator = "GreaterThanThreshold"
threshold = 80
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 300
statistic = "Average"
}
Module Design Principles
Use locals for computed values
locals {
name_prefix = "${var.project}-${var.environment}"
private_subnet_cidrs = [
for i, az in var.availability_zones :
cidrsubnet(var.vpc_cidr, 8, i)
]
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
}
Right level of abstraction
# TOO LOW — consumer needs to know implementation details
variable "subnet_route_table_association_count" {}
# TOO HIGH — no flexibility at all
# (hardcoded CIDR and subnet count)
# JUST RIGHT
variable "vpc_cidr" { default = "10.0.0.0/16" }
variable "availability_zones" {} # Force explicit choice
Testing Modules
# Validate syntax
cd modules/vpc/examples/simple
terraform init
terraform validate
# Plan to check resource count
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan | jq '.resource_changes | length'
Common Mistakes
count with modules based on variables — Changing count reindexes instances and recreates resources. Use for_each with maps.ref=main means any commit can break your infrastructure. Always pin to a tag.Quick Reference
| Pattern | When to Use |
|---|---|
| Local module | Team-internal shared patterns |
| Git ref module | Cross-team shared modules |
| Registry module | Community modules with pinned version |
| Wrapper module | Add company defaults to community modules |
| Feature toggles | Optional features within a module |
| Composition | Root modules calling focused children |
Summary
Good modules are focused, validated, versioned, and composed at the root level. Start simple, add validation rules, version with semantic tags, and test with example configurations. The goal is infrastructure patterns teams can adopt confidently without reading the implementation.
---
Frequently Asked Questions
What is a Terraform module and when should I create one?
A Terraform module is a reusable collection of resources with configurable inputs (variables) and outputs. Create a module when you have a set of resources that are deployed together in a repeated pattern (e.g., VPC + subnets + route tables). Don't over-modularize — if it's used in only one place, keep it inline until you need reuse.
How do I structure a Terraform module?
A module needs at minimum: main.tf (resources), variables.tf (inputs), outputs.tf (exports), and README.md (documentation). Add versions.tf for provider version constraints. Keep modules focused on one concern — a "networking" module shouldn't also create compute resources. Use descriptive variable names with validation blocks and sensible defaults.
What is the difference between root modules and child modules?
The root module is your top-level working directory where you run terraform apply — it contains your backend config and calls other modules. Child modules are reusable components called with module "name" { source = "./modules/vpc" }. Root modules are environment-specific (dev, prod), while child modules are environment-agnostic and parameterized through variables.
How do I version and share Terraform modules?
Use Git tags for versioning (v1.0.0) and reference modules with source = "git::https://github.com/org/module.git?ref=v1.0.0". For organizations, publish to a private Terraform registry or use Git SSH sources. Pin module versions in calling code and use semantic versioning to communicate breaking changes. Never reference main branch in production.
How do I pass data between Terraform modules?
Define outputs in the source module (output "vpc_id" { value = aws_vpc.main.id }) and reference them in the calling module with module.vpc.vpc_id. For modules that don't directly call each other, use terraform_remote_state data source to read outputs from another state file, or pass values through the root module as variables.
---
Related Resources
- Production Reference Architectures — 6 production reference architectures with IaC patterns
- DevOps Prompt Library — 500 DevOps prompts including Terraform templates
- AWS IAM Least Privilege Guide — IAM policies for Terraform roles
- Terraform State Management — Managing state files in production
- Terraform Import Existing Resources — Importing existing infra into Terraform