Chapter 7: Terraform Modules#
π― Learning Objectives#
- Understand what modules are and why they’re important
- Create and use local modules
- Publish and consume modules from the Terraform Registry
- Design reusable modules with proper interfaces
- Implement module versioning and testing
- Understand module composition and nesting
7.1 What are Modules?#
Modules are self-contained packages of Terraform configurations that are managed as a group. They’re the primary way to package and reuse infrastructure code.
Module Structure#
modules/
βββ ec2-instance/
βββ main.tf # Resources
βββ variables.tf # Input variables (interface)
βββ outputs.tf # Output values (return values)
βββ README.md # Documentation (for published modules)Root Module vs Child Modules#
- Root module: The current working directory with your
.tffiles - Child module: A module called from within another module
project/
βββ main.tf # Root module
βββ variables.tf
βββ outputs.tf
βββ modules/
βββ webserver/ # Child module (local)
βββ main.tf
βββ variables.tf
βββ outputs.tf7.2 Creating a Module#
Step 1: Define the Module Interface#
# modules/ec2-instance/variables.tf
variable "name" {
description = "Name of the EC2 instance"
type = string
}
variable "ami" {
description = "AMI ID"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "subnet_id" {
description = "Subnet ID"
type = string
}
variable "security_group_ids" {
description = "Security group IDs"
type = list(string)
default = []
}
variable "user_data" {
description = "User data script"
type = string
default = null
}
variable "tags" {
description = "Tags to apply"
type = map(string)
default = {}
}
variable "root_volume_size" {
description = "Root EBS volume size in GB"
type = number
default = 20
}Step 2: Implement Module Resources#
# modules/ec2-instance/main.tf
resource "aws_instance" "this" {
ami = var.ami
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.security_group_ids
user_data = var.user_data
root_block_device {
volume_size = var.root_volume_size
volume_type = "gp3"
}
tags = merge(var.tags, {
Name = var.name
})
}Step 3: Define Module Outputs#
# modules/ec2-instance/outputs.tf
output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.this.id
}
output "instance_arn" {
description = "ARN of the EC2 instance"
value = aws_instance.this.arn
}
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
}
output "availability_zone" {
description = "Availability Zone"
value = aws_instance.this.availability_zone
}Step 4: Document the Module#
# modules/ec2-instance/README.md
# EC2 Instance Module
## Usage
```hcl
module "web_server" {
source = "./modules/ec2-instance"
name = "web-server"
ami = "ami-0c55b159cbfafe1f0"
subnet_id = "subnet-12345678"
instance_type = "t2.micro"
security_group_ids = ["sg-12345678"]
}Inputs#
| Name | Description | Type | Default |
|---|---|---|---|
| name | Instance name | string |
n/a |
| ami | AMI ID | string |
n/a |
| instance_type | Instance type | string |
"t2.micro" |
| … | … | … | … |
Outputs#
| Name | Description |
|---|---|
| instance_id | EC2 instance ID |
| public_ip | Public IP address |
7.3 Using Local Modules#
# main.tf (root module)
module "web_server" {
source = "./modules/ec2-instance"
# β Path to module directory (relative or absolute)
name = "web-server"
ami = data.aws_ami.ubuntu.id
subnet_id = aws_subnet.public[0].id
instance_type = "t2.micro"
security_group_ids = [aws_security_group.web.id]
tags = {
Environment = "dev"
ManagedBy = "Terraform"
}
}
module "app_server" {
source = "./modules/ec2-instance"
name = "app-server"
ami = data.aws_ami.ubuntu.id
subnet_id = aws_subnet.private[0].id
instance_type = "t3.small"
}
# Use module outputs
output "web_public_ip" {
value = module.web_server.public_ip
}7.4 Using Modules from the Terraform Registry#
The Terraform Registry (registry.terraform.io) hosts thousands of pre-built modules.
# VPC module from the registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# β Namespace / Module Name / Provider
version = "5.5.0"
# β Version constraint (required for registry modules)
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 = false
tags = {
Environment = "dev"
Terraform = "true"
}
}
# Security group module
module "web_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "5.1.0"
name = "web-sg"
description = "Security group for web servers"
vpc_id = module.vpc.vpc_id
ingress_with_cidr_blocks = [
{
from_port = 80
to_port = 80
protocol = "tcp"
description = "HTTP"
cidr_blocks = "0.0.0.0/0"
},
{
from_port = 443
to_port = 443
protocol = "tcp"
description = "HTTPS"
cidr_blocks = "0.0.0.0/0"
}
]
egress_rules = ["all-all"]
}Finding Registry Modules#
# Search for modules in the registry
# Visit: https://registry.terraform.io/
# Or search from CLI:
terraform login # Authenticate
terraform init # Downloads modules from registryModule Source Formats (Exam Critical)#
| Source | Syntax | Example |
|---|---|---|
| Local path | Path string | "./modules/vpc" |
| Terraform Registry | namespace/name/provider |
"hashicorp/consul/aws" |
| GitHub | GitHub URL | "github.com/hashicorp/example" |
| Git generic | Git URL | "git::https://example.com/repo.git" |
| HTTP | HTTP URL | "https://example.com/module.zip" |
| S3 | S3 bucket | "s3::https://s3-eu-west-1.amazonaws.com/example" |
| GCS | GCS bucket | "gcs::https://www.googleapis.com/storage/v1/example" |
7.5 Module Versioning#
# Version constraints for registry modules
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# Version constraints
version = "5.5.0" # Exact version
# version = "~> 5.0" # >= 5.0, < 6.0
# version = ">= 5.0, < 5.5" # Range
# version = "~> 5.4.0" # >= 5.4.0, < 5.5.0
}Module Version File#
# versions.tf inside module
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0, < 6.0"
}
}
}7.6 Module Composition#
Module Within Module#
modules/
βββ vpc/ # VPC module
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ security/ # Security module (uses VPC module)
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ webserver/ # Web server module (uses VPC + Security modules)
βββ main.tf
βββ variables.tf
βββ outputs.tf# modules/webserver/main.tf
module "vpc" {
source = "../vpc"
name = var.name
cidr = var.vpc_cidr
azs = var.azs
}
module "security" {
source = "../security"
name = var.name
vpc_id = module.vpc.vpc_id
}
resource "aws_instance" "web" {
ami = var.ami
instance_type = var.instance_type
subnet_id = module.vpc.public_subnet_ids[0]
vpc_security_group_ids = [module.security.web_sg_id]
}7.7 Module Best Practices#
1. Keep Modules Focused#
# GOOD: One responsibility per module
module "vpc" {
source = "./modules/vpc"
# Only creates VPC-related resources
}
module "database" {
source = "./modules/database"
# Only creates database resources
}
# BAD: Module does everything
module "everything" {
source = "./modules/everything"
# Creates VPC, instances, databases, monitoring...
}2. Design Clear Interfaces#
# GOOD: Minimal, clear interface
module "ec2_instance" {
source = "./modules/ec2-instance"
name = var.name
ami = var.ami
subnet_id = var.subnet_id
# Sensible defaults for optional parameters
instance_type = var.environment == "production" ? "t3.large" : "t2.micro"
root_volume_size = 20
}
# BAD: Interface that exposes internal implementation
module "ec2_instance" {
source = "./modules/ec2-instance"
aws_inst_ami_123 = var.ami # β Internal naming
instance_ebs_volumes = [ # β Exposing internal structure
{ device = "/dev/xvda", size = 20 }
]
}3. Provide Sensible Defaults#
# Module with good defaults
variable "instance_type" {
type = string
default = "t2.micro"
}
variable "monitoring" {
type = bool
default = false
}
variable "backup_retention" {
type = number
default = 7
}4. Document Everything#
# Every variable needs a description
variable "instance_count" {
description = "Number of EC2 instances to create"
type = number
default = 1
}
# Every output needs a description
output "instance_ids" {
description = "IDs of the created EC2 instances"
value = aws_instance.this[*].id
}5. Use for_each for Flexible Resource Creation#
# Allow users to pass multiple configurations
variable "additional_security_group_rules" {
description = "Additional security group rules"
type = list(object({
type = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = []
}
resource "aws_security_group_rule" "additional" {
for_each = { for idx, rule in var.additional_security_group_rules : idx => rule }
type = each.value.type
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
description = each.value.description
security_group_id = aws_security_group.this.id
}7.8 Module Testing#
Basic Validation#
# Validate module syntax
cd modules/ec2-instance
terraform init
terraform validateUsing terraform test (Terraform 1.6+)#
# modules/ec2-instance/tests/basic.tftest.hcl
run "basic_test" {
# Define module variables
variables {
name = "test-instance"
ami = "ami-0c55b159cbfafe1f0"
subnet_id = "subnet-12345678"
}
# Assert outputs
assert {
condition = output.instance_id != ""
error_message = "Instance ID should not be empty"
}
assert {
condition = can(regex("^i-", output.instance_id))
error_message = "Instance ID should start with i-"
}
}
run "development_test" {
variables {
name = "dev-instance"
ami = "ami-0c55b159cbfafe1f0"
subnet_id = "subnet-12345678"
instance_type = "t2.nano" # Cheaper for dev
}
assert {
condition = output.instance_type == "t2.nano"
error_message = "Instance type should be t2.nano for dev"
}
}# Run tests
cd modules/ec2-instance
terraform test7.9 Module Publishing#
Publishing to Terraform Registry#
# terraform-aws-ec2-instance/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0"
}
}
}Module Repository Requirements#
| Requirement | Details |
|---|---|
| Repository name | terraform-<PROVIDER>-<NAME> (e.g., terraform-aws-ec2-instance) |
main.tf |
Main resources |
variables.tf |
Input variables with descriptions |
outputs.tf |
Output values with descriptions |
README.md |
Documentation and usage examples |
| Version tags | Semantic versioning (e.g., v1.0.0) |
Versioning Convention#
# Tag versions in Git
git tag v1.0.0
git push origin v1.0.0
git tag v1.1.0
git push origin v1.1.0
git tag v2.0.0 # Breaking changes
git push origin v2.0.07.10 Module Composition Patterns#
Pattern 1: Wrapper Module#
# modules/complete-infra/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "${var.environment}-vpc"
cidr = var.vpc_cidr
azs = var.availability_zones
public_subnets = var.public_subnet_cidrs
private_subnets = var.private_subnet_cidrs
enable_nat_gateway = var.environment == "production"
}
module "ec2" {
source = "../ec2-instance"
name = "${var.environment}-web"
ami = var.ami
subnet_id = module.vpc.public_subnets[0]
instance_type = var.instance_type
}Pattern 2: Infrastructure Module#
# modules/web-app/main.tf
module "load_balancer" {
source = "terraform-aws-modules/alb/aws"
version = "9.0.0"
name = "${var.environment}-alb"
vpc_id = var.vpc_id
subnets = var.public_subnet_ids
security_group_ingress_rules = {
"http" = {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
module "auto_scaling" {
source = "../auto-scaling-group"
name = "${var.environment}-asg"
vpc_id = var.vpc_id
subnet_ids = var.private_subnet_ids
target_group_arns = [module.load_balancer.target_group_arns[0]]
instance_type = var.instance_type
min_size = var.min_size
max_size = var.max_size
}π Exam Tips#
- Module source determines where to find the module β Local path, registry, Git, etc.
- Registry modules require a version constraint
- Modules have their own
terraformandproviderblocks β Especiallyrequired_providers - Module outputs can be referenced as
module.MODULE_NAME.OUTPUT_NAME - Input variables define the module interface β Document them well
- Outputs define what information the module returns
for_eachin modules (Terraform 0.13+) allows creating multiple module instances- Version constraints use the same syntax as providers
- Module composition β modules can use other modules (nesting)
- Terraform Registry β Use
namespace/name/providerformat - README is essential for published modules
- Semantic versioning β Use tags like
v1.0.0,v1.1.0,v2.0.0 terraform getdownloads modules,terraform initdoes both.terraform/modules/contains downloaded module code- Module source can be Git, S3, GCS, HTTP, etc.
β Chapter 7 Quiz#
-
Which module source format is used for the Terraform Registry?
- a)
namespace/name/provider - b)
provider/name/namespace - c)
hashicorp/consul - d)
terraform-aws-vpc
- a)
-
How do you reference an output from a module named “vpc”?
- a)
vpc.output.vpc_id - b)
module.vpc.vpc_id - c)
output.vpc.vpc_id - d)
vpc.module.vpc_id
- a)
-
True or False: Modules can contain other modules.
-
What is the purpose of module outputs?
- a) To display information in the console
- b) To return values from a module for use in other parts of the config
- c) To log module execution
- d) To send data to CloudWatch
-
Which command downloads modules referenced in configuration?
- a)
terraform get - b)
terraform module - c)
terraform download - d)
terraform fetch
- a)
π Answers
- a β
namespace/name/provider(e.g.,terraform-aws-modules/vpc/aws) - b β
module.MODULE_NAME.OUTPUT_NAME - True β Modules can compose other modules (module nesting)
- b β Outputs return values from a module for use elsewhere
- a β
terraform getdownloads modules (also done byterraform init)
π See Modules in Action: Explore how modules are used in real-world projects like the VPC Module, ECS Fargate, EKS Cluster, and the Production-Ready Example.
Continue to β Chapter 8: Workspaces & Environments