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.