Modules & Composition

Copy-pasting Terraform configurations between projects is a red flag. If you find yourself duplicating the same VPC setup, database configuration, or security group rules across multiple environments, you’re missing one of Terraform’s most powerful features: modules.

Modules aren’t just about code reuse—they’re about creating consistent, well-designed infrastructure patterns that can evolve with your organization. Good modules encapsulate complexity, provide sensible defaults, and make it easy to do the right thing. They’re the difference between managing infrastructure and architecting it.

What Makes a Good Module

A module is just a collection of Terraform files in a directory, but a good module is much more. It should have a clear purpose, a well-defined interface, and sensible defaults. Think of modules like functions in programming—they should do one thing well and be composable with other modules.

Here’s the basic structure of a module:

modules/vpc/
├── main.tf       # Primary resource definitions
├── variables.tf  # Input variables
├── outputs.tf    # Output values
└── README.md     # Documentation

The key insight is that modules have inputs (variables) and outputs, just like functions. This interface is what makes them reusable and composable.

Creating Your First Module

Let’s create a VPC module that encapsulates common networking patterns:

# modules/vpc/variables.tf
variable "name" {
  description = "Name prefix for all resources"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "List of availability zones"
  type        = list(string)
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets"
  type        = list(string)
}

variable "private_subnet_cidrs" {
  description = "CIDR blocks for private subnets"
  type        = list(string)
}

variable "enable_nat_gateway" {
  description = "Enable NAT gateway for private subnets"
  type        = bool
  default     = true
}
# modules/vpc/main.tf
resource "aws_vpc" "main" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true
  
  tags = {
    Name = "${var.name}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  
  tags = {
    Name = "${var.name}-igw"
  }
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]
  
  map_public_ip_on_launch = true
  
  tags = {
    Name = "${var.name}-public-${count.index + 1}"
    Type = "public"
  }
}

resource "aws_subnet" "private" {
  count             = length(var.private_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]
  
  tags = {
    Name = "${var.name}-private-${count.index + 1}"
    Type = "private"
  }
}

# NAT Gateway for private subnet internet access
resource "aws_eip" "nat" {
  count  = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
  domain = "vpc"
  
  tags = {
    Name = "${var.name}-nat-eip-${count.index + 1}"
  }
}

resource "aws_nat_gateway" "main" {
  count         = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
  
  tags = {
    Name = "${var.name}-nat-${count.index + 1}"
  }
  
  depends_on = [aws_internet_gateway.main]
}
# modules/vpc/outputs.tf
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "vpc_cidr_block" {
  description = "CIDR block of the VPC"
  value       = aws_vpc.main.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 "internet_gateway_id" {
  description = "ID of the Internet Gateway"
  value       = aws_internet_gateway.main.id
}

output "nat_gateway_ids" {
  description = "IDs of the NAT Gateways"
  value       = aws_nat_gateway.main[*].id
}

Using Modules

Now you can use this module in your main configuration:

# main.tf
module "vpc" {
  source = "./modules/vpc"
  
  name               = "my-app"
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"]
  
  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"]
  
  enable_nat_gateway = true
}

# Use module outputs in other resources
resource "aws_security_group" "web" {
  name_prefix = "web-"
  vpc_id      = module.vpc.vpc_id
  
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

The module encapsulates all the complexity of creating a VPC with public and private subnets, internet gateway, and NAT gateways. Users of the module only need to provide the essential parameters.

Module Versioning and Sources

Modules can come from various sources, and versioning is crucial for stability:

Local modules (development):

module "vpc" {
  source = "./modules/vpc"
}

Git repositories:

module "vpc" {
  source = "git::https://github.com/company/terraform-modules.git//vpc?ref=v1.2.0"
}

Terraform Registry:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 3.0"
}

Local file paths:

module "vpc" {
  source = "../shared-modules/vpc"
}

Always pin module versions in production to prevent unexpected changes from breaking your infrastructure.

Advanced Module Patterns

Conditional resources let modules adapt to different use cases:

# Create NAT gateway only if enabled
resource "aws_nat_gateway" "main" {
  count         = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
}

# Create different instance types based on environment
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
  subnet_id     = var.subnet_id
}

Dynamic blocks handle variable-length configuration:

resource "aws_security_group" "main" {
  name_prefix = var.name_prefix
  vpc_id      = var.vpc_id
  
  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

Module composition combines multiple modules:

module "vpc" {
  source = "./modules/vpc"
  # configuration...
}

module "database" {
  source = "./modules/rds"
  
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}

module "application" {
  source = "./modules/ecs-app"
  
  vpc_id           = module.vpc.vpc_id
  public_subnets   = module.vpc.public_subnet_ids
  private_subnets  = module.vpc.private_subnet_ids
  database_endpoint = module.database.endpoint
}

Module Design Principles

Single responsibility: Each module should have one clear purpose. Don’t create a “kitchen sink” module that does everything.

Sensible defaults: Provide defaults for optional parameters that work in most cases:

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "backup_retention_days" {
  description = "Number of days to retain backups"
  type        = number
  default     = 7
  
  validation {
    condition     = var.backup_retention_days >= 1 && var.backup_retention_days <= 35
    error_message = "Backup retention must be between 1 and 35 days."
  }
}

Clear interfaces: Use descriptive variable names and provide good documentation:

variable "allowed_cidr_blocks" {
  description = "List of CIDR blocks allowed to access the application"
  type        = list(string)
  default     = []
  
  validation {
    condition = alltrue([
      for cidr in var.allowed_cidr_blocks : can(cidrhost(cidr, 0))
    ])
    error_message = "All values must be valid CIDR blocks."
  }
}

Composability: Design modules to work well together. Use consistent naming conventions and output the information other modules might need.

Testing Modules

Modules should be tested like any other code. Here’s a simple testing approach using Terratest (Go) or pytest (Python):

# test/vpc_test.tf
module "test_vpc" {
  source = "../modules/vpc"
  
  name               = "test"
  availability_zones = ["us-west-2a", "us-west-2b"]
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24"]
}

# Validate outputs
output "vpc_id" {
  value = module.test_vpc.vpc_id
}

output "public_subnets_count" {
  value = length(module.test_vpc.public_subnet_ids)
}

Test your modules in isolation before using them in production configurations.

Module Registry and Sharing

For organizations with multiple teams, consider creating a private module registry:

# Using modules from a private registry
module "vpc" {
  source  = "app.terraform.io/company/vpc/aws"
  version = "~> 2.0"
  
  name = "production"
  # other configuration...
}

Document your modules well and include examples. Good documentation makes modules more likely to be adopted and used correctly.

Common Module Pitfalls

Over-abstraction: Don’t try to make modules handle every possible use case. It’s better to have focused modules that do one thing well.

Hidden complexity: Modules should simplify usage, not hide important details. Make sure users understand what resources are being created.

Tight coupling: Avoid modules that depend too heavily on specific configurations or other modules. Loose coupling makes modules more reusable.

Version sprawl: Don’t create new module versions for every small change. Use semantic versioning and batch compatible changes together.

What’s Next

Modules are the key to scaling Terraform in organizations. They enable code reuse, enforce standards, and make complex infrastructure manageable. The patterns you’ve learned—clear interfaces, sensible defaults, and composition—apply whether you’re building simple utility modules or complex application platforms.

In the next part, we’ll explore advanced Terraform patterns including workspaces for managing multiple environments, remote backends for team collaboration, and techniques for handling complex dependencies and state management scenarios. These patterns build on the module foundation to create robust, scalable infrastructure management systems.