Skip to content
Unverified — AI-generated content. Help verify this page

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 tests

A Complete VPC Module

Here is a production-quality VPC module to illustrate every concept:

hcl
# modules/vpc/versions.tf
terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}
hcl
# 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     = {}
}
hcl
# 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"
  })
}
hcl
# 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"
  })
}
hcl
# 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

hcl
# 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

hcl
# 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

hcl
# 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

hcl
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

hcl
# 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

bash
# 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.tf

Module Composition Patterns

Pattern 1: Flat Modules

Each module manages one concern. The root module composes them:

hcl
# 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:

hcl
# 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:

hcl
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:

hcl
# 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:

hcl
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:

hcl
# 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:

hcl
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 dashboards

4. Avoid Hard-Coded Values

Everything environment-specific should be a variable:

hcl
# 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:

hcl
# 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

go
// 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

go
// 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

bash
# 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 4

Test Cost Management

Terratest creates real infrastructure. Control costs with:

  1. Use small resources — t3.micro, db.t3.micro
  2. Set timeouts-timeout 30m prevents stuck tests
  3. Clean up on failuredefer terraform.Destroy() runs even if the test fails
  4. Use a dedicated test account — separate from production
  5. Run tests in CI only on PR — not on every push
  6. Tag test resourcesTestName = "vpc_test" for easy identification

terraform-docs

Auto-generate documentation from your module:

bash
# 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:

yaml
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-Repo

terraform-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

"What I cannot create, I do not understand." — Richard Feynman