Terraform Modules
A module is a container for multiple resources that are used together. Every Terraform configuration is already a module — the root module. When you create a directory with .tf files and call it from another configuration, it becomes a child module. Modules are how you turn Terraform from a scripting tool into an engineering tool — reusable, testable, and composable.
Why Modules Matter
Without modules, Terraform configurations grow into massive main.tf files with hundreds of resources, duplicated across environments. Changes require copy-paste editing. Testing is impossible. Reviews take hours.
With modules, you get:
- Reusability: Define a VPC once, use it across dev/staging/production
- Encapsulation: Hide implementation details behind a clean interface
- Testability: Test a module in isolation with Terratest
- Versioning: Pin module versions so updates are intentional
- Consistency: Every environment uses the same proven module
Module Structure
A well-structured module looks like this:
modules/
└── vpc/
├── main.tf # Primary resources
├── variables.tf # Input variables (the module's API)
├── outputs.tf # Output values (what the module exposes)
├── versions.tf # Required providers and Terraform version
├── locals.tf # Local computed values
├── data.tf # Data sources
├── README.md # Documentation (auto-generated by terraform-docs)
├── examples/
│ ├── simple/
│ │ └── main.tf # Minimal usage example
│ └── complete/
│ └── main.tf # Full-featured usage example
└── tests/
└── vpc_test.go # Terratest testsA Complete VPC Module
Here is a production-quality VPC module to illustrate every concept:
# modules/vpc/versions.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all VPC resources"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,28}[a-z0-9]$", var.name))
error_message = "Name must be 4-30 characters, lowercase alphanumeric with hyphens."
}
}
variable "cidr_block" {
description = "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 "availability_zones" {
description = "List of availability zones to use"
type = list(string)
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 availability zones are required for high availability."
}
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets (one per AZ)"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets (one per AZ)"
type = list(string)
}
variable "database_subnet_cidrs" {
description = "CIDR blocks for database subnets (one per AZ). Set to empty list to skip."
type = list(string)
default = []
}
variable "enable_nat_gateway" {
description = "Whether to create NAT gateways for private subnets"
type = bool
default = true
}
variable "single_nat_gateway" {
description = "Use a single NAT gateway instead of one per AZ (saves cost, reduces availability)"
type = bool
default = false
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in the VPC"
type = bool
default = true
}
variable "enable_flow_logs" {
description = "Enable VPC flow logs"
type = bool
default = true
}
variable "flow_log_retention_days" {
description = "Number of days to retain flow logs in CloudWatch"
type = number
default = 30
}
variable "tags" {
description = "Additional tags to apply to all resources"
type = map(string)
default = {}
}# modules/vpc/locals.tf
locals {
nat_gateway_count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.availability_zones)) : 0
common_tags = merge(var.tags, {
Module = "vpc"
})
}# modules/vpc/main.tf
# ─── VPC ────────────────────────────────────────────────────────────────────────
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = var.enable_dns_hostnames
tags = merge(local.common_tags, {
Name = "${var.name}-vpc"
})
}
# ─── Internet Gateway ──────────────────────────────────────────────────────────
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, {
Name = "${var.name}-igw"
})
}
# ─── Public Subnets ────────────────────────────────────────────────────────────
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${var.name}-public-${var.availability_zones[count.index]}"
"kubernetes.io/role/elb" = "1"
Tier = "public"
})
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, {
Name = "${var.name}-public-rt"
})
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# ─── Private Subnets ───────────────────────────────────────────────────────────
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(local.common_tags, {
Name = "${var.name}-private-${var.availability_zones[count.index]}"
"kubernetes.io/role/internal-elb" = "1"
Tier = "private"
})
}
# ─── NAT Gateways ──────────────────────────────────────────────────────────────
resource "aws_eip" "nat" {
count = local.nat_gateway_count
domain = "vpc"
tags = merge(local.common_tags, {
Name = "${var.name}-nat-eip-${count.index + 1}"
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_nat_gateway" "this" {
count = local.nat_gateway_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(local.common_tags, {
Name = "${var.name}-nat-${count.index + 1}"
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_route_table" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, {
Name = "${var.name}-private-rt-${count.index + 1}"
})
}
resource "aws_route" "private_nat" {
count = var.enable_nat_gateway ? length(var.private_subnet_cidrs) : 0
route_table_id = aws_route_table.private[count.index].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[var.single_nat_gateway ? 0 : count.index].id
}
resource "aws_route_table_association" "private" {
count = length(var.private_subnet_cidrs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# ─── Database Subnets ──────────────────────────────────────────────────────────
resource "aws_subnet" "database" {
count = length(var.database_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.database_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(local.common_tags, {
Name = "${var.name}-database-${var.availability_zones[count.index]}"
Tier = "database"
})
}
resource "aws_db_subnet_group" "this" {
count = length(var.database_subnet_cidrs) > 0 ? 1 : 0
name = "${var.name}-db-subnet-group"
subnet_ids = aws_subnet.database[*].id
tags = merge(local.common_tags, {
Name = "${var.name}-db-subnet-group"
})
}
resource "aws_route_table" "database" {
count = length(var.database_subnet_cidrs) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, {
Name = "${var.name}-database-rt"
})
}
resource "aws_route_table_association" "database" {
count = length(var.database_subnet_cidrs)
subnet_id = aws_subnet.database[count.index].id
route_table_id = aws_route_table.database[0].id
}
# ─── VPC Flow Logs ─────────────────────────────────────────────────────────────
resource "aws_cloudwatch_log_group" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "/aws/vpc/flow-logs/${var.name}"
retention_in_days = var.flow_log_retention_days
tags = local.common_tags
}
resource "aws_iam_role" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.name}-vpc-flow-logs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "vpc-flow-logs.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
tags = local.common_tags
}
resource "aws_iam_role_policy" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.name}-vpc-flow-logs-policy"
role = aws_iam_role.flow_logs[0].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams"
]
Resource = "*"
}
]
})
}
resource "aws_flow_log" "this" {
count = var.enable_flow_logs ? 1 : 0
vpc_id = aws_vpc.this.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow_logs[0].arn
iam_role_arn = aws_iam_role.flow_logs[0].arn
tags = merge(local.common_tags, {
Name = "${var.name}-flow-log"
})
}
# ─── Network ACLs ──────────────────────────────────────────────────────────────
resource "aws_network_acl" "public" {
vpc_id = aws_vpc.this.id
subnet_ids = aws_subnet.public[*].id
ingress {
rule_no = 100
protocol = "-1"
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
egress {
rule_no = 100
protocol = "-1"
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = merge(local.common_tags, {
Name = "${var.name}-public-nacl"
})
}
resource "aws_network_acl" "private" {
vpc_id = aws_vpc.this.id
subnet_ids = aws_subnet.private[*].id
ingress {
rule_no = 100
protocol = "-1"
action = "allow"
cidr_block = var.cidr_block
from_port = 0
to_port = 0
}
ingress {
rule_no = 200
protocol = "tcp"
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
egress {
rule_no = 100
protocol = "-1"
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
tags = merge(local.common_tags, {
Name = "${var.name}-private-nacl"
})
}# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
output "database_subnet_ids" {
description = "IDs of the database subnets"
value = aws_subnet.database[*].id
}
output "database_subnet_group_name" {
description = "Name of the database subnet group"
value = length(aws_db_subnet_group.this) > 0 ? aws_db_subnet_group.this[0].name : null
}
output "nat_gateway_ips" {
description = "Elastic IPs of the NAT gateways"
value = aws_eip.nat[*].public_ip
}
output "public_route_table_id" {
description = "ID of the public route table"
value = aws_route_table.public.id
}
output "private_route_table_ids" {
description = "IDs of the private route tables"
value = aws_route_table.private[*].id
}
output "internet_gateway_id" {
description = "ID of the internet gateway"
value = aws_internet_gateway.this.id
}
output "availability_zones" {
description = "Availability zones used"
value = var.availability_zones
}Using Modules
Local Module Reference
# environments/production/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "myapp-production"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
database_subnet_cidrs = ["10.0.21.0/24", "10.0.22.0/24", "10.0.23.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # One per AZ for production
enable_flow_logs = true
tags = {
Environment = "production"
Team = "platform"
}
}
# Reference module outputs
resource "aws_security_group" "web" {
vpc_id = module.vpc.vpc_id
# ...
}
resource "aws_db_instance" "main" {
db_subnet_group_name = module.vpc.database_subnet_group_name
# ...
}Registry Module Reference
# Using an official HashiCorp registry module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "myapp-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
enable_nat_gateway = true
single_nat_gateway = false
}Git Module Reference
# From a Git repository
module "vpc" {
source = "git::https://github.com/mycompany/terraform-modules.git//vpc?ref=v2.1.0"
name = "myapp-vpc"
cidr_block = "10.0.0.0/16"
# ...
}
# SSH
module "vpc" {
source = "git::ssh://git@github.com/mycompany/terraform-modules.git//vpc?ref=v2.1.0"
# ...
}S3 Module Reference
module "vpc" {
source = "s3::https://s3-us-east-1.amazonaws.com/mycompany-terraform-modules/vpc/v2.1.0.zip"
# ...
}Module Versioning
Semantic Versioning Strategy
v1.0.0 → Initial release
v1.1.0 → Added database subnets (backwards compatible)
v1.2.0 → Added VPC flow logs option (backwards compatible)
v2.0.0 → Changed subnet CIDR input from list to map (breaking change)Version Constraints
# Pin to exact version (safest but requires manual updates)
version = "2.1.0"
# Allow patch updates (recommended for most teams)
version = "~> 2.1.0" # allows 2.1.x
# Allow minor updates (if you trust the module maintainers)
version = "~> 2.0" # allows 2.x.x
# Range
version = ">= 2.0, < 3.0"Versioning with Git Tags
# Tag a release
git tag -a v2.1.0 -m "Add VPC flow logs support"
git push origin v2.1.0
# Reference in module
module "vpc" {
source = "git::https://github.com/mycompany/terraform-modules.git//vpc?ref=v2.1.0"
}Publishing to Terraform Registry
For private registries (Terraform Cloud/Enterprise) or the public registry:
# Required repository naming convention:
terraform-<PROVIDER>-<NAME>
# Example: terraform-aws-vpc
# Required structure:
terraform-aws-vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md # Required for registry documentation
├── examples/
│ └── simple/
│ └── main.tf
└── modules/ # Optional submodules
└── subnets/
├── main.tf
├── variables.tf
└── outputs.tfModule Composition Patterns
Pattern 1: Flat Modules
Each module manages one concern. The root module composes them:
# environments/production/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "myapp-prod"
# ...
}
module "security_groups" {
source = "../../modules/security-groups"
vpc_id = module.vpc.vpc_id
# ...
}
module "ecs" {
source = "../../modules/ecs"
vpc_id = module.vpc.vpc_id
private_subnets = module.vpc.private_subnet_ids
security_groups = [module.security_groups.ecs_sg_id]
# ...
}
module "rds" {
source = "../../modules/rds"
vpc_id = module.vpc.vpc_id
database_subnets = module.vpc.database_subnet_ids
security_group_id = module.security_groups.rds_sg_id
# ...
}This is the recommended pattern for most teams. Each module is small, focused, and independently testable.
Pattern 2: Nested Modules
A top-level module contains submodules:
# modules/application-stack/main.tf
module "vpc" {
source = "../vpc"
name = var.name
# ...
}
module "ecs" {
source = "../ecs"
vpc_id = module.vpc.vpc_id
private_subnets = module.vpc.private_subnet_ids
# ...
}
module "rds" {
source = "../rds"
vpc_id = module.vpc.vpc_id
database_subnets = module.vpc.database_subnet_ids
# ...
}
# Usage is simpler but less flexible
module "app" {
source = "../../modules/application-stack"
name = "myapp-prod"
# One module call provisions everything
}Use this when the components always go together and flexibility is less important than simplicity.
Pattern 3: Module Factory
Use for_each to create multiple instances of a module:
variable "services" {
type = map(object({
image = string
cpu = number
memory = number
port = number
desired_count = number
health_check_path = string
}))
}
module "ecs_service" {
source = "../../modules/ecs-service"
for_each = var.services
name = each.key
cluster_id = aws_ecs_cluster.main.id
image = each.value.image
cpu = each.value.cpu
memory = each.value.memory
port = each.value.port
desired_count = each.value.desired_count
health_check_path = each.value.health_check_path
subnet_ids = module.vpc.private_subnet_ids
}
# terraform.tfvars
services = {
"api" = {
image = "myapp/api:latest"
cpu = 512
memory = 1024
port = 8080
desired_count = 3
health_check_path = "/health"
}
"worker" = {
image = "myapp/worker:latest"
cpu = 256
memory = 512
port = 9090
desired_count = 2
health_check_path = "/ready"
}
}Pattern 4: Wrapper Module
Wrap a community module with your organization's defaults:
# modules/company-vpc/main.tf
# Wraps the official terraform-aws-modules/vpc/aws with company defaults
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = var.name
cidr = var.cidr_block
azs = var.availability_zones
public_subnets = var.public_subnet_cidrs
private_subnets = var.private_subnet_cidrs
# Company defaults that should not vary between projects
enable_nat_gateway = true
enable_dns_hostnames = true
enable_dns_support = true
enable_flow_log = true
flow_log_destination_type = "cloud-watch-logs"
# Kubernetes tags required by company EKS standard
public_subnet_tags = {
"kubernetes.io/role/elb" = "1"
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = "1"
}
tags = merge(var.tags, {
ManagedBy = "terraform"
Module = "company-vpc"
})
}Module Design Principles
1. Define a Clear Interface
The variables and outputs ARE your module's API. Document them thoroughly:
variable "instance_type" {
description = <<-EOT
EC2 instance type for the web servers.
For production, use m5.large or larger.
For development, t3.micro is sufficient.
See: https://aws.amazon.com/ec2/instance-types/
EOT
type = string
default = "t3.micro"
validation {
condition = contains([
"t3.micro", "t3.small", "t3.medium",
"m5.large", "m5.xlarge", "m5.2xlarge",
], var.instance_type)
error_message = "Instance type must be from the approved list."
}
}2. Use Sensible Defaults
Modules should work with minimal configuration. Require only what you must:
# Good: works with just a name
module "vpc" {
source = "../../modules/vpc"
name = "myapp"
# Everything else has sensible defaults
}
# Bad: requires specifying everything
module "vpc" {
source = "../../modules/vpc"
name = "myapp"
cidr = "10.0.0.0/16" # Could default
dns_support = true # Could default
dns_hostnames = true # Could default
instance_tenancy = "default" # Could default
# ... 20 more required variables
}3. Output Everything Useful
You cannot predict what consumers will need. Output every ID, ARN, and name:
output "cluster_id" {
description = "ID of the ECS cluster"
value = aws_ecs_cluster.this.id
}
output "cluster_arn" {
description = "ARN of the ECS cluster"
value = aws_ecs_cluster.this.arn
}
output "cluster_name" {
description = "Name of the ECS cluster"
value = aws_ecs_cluster.this.name
}
# All three are useful in different contexts:
# - ID for resource references
# - ARN for IAM policies
# - Name for CLI commands and dashboards4. Avoid Hard-Coded Values
Everything environment-specific should be a variable:
# Bad
resource "aws_instance" "web" {
instance_type = "t3.micro" # Hard-coded
ami = "ami-0c55b159cbfafe1f0" # Hard-coded, region-specific
}
# Good
resource "aws_instance" "web" {
instance_type = var.instance_type
ami = var.ami_id != null ? var.ami_id : data.aws_ami.default.id
}5. Handle Provider Configuration Carefully
Modules should NOT configure providers. The calling module should pass provider configurations:
# modules/s3-bucket/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
# No configuration here — the caller provides it
}
}
}
# Usage with multiple regions
module "us_bucket" {
source = "../../modules/s3-bucket"
name = "myapp-us"
# Uses default provider
}
module "eu_bucket" {
source = "../../modules/s3-bucket"
name = "myapp-eu"
providers = {
aws = aws.eu
}
}Testing Modules with Terratest
Terratest is a Go library that lets you write automated tests for Terraform modules. Tests create real infrastructure, validate it, and destroy it.
Basic Test Structure
// tests/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
awsRegion := "us-east-1"
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/simple",
Vars: map[string]interface{}{
"name": "test-vpc",
"cidr_block": "10.99.0.0/16",
"availability_zones": []string{"us-east-1a", "us-east-1b"},
"public_subnet_cidrs": []string{"10.99.1.0/24", "10.99.2.0/24"},
"private_subnet_cidrs": []string{"10.99.11.0/24", "10.99.12.0/24"},
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
})
// Clean up resources when the test finishes
defer terraform.Destroy(t, terraformOptions)
// Run terraform init and apply
terraform.InitAndApply(t, terraformOptions)
// Get outputs
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
privateSubnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
// Validate VPC exists
vpc := aws.GetVpcById(t, vpcId, awsRegion)
assert.Equal(t, "10.99.0.0/16", *vpc.CidrBlock)
// Validate subnet counts
assert.Equal(t, 2, len(publicSubnetIds))
assert.Equal(t, 2, len(privateSubnetIds))
// Validate subnets are in different AZs
subnets := aws.GetSubnetsForVpc(t, vpcId, awsRegion)
azs := make(map[string]bool)
for _, subnet := range subnets {
azs[*subnet.AvailabilityZone] = true
}
assert.GreaterOrEqual(t, len(azs), 2, "Subnets should span at least 2 AZs")
}
func TestVpcModuleWithFlowLogs(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/complete",
Vars: map[string]interface{}{
"name": "test-vpc-flowlogs",
"enable_flow_logs": true,
"flow_log_retention": 7,
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Verify flow logs were created
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
require.NotEmpty(t, vpcId)
}Testing Patterns
// Test that module validates input correctly
func TestVpcModuleValidation(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../examples/simple",
Vars: map[string]interface{}{
"name": "X", // Too short — should fail validation
"availability_zones": []string{"us-east-1a"}, // Only 1 AZ — should fail
},
}
// Expect init+plan to fail due to validation
_, err := terraform.InitAndPlanE(t, terraformOptions)
require.Error(t, err)
}
// Test idempotency — applying twice should show no changes
func TestVpcModuleIdempotent(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/simple",
Vars: map[string]interface{}{
"name": "test-idempotent",
},
})
defer terraform.Destroy(t, terraformOptions)
// First apply
terraform.InitAndApply(t, terraformOptions)
// Second apply should show no changes
exitCode := terraform.PlanExitCode(t, terraformOptions)
assert.Equal(t, 0, exitCode, "Second apply should show no changes")
}Running Tests
# Run all tests
cd modules/vpc/tests
go test -v -timeout 30m
# Run a specific test
go test -v -timeout 30m -run TestVpcModule
# Run with test parallelism
go test -v -timeout 30m -parallel 4Test Cost Management
Terratest creates real infrastructure. Control costs with:
- Use small resources — t3.micro, db.t3.micro
- Set timeouts —
-timeout 30mprevents stuck tests - Clean up on failure —
defer terraform.Destroy()runs even if the test fails - Use a dedicated test account — separate from production
- Run tests in CI only on PR — not on every push
- Tag test resources —
TestName = "vpc_test"for easy identification
terraform-docs
Auto-generate documentation from your module:
# Install
brew install terraform-docs
# Generate markdown table
terraform-docs markdown table modules/vpc/ > modules/vpc/README.md
# Generate with custom template
terraform-docs markdown document \
--output-file README.md \
--output-mode inject \
modules/vpc/Add a .terraform-docs.yml to your module:
formatter: markdown table
header-from: docs/header.md
content: |-
{{ .Header }}
## Usage
```hcl
module "vpc" {
source = "git::https://github.com/mycompany/terraform-modules.git//vpc?ref=v2.0.0"
name = "myapp"
cidr_block = "10.0.0.0/16"
}{{ .Requirements }} {{ .Providers }} {{ .Inputs }}
output: file: README.md mode: inject template: |- {{ .Content }}
sort: enabled: true by: required
## Module Monorepo vs Multi-Repo
### Monorepo (Recommended for Most Teams)terraform-modules/ ├── modules/ │ ├── vpc/ │ ├── ecs/ │ ├── rds/ │ └── s3/ ├── examples/ │ ├── simple-vpc/ │ └── full-stack/ ├── tests/ │ ├── vpc_test.go │ └── ecs_test.go ├── .github/ │ └── workflows/ │ └── test.yml └── Makefile
Advantages: atomic cross-module changes, easier testing, single CI pipeline, simpler dependency management.
### Multi-Repoterraform-aws-vpc/ # One repo per module terraform-aws-ecs/ terraform-aws-rds/
Advantages: independent versioning, separate access control, cleaner git history per module.
Use multi-repo only when modules are maintained by different teams with different release cadences.
## What to Learn Next
- **[Workspaces](./workspaces)** — manage multiple environments with a single configuration
- **[AWS Startup Stack](./aws-startup-stack)** — see modules composed into a complete production infrastructure
- **[State Management](./state-management)** — manage state across multiple module-based projects