Terraform modules
In this series (10 parts)
Copying and pasting Terraform blocks across projects works until it does not. A single change to your VPC layout means updating ten repositories. Modules solve this by turning infrastructure into reusable building blocks with clear interfaces.
Why modules exist
Every Terraform configuration is technically a module. The directory you run terraform apply in is the root module. When you extract resources into a separate directory and call them with a module block, you create a child module.
Modules give you three things:
- Reusability. Write a VPC once. Deploy it everywhere.
- Encapsulation. Consumers see inputs and outputs, not internal wiring.
- Consistency. Teams share the same tested patterns instead of inventing their own.
graph TD A[Root Module] --> B[VPC Module] A --> C[Database Module] A --> D[App Module] D --> B C --> B
Module structure
A well-organized module follows a predictable file layout. Nothing enforces this convention, but every team you join will expect it.
modules/vpc/
main.tf # Resources
variables.tf # Input variables
outputs.tf # Output values
versions.tf # Provider requirements
README.md # Usage documentation
Keep modules focused. A module that creates a VPC, subnets, route tables, and an internet gateway is reasonable. A module that also provisions EC2 instances, databases, and DNS records is doing too much.
Building a reusable VPC module
Start with the input variables. These define the module’s public API.
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 AZs for subnet placement"
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)
}
modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = { Name = "${var.name}-igw" }
}
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 = { Name = "${var.name}-public-${count.index}" }
}
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 = { Name = "${var.name}-private-${count.index}" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Calling the module
From your root configuration, reference the module with a source argument.
module "network" {
source = "./modules/vpc"
name = "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.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]
}
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = module.network.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
Access outputs through module.<name>.<output>. This is the only way modules communicate.
Module sources and versioning
The source argument accepts local paths, Terraform Registry references, GitHub URLs, S3 buckets, and generic Git repositories. For remote sources, always pin versions.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.1"
name = "my-vpc"
cidr = "10.0.0.0/16"
}
Never use unversioned remote modules in production. A breaking change upstream will break your next apply.
Composition patterns
Real infrastructure composes multiple modules. Outputs from one module feed into inputs of another. Terraform resolves the dependency graph automatically.
module "network" {
source = "./modules/vpc"
name = "prod"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]
}
module "database" {
source = "./modules/rds"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
engine = "postgres"
}
module "application" {
source = "./modules/ecs"
vpc_id = module.network.vpc_id
subnet_ids = module.network.public_subnet_ids
db_endpoint = module.database.endpoint
}
The network builds first. Then the database and application proceed in whatever order their dependencies allow.
Guidelines for good modules
- Expose only what callers need. Not every resource attribute belongs in
outputs.tf. - Use sensible defaults. Required variables should be things that genuinely vary between deployments.
- Validate inputs. Use
validationblocks to catch bad values before they hit the provider.
variable "cidr_block" {
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
What comes next
Modules give you reusable components. The final article in this series covers testing and CI/CD pipelines for infrastructure code. You will learn how to validate modules with terraform validate, run integration tests, and set up automated workflows that plan and apply changes safely.