Copy-pasting Terraform code leads to drift, bugs, and maintenance nightmares. Modules encapsulate infrastructure components into reusable, testable, versioned units. In this lesson, you'll create your own modules, use modules from the Terraform Registry, and share infrastructure components across your organization.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Create reusable Terraform modules
- Define module inputs (variables) and outputs
- Use modules from the Terraform Registry
- Version modules for different environments
- Write module documentation
- Test modules with terratest
2. Why This Matters
Real-world scenario: Your team manages 50 AWS VPCs. Without modules, each VPC has 200 lines of duplicated code. With modules, you write the VPC once and reuse it everywhere. Changes propagate to all VPCs automatically.
3. Core Concepts
Module Structure
# Module directory structure
modules/
├── vpc/
│ ├── main.tf # Main resource definitions
│ ├── variables.tf # Input variables
│ ├── outputs.tf # Output values
│ ├── versions.tf # Terraform and provider versions
│ ├── README.md # Documentation
│ └── examples/ # Example usage
├── ec2/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── rds/
├── main.tf
├── variables.tf
└── outputs.tf
Module directory structure
Creating a VPC Module
# modules/vpc/variables.tf
variable "name" {
description = "Name of the VPC"
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)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "public_subnet_count" {
description = "Number of public subnets"
type = number
default = 3
}
variable "private_subnet_count" {
description = "Number of private subnets"
type = number
default = 3
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = var.name
})
}
# Public subnets
resource "aws_subnet" "public" {
count = var.public_subnet_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = var.availability_zones[count.index % length(var.availability_zones)]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${count.index + 1}"
Type = "Public"
})
}
# Private subnets
resource "aws_subnet" "private" {
count = var.private_subnet_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index + 10)
availability_zone = var.availability_zones[count.index % length(var.availability_zones)]
tags = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
Type = "Private"
})
}
# Internet Gateway
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, {
Name = var.name
})
}
# Public route table
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
}
tags = merge(var.tags, {
Name = "${var.name}-public"
})
}
# Public route table associations
resource "aws_route_table_association" "public" {
count = var.public_subnet_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
count = var.public_subnet_count
domain = "vpc"
tags = merge(var.tags, {
Name = "${var.name}-nat-${count.index + 1}"
})
}
# NAT Gateway (one per public subnet)
resource "aws_nat_gateway" "this" {
count = var.public_subnet_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(var.tags, {
Name = "${var.name}-nat-${count.index + 1}"
})
depends_on = [aws_internet_gateway.this]
}
# Private route table for each AZ
resource "aws_route_table" "private" {
count = var.private_subnet_count
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[count.index].id
}
tags = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
})
}
# Private route table associations
resource "aws_route_table_association" "private" {
count = var.private_subnet_count
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of private subnets"
value = aws_subnet.private[*].id
}
output "public_subnet_cidrs" {
description = "CIDR blocks of public subnets"
value = aws_subnet.public[*].cidr_block
}
output "private_subnet_cidrs" {
description = "CIDR blocks of private subnets"
value = aws_subnet.private[*].cidr_block
}
output "nat_gateway_ids" {
description = "IDs of NAT gateways"
value = aws_nat_gateway.this[*].id
}
Complete VPC module
Using Modules
# main.tf - Using the VPC module
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Using local module
module "production_vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
tags = {
Environment = "production"
ManagedBy = "Terraform"
}
}
module "staging_vpc" {
source = "./modules/vpc"
name = "staging"
cidr_block = "10.1.0.0/16"
tags = {
Environment = "staging"
ManagedBy = "Terraform"
}
}
# Using EC2 instance in the VPC
module "web_server" {
source = "./modules/ec2"
instance_type = "t3.micro"
subnet_id = module.production_vpc.public_subnet_ids[0]
security_group_ids = [aws_security_group.web.id]
tags = {
Name = "web-server"
}
}
# Outputs from modules
output "production_vpc_id" {
value = module.production_vpc.vpc_id
}
output "production_public_subnets" {
value = module.production_vpc.public_subnet_ids
}
Using local modules
Terraform Registry Modules
# Using modules from Terraform Registry
# AWS VPC module (official)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
enable_vpn_gateway = true
tags = {
Environment = "dev"
Terraform = "true"
}
}
# AWS EC2 module
module "ec2_instance" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "5.0.0"
name = "web-server"
instance_type = "t3.micro"
key_name = "my-key"
monitoring = true
vpc_security_group_ids = [module.vpc.default_security_group_id]
subnet_id = module.vpc.public_subnets[0]
tags = {
Name = "web-server"
}
}
# AWS RDS module
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = "6.0.0"
identifier = "my-postgres"
engine = "postgres"
engine_version = "15.3"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
db_name = "myapp"
username = "dbuser"
password = random_password.db_password.result
port = 5432
vpc_security_group_ids = [module.vpc.default_security_group_id]
db_subnet_group_name = module.vpc.database_subnet_group
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
backup_retention_period = 7
tags = {
Environment = "production"
}
}
# Random password for database
resource "random_password" "db_password" {
length = 16
special = false
}
Using Terraform Registry modules
Module Versioning
# Module versioning best practices
# Pin to exact version
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0" # Exact version
}
# Use major version only (gets latest minor/patch)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # Any 5.x version
}
# Git repository as source
module "my_module" {
source = "git::https://github.com/mycompany/terraform-modules.git//modules/vpc?ref=v1.2.0"
}
# Git with SSH
module "my_module" {
source = "git::git@github.com:mycompany/terraform-modules.git//modules/vpc?ref=main"
}
# Private GitHub repository (with token)
module "my_module" {
source = "github.com/mycompany/private-modules//vpc?ref=v1.0.0&access_token=${var.github_token}"
}
# S3 bucket (for private modules)
module "my_module" {
source = "s3::https://s3.amazonaws.com/my-bucket/modules/vpc.zip"
}
# Terraform Cloud private registry
module "my_module" {
source = "app.terraform.io/my-company/vpc/aws"
version = "1.2.0"
}
Module versioning and sources
Module Documentation
# modules/vpc/README.md
# AWS VPC Module
This module creates a VPC with public and private subnets, NAT gateways, and internet gateway.
## Usage
```hcl
module "vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
tags = {
Environment = "production"
}
}
```
## Requirements
| Name | Version |
|------|---------|
| terraform | >= 1.0 |
| aws | >= 4.0 |
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| name | Name of the VPC | `string` | n/a | yes |
| cidr_block | CIDR block for the VPC | `string` | `"10.0.0.0/16"` | no |
| availability_zones | List of availability zones | `list(string)` | `["us-east-1a", "us-east-1b", "us-east-1c"]` | no |
| public_subnet_count | Number of public subnets | `number` | `3` | no |
| private_subnet_count | Number of private subnets | `number` | `3` | no |
| tags | Tags to apply to resources | `map(string)` | `{}` | no |
## Outputs
| Name | Description |
|------|-------------|
| vpc_id | ID of the VPC |
| public_subnet_ids | IDs of public subnets |
| private_subnet_ids | IDs of private subnets |
## Examples
See the `examples/` directory for complete examples.
## License
MIT
Module documentation with terraform-docs format
4. Complete Project: Production-Ready Module
# Complete production-ready EC2 module
# modules/ec2/variables.tf
variable "name" {
description = "Name prefix for resources"
type = string
}
variable "instance_count" {
description = "Number of EC2 instances to create"
type = number
default = 1
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "ami_id" {
description = "AMI ID for instances"
type = string
default = null
}
variable "subnet_ids" {
description = "List of subnet IDs to launch instances in"
type = list(string)
}
variable "security_group_ids" {
description = "List of security group IDs"
type = list(string)
default = []
}
variable "key_name" {
description = "SSH key name"
type = string
default = null
}
variable "user_data" {
description = "User data script"
type = string
default = null
}
variable "root_volume_size" {
description = "Root volume size in GB"
type = number
default = 20
}
variable "root_volume_type" {
description = "Root volume type"
type = string
default = "gp3"
}
variable "additional_volumes" {
description = "Additional EBS volumes"
type = list(object({
size = number
type = string
device_name = string
encrypted = bool
delete_on_termination = bool
}))
default = []
}
variable "associate_public_ip" {
description = "Associate public IP address"
type = bool
default = false
}
variable "monitoring" {
description = "Enable detailed monitoring"
type = bool
default = false
}
variable "tags" {
description = "Tags to apply to instances"
type = map(string)
default = {}
}
# modules/ec2/main.tf
# Get latest Amazon Linux 2 AMI if not specified
data "aws_ami" "amazon_linux" {
count = var.ami_id == null ? 1 : 0
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
locals {
ami_id = var.ami_id != null ? var.ami_id : data.aws_ami.amazon_linux[0].id
}
# EC2 instances
resource "aws_instance" "this" {
count = var.instance_count
ami = local.ami_id
instance_type = var.instance_type
key_name = var.key_name
subnet_id = var.subnet_ids[count.index % length(var.subnet_ids)]
vpc_security_group_ids = var.security_group_ids
user_data = var.user_data
associate_public_ip_address = var.associate_public_ip
monitoring = var.monitoring
root_block_device {
volume_size = var.root_volume_size
volume_type = var.root_volume_type
encrypted = true
tags = merge(var.tags, {
Name = "${var.name}-root-${count.index + 1}"
})
}
dynamic "ebs_block_device" {
for_each = var.additional_volumes
content {
device_name = ebs_block_device.value.device_name
volume_size = ebs_block_device.value.size
volume_type = ebs_block_device.value.type
encrypted = ebs_block_device.value.encrypted
delete_on_termination = ebs_block_device.value.delete_on_termination
}
}
tags = merge(var.tags, {
Name = "${var.name}-${count.index + 1}"
})
lifecycle {
create_before_destroy = true
}
}
# modules/ec2/outputs.tf
output "instance_ids" {
description = "IDs of EC2 instances"
value = aws_instance.this[*].id
}
output "instance_public_ips" {
description = "Public IP addresses of instances"
value = aws_instance.this[*].public_ip
}
output "instance_private_ips" {
description = "Private IP addresses of instances"
value = aws_instance.this[*].private_ip
}
output "instance_arn" {
description = "ARN of instances"
value = aws_instance.this[*].arn
}
# Usage example
data "aws_subnets" "public" {
filter {
name = "vpc-id"
values = [module.vpc.vpc_id]
}
tags = {
Type = "Public"
}
}
module "web_servers" {
source = "./modules/ec2"
name = "web-server"
instance_count = 3
instance_type = "t3.medium"
subnet_ids = data.aws_subnets.public.ids
security_group_ids = [aws_security_group.web.id]
key_name = "devops-key"
associate_public_ip = true
monitoring = true
user_data = templatefile("${path.module}/user_data.sh", {
environment = "production"
})
root_volume_size = 30
additional_volumes = [
{
size = 100
type = "gp3"
device_name = "/dev/sdb"
encrypted = true
delete_on_termination = true
}
]
tags = {
Environment = "production"
Terraform = "true"
Service = "web"
}
}
Production-ready EC2 module
5. Common Errors & Solutions
6. Summary Checklist
7. Next Steps
Next lesson: Terraform Lesson 8.4: Advanced Features (count, for_each, dynamic)
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment