Terraform Modules for Reusable Infrastructure
Learn how to build, structure, and publish Terraform modules to create reusable, composable infrastructure as code.
What you'll learn
- ✓Why Terraform modules matter for scaling IaC
- ✓How to structure a module with inputs, outputs, and defaults
- ✓Composing modules together for full environments
- ✓Versioning and publishing modules to a registry
Prerequisites
- •Basic Terraform knowledge (resources, providers, state)
- •Comfortable with HCL syntax
Why Modules
When your Terraform codebase grows past a few hundred lines, you start copy-pasting resource blocks across projects. A VPC here, an RDS instance there, each with slightly different variable names and forgotten security group rules. Modules solve this by letting you package a set of resources into a reusable, versioned unit with a clean interface.
Think of a module like a function in programming. It takes inputs (variables), does something internally (creates resources), and returns outputs. The caller does not need to know the implementation details. They just pass in a CIDR block and get a fully configured VPC back.
Module Structure
A well-structured module lives in its own directory with three core files:
# 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 "az_count" {
description = "Number of availability zones to use"
type = number
default = 2
}
variable "enable_nat_gateway" {
description = "Whether to create NAT gateways for private subnets"
type = bool
default = true
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.name}-vpc"
}
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.name}-public-${count.index}"
Tier = "public"
}
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index + var.az_count)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.name}-private-${count.index}"
Tier = "private"
}
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.name}-igw"
}
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.this.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
}
The key convention is that every variable has a description and a sensible default where possible, and every output has a description. This makes the module self-documenting.
Calling a Module
From your root configuration, you call the module like this:
# environments/production/main.tf
module "network" {
source = "../../modules/vpc"
name = "prod"
cidr_block = "10.0.0.0/16"
az_count = 3
enable_nat_gateway = true
}
module "database" {
source = "../../modules/rds"
name = "prod-db"
subnet_ids = module.network.private_subnet_ids
vpc_id = module.network.vpc_id
instance_class = "db.r6g.large"
engine_version = "15.4"
allocated_storage = 100
}
Notice how module.network.private_subnet_ids flows directly into the database module. This is composition: modules producing outputs that become inputs for other modules.
Composing Modules Together
Real infrastructure is built from layers of modules. A common pattern is a “stack” module that composes lower-level modules:
# stacks/web-app/main.tf
module "network" {
source = "../../modules/vpc"
name = var.environment
cidr_block = var.vpc_cidr
az_count = var.az_count
}
module "cluster" {
source = "../../modules/ecs-cluster"
name = var.environment
vpc_id = module.network.vpc_id
private_subnet_ids = module.network.private_subnet_ids
}
module "service" {
source = "../../modules/ecs-service"
name = "${var.environment}-api"
cluster_id = module.cluster.cluster_id
container_image = var.api_image
container_port = 8080
desired_count = var.api_replicas
subnet_ids = module.network.private_subnet_ids
}
module "database" {
source = "../../modules/rds"
name = "${var.environment}-db"
subnet_ids = module.network.private_subnet_ids
vpc_id = module.network.vpc_id
instance_class = var.db_instance_class
}
Now spinning up a full environment is a single terraform apply with different variable files for staging versus production.
Validation and Preconditions
Terraform 1.2 and later supports validation blocks and preconditions that make modules more robust:
variable "cidr_block" {
type = string
description = "VPC CIDR block"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "instance_class" {
type = string
description = "RDS instance class"
validation {
condition = startswith(var.instance_class, "db.")
error_message = "Instance class must start with 'db.' prefix."
}
}
Preconditions inside resources catch runtime issues:
resource "aws_db_instance" "this" {
# ...
lifecycle {
precondition {
condition = var.allocated_storage >= 20
error_message = "Minimum storage for production databases is 20 GB."
}
}
}
Versioning Modules
When modules are shared across teams, you need versioning. The two main approaches are Git tags and a Terraform registry.
With Git tags, you pin a specific version in the source:
module "vpc" {
source = "git::https://github.com/your-org/terraform-modules.git//modules/vpc?ref=v2.1.0"
# ...
}
With a private registry (Terraform Cloud, Spacelift, or even S3-backed), you use semantic versioning:
module "vpc" {
source = "app.terraform.io/your-org/vpc/aws"
version = "~> 2.1"
# ...
}
The ~> constraint means “any 2.x release at 2.1 or higher.” This lets you receive patch fixes without unexpected major changes.
Testing Modules
You should test modules before publishing. Terratest (Go) and terraform test (built-in since Terraform 1.6) are the main options:
# tests/vpc.tftest.hcl
run "creates_vpc_with_correct_cidr" {
command = plan
variables {
name = "test"
cidr_block = "10.99.0.0/16"
az_count = 2
}
assert {
condition = aws_vpc.this.cidr_block == "10.99.0.0/16"
error_message = "VPC CIDR block does not match input"
}
}
run "creates_correct_number_of_subnets" {
command = plan
variables {
name = "test"
az_count = 3
}
assert {
condition = length(aws_subnet.public) == 3
error_message = "Expected 3 public subnets"
}
}
Run with terraform test from the module directory. Plan-only tests are fast and do not require cloud credentials in CI.
Common Mistakes
Hardcoding provider configuration inside modules. The caller should configure providers. Modules should declare required_providers but never set region or profile.
Too many variables. If your module has forty variables, it is doing too much. Split it into smaller, focused modules.
No outputs. If downstream modules cannot reference your resources, they will resort to data sources or hardcoded IDs, which defeats the purpose of composition.
Skipping validation. Without input validation, users discover misconfiguration at apply time instead of plan time. Add validation blocks to catch bad inputs early.
What to Do Next
Start by extracting one repeated pattern from your codebase into a module. A VPC or a security group is a good first candidate. Add variables, outputs, and a README. Once it works, version it with a Git tag and reference it from a second project. That single loop will teach you more about module design than any tutorial can.
Related articles
- DevOps Infrastructure as Code: Terraform Getting Started Guide
Learn Infrastructure as Code with Terraform. This beginner guide covers HCL syntax, providers, state management, modules, and deploying your first cloud resources step by step.
- DevOps Pulumi for Infrastructure as Code with Real Programming Languages
Learn how Pulumi lets you define cloud infrastructure using TypeScript, Python, or Go instead of domain-specific languages.
- DevOps Terraform State Management Basics
Why Terraform state exists, how it maps configuration to real infrastructure, and how to set up remote backends, locking, and workspaces without losing your mind.
- DevOps Ansible vs Terraform: Configuration vs Infrastructure
Compare Ansible and Terraform for DevOps automation. Learn when to use each tool, how they differ in approach, and how to combine them effectively.