Modules
Create reusable infrastructure components with Terraform modules
Modules
In the previous tutorial, we learned expressions and functions ā making configs dynamic and smart. Now let's fix the biggest problem in Terraform projects: copy-pasting code everywhere.
Copy-pasting Terraform code between projects? Stop. Please. Modules let you package infrastructure into reusable components. Define it once, use it everywhere. It's like writing a function instead of copying the same 50 lines all over the place.
What's a Module?
"Is a module some complicated thing I have to learn?"
Nope. A module is just a directory with Terraform files. That's it. Seriously.
modules/
āāā ec2-instance/
āāā main.tf
āāā variables.tf
āāā outputs.tf
Your root configuration is also a module ā the "root module." You've been using modules this whole time without knowing it. How's that for a plot twist?
Your First Module
Let's create a reusable EC2 instance module. Once this exists, spinning up a new server takes about 5 lines.
Module Structure
modules/ec2-instance/
āāā main.tf # Resources
āāā variables.tf # Inputs
āāā outputs.tf # Outputs
variables.tf
variable "name" {
description = "Name for the instance"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "ami_id" {
description = "AMI ID to use"
type = string
}
variable "subnet_id" {
description = "Subnet to launch in"
type = string
}
variable "security_group_ids" {
description = "Security groups to attach"
type = list(string)
default = []
}
variable "tags" {
description = "Additional tags"
type = map(string)
default = {}
}
main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.security_group_ids
tags = merge(var.tags, {
Name = var.name
})
}
outputs.tf
output "id" {
description = "Instance ID"
value = aws_instance.this.id
}
output "public_ip" {
description = "Public IP address"
value = aws_instance.this.public_ip
}
output "private_ip" {
description = "Private IP address"
value = aws_instance.this.private_ip
}
Using a Module
"Great, I built a module. Now how do I use it?"
Local Module
module "web_server" {
source = "./modules/ec2-instance"
name = "web-server"
instance_type = "t2.small"
ami_id = data.aws_ami.ubuntu.id
subnet_id = aws_subnet.public.id
security_group_ids = [aws_security_group.web.id]
tags = {
Environment = "production"
Team = "platform"
}
}
# Access module outputs
output "web_server_ip" {
value = module.web_server.public_ip
}
Multiple Instances
"What if I need 3 web servers from the same module?"
Combine with for_each:
module "web_servers" {
source = "./modules/ec2-instance"
for_each = toset(["web-1", "web-2", "web-3"])
name = each.key
ami_id = data.aws_ami.ubuntu.id
subnet_id = aws_subnet.public.id
security_group_ids = [aws_security_group.web.id]
}
# All IPs
output "web_ips" {
value = {for name, instance in module.web_servers : name => instance.public_ip}
}
Module Sources
Modules can come from anywhere ā your laptop, GitHub, the Terraform Registry, even an S3 bucket.
Local Path
module "vpc" {
source = "./modules/vpc"
}
module "shared" {
source = "../shared-modules/database"
}
Git Repository
module "vpc" {
source = "git::https://github.com/your-org/terraform-modules.git//vpc"
}
# Specific branch
module "vpc" {
source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=main"
}
# Specific tag
module "vpc" {
source = "git::https://github.com/your-org/terraform-modules.git//vpc?ref=v1.2.0"
}
# SSH
module "vpc" {
source = "git@github.com:your-org/terraform-modules.git//vpc?ref=v1.2.0"
}
Terraform Registry
"Isn't there a library of pre-built modules?"
Yes! The Terraform Registry has thousands of community modules. Why build a VPC from scratch when someone already did it?
# Public registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
}
That VPC module has been downloaded millions of times. Battle-tested by the community.
# Private registry
module "internal" {
source = "app.terraform.io/your-org/module-name/provider"
version = "1.0.0"
}
S3 Bucket
module "vpc" {
source = "s3::https://s3-us-west-2.amazonaws.com/your-bucket/vpc.zip"
}
Version Constraints
"What if a module update breaks my stuff?"
Pin your versions. Always.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0" # Exact version
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 19.0" # >= 19.0.0 and < 20.0.0
}
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = ">= 5.0, < 6.0" # Range
}
Always pin versions in production. A ~> is usually the right balance ā gets you patches but not breaking changes.
Practical Module: VPC
Let's build something more substantial ā a reusable VPC module with public/private subnets and an optional NAT Gateway.
modules/vpc/variables.tf
variable "name" {
description = "VPC name"
type = string
}
variable "cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "Availability zones"
type = list(string)
}
variable "public_subnets" {
description = "Public subnet CIDRs"
type = list(string)
default = []
}
variable "private_subnets" {
description = "Private subnet CIDRs"
type = list(string)
default = []
}
variable "enable_nat" {
description = "Enable NAT Gateway"
type = bool
default = false
}
variable "tags" {
description = "Tags for all resources"
type = map(string)
default = {}
}
modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = var.name
})
}
resource "aws_internet_gateway" "this" {
count = length(var.public_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
tags = merge(var.tags, {
Name = "${var.name}-igw"
})
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index % length(var.azs)]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${count.index + 1}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.azs[count.index % length(var.azs)]
tags = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
Tier = "private"
})
}
# NAT Gateway (optional)
resource "aws_eip" "nat" {
count = var.enable_nat ? 1 : 0
domain = "vpc"
tags = merge(var.tags, {
Name = "${var.name}-nat-eip"
})
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = merge(var.tags, {
Name = "${var.name}-nat"
})
}
# Route tables
resource "aws_route_table" "public" {
count = length(var.public_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this[0].id
}
tags = merge(var.tags, {
Name = "${var.name}-public-rt"
})
}
resource "aws_route_table" "private" {
count = length(var.private_subnets) > 0 ? 1 : 0
vpc_id = aws_vpc.this.id
dynamic "route" {
for_each = var.enable_nat ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[0].id
}
}
tags = merge(var.tags, {
Name = "${var.name}-private-rt"
})
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnets)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public[0].id
}
resource "aws_route_table_association" "private" {
count = length(var.private_subnets)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[0].id
}
modules/vpc/outputs.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.this.id
}
output "vpc_cidr" {
description = "VPC CIDR block"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "Private subnet IDs"
value = aws_subnet.private[*].id
}
output "nat_gateway_ip" {
description = "NAT Gateway public IP"
value = var.enable_nat ? aws_eip.nat[0].public_ip : null
}
Using the VPC Module
module "vpc" {
source = "./modules/vpc"
name = "production"
cidr = "10.0.0.0/16"
azs = ["us-west-2a", "us-west-2b", "us-west-2c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat = true
tags = {
Environment = "production"
Project = "myapp"
}
}
# Use outputs
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
# ...
}
Module Composition
"Can modules use other modules?"
Absolutely. That's called composition, and it's where things get really powerful.
Nested Modules
modules/
āāā vpc/
āāā security-group/
āāā web-stack/
āāā main.tf # Uses vpc and security-group modules
āāā variables.tf
āāā outputs.tf
# modules/web-stack/main.tf
module "vpc" {
source = "../vpc"
name = var.name
cidr = var.vpc_cidr
# ...
}
module "web_sg" {
source = "../security-group"
vpc_id = module.vpc.vpc_id
# ...
}
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
vpc_security_group_ids = [module.web_sg.id]
# ...
}
Passing Providers
# For multi-region or multi-account
provider "aws" {
alias = "us_east"
region = "us-east-1"
}
provider "aws" {
alias = "us_west"
region = "us-west-2"
}
module "vpc_east" {
source = "./modules/vpc"
providers = {
aws = aws.us_east
}
# ...
}
module "vpc_west" {
source = "./modules/vpc"
providers = {
aws = aws.us_west
}
# ...
}
Module Best Practices
The wisdom section. Learn from other people's tightly-coupled spaghetti modules.
1. Keep It Focused
# Good: Single responsibility
modules/
āāā vpc/
āāā security-group/
āāā ec2-instance/
āāā rds/
# Bad: Kitchen sink
modules/
āāā entire-application/ # Does everything
2. Sensible Defaults
variable "instance_type" {
default = "t2.micro" # Reasonable default
}
variable "enable_monitoring" {
default = true # Secure by default
}
variable "backup_retention" {
default = 7 # Safe default
}
3. Validate Inputs
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_type" {
type = string
validation {
condition = can(regex("^t[23]\\.", var.instance_type))
error_message = "Only t2 or t3 instance types allowed."
}
}
4. Document Everything
"But writing docs is boring."
So is debugging a module with zero documentation at 2 AM.
variable "vpc_cidr" {
description = <<-EOT
CIDR block for the VPC. Must be at least /16 to accommodate
all subnets. Example: 10.0.0.0/16
EOT
type = string
}
5. Consistent Naming
# Resource naming convention
resource "aws_instance" "this" { # "this" for the main resource
# ...
}
# Output naming
output "id" { } # Not "instance_id"
output "arn" { } # Not "instance_arn"
output "security_group_id" { } # Explicit for related resources
Publishing Modules
Terraform Registry Requirements
terraform-<PROVIDER>-<NAME>/
āāā README.md # Required
āāā main.tf # Required
āāā variables.tf # Required
āāā outputs.tf # Required
āāā versions.tf # Provider requirements
āāā examples/
ā āāā simple/
ā āāā main.tf
āāā modules/
āāā submodule/
versions.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0"
}
}
}
What's Next?
You just unlocked the key to scalable Terraform. No more copy-paste, no more snowflake projects. You learned:
- Module structure (just a directory with .tf files!)
- Using local, Git, and registry modules
- Version pinning for safety
- A real VPC module you can actually use
- Best practices that'll save you headaches
Now let's zoom out and talk about project structure ā how to organize all these files for real-world projects. Let's go!