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)

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen February 19, 2025

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment