🚀 Day 17: Introduction to Terraform Modules

Welcome to Day 17 of your Terraform learning journey!
Today, you’re going to explore one of the most powerful and essential features of Terraform — modules.
Modules allow you to organize, reuse, and simplify your infrastructure code. Once you understand modules, you’ll be able to build scalable, clean, and production-grade Terraform configurations.
🎯 Today’s Goals
By the end of this lesson, you will be able to:
✅ Understand what Terraform modules are and why they’re so powerful
✅ Learn how modules are structured and organized
✅ Use public modules from the Terraform Registry
✅ Pass data between modules using input variables and outputs
✅ Build your first custom module step by step
📦 What Are Terraform Modules?
In simple terms, a module is just a collection of Terraform files that work together to manage a set of related resources.
Every Terraform configuration — even a simple one — is technically a module called the root module.
Any module that you call or include inside it is called a child module.
🧩 Why Use Modules?
Here’s why modules are essential when your infrastructure grows:
| Benefit | Description |
| Reusability | Define once, use anywhere — just like a function. |
| Organization | Group related resources (e.g., VPC, EC2, S3) neatly. |
| Encapsulation | Hide internal complexity — consumers only see inputs and outputs. |
| Consistency | Enforce best practices and naming conventions. |
| Collaboration | Share your modules across your team or projects easily. |
💡 Think of Modules Like Functions
Terraform modules are similar to programming functions:
| Programming Concept | Terraform Equivalent |
| Function | Module |
| Parameters | Input Variables |
| Return Values | Output Values |
For example, instead of repeating the same EC2 setup in 5 different projects, you can wrap it in a module and just call it wherever you need it.
🏗️ Module Structure
Let’s look at what a module typically looks like.
Basic Module Layout
my-module/
├── main.tf # Main resource definitions
├── variables.tf # Input variable declarations
├── outputs.tf # Output value declarations
├── README.md # Documentation (how to use the module)
└── versions.tf # Provider and version constraints (optional)
Each file has a purpose:
main.tf → defines your resources (e.g., AWS EC2, S3, etc.)
variables.tf → defines configurable inputs (like instance type)
outputs.tf → defines what data you want to expose (like IDs or IPs)
README.md → explains how to use your module (important when sharing)
versions.tf → locks provider versions to prevent breaking changes
Root Module vs Child Module
Here’s how your project might look when using multiple modules:
project/
├── main.tf # Root module (entry point)
├── variables.tf # Root module inputs
├── outputs.tf # Root module outputs
└── modules/
├── vpc/ # Child module 1
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── ec2/ # Child module 2
├── main.tf
├── variables.tf
└── outputs.tf
The root module is your main Terraform directory — the one you run commands in.
The child modules are reusable building blocks stored in the modules/ folder.
📥 Using Modules in Terraform
You can include a module using the module block.
Basic Module Usage
module "module_name" {
source = "./path/to/module" # Location of the module folder
# Provide input variables
variable_name = value
}
🧠 Example: Creating and Using a Simple S3 Module
Let’s create a reusable module that provisions an S3 bucket.
Step 1: Create the Module
Folder: modules/s3-bucket/
main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = var.tags
}
variables.tf
variable "bucket_name" {
description = "Name of the S3 bucket"
type = string
}
variable "tags" {
description = "Tags for the S3 bucket"
type = map(string)
default = {}
}
outputs.tf
output "bucket_id" {
description = "The ID of the S3 bucket"
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "The ARN of the S3 bucket"
value = aws_s3_bucket.this.arn
}
Step 2: Use the Module in Root Configuration
main.tf
module "logs_bucket" {
source = "./modules/s3-bucket"
bucket_name = "my-logs-bucket"
tags = {
Purpose = "Logs"
Owner = "DevOps"
}
}
# Access outputs from the module
output "logs_bucket_id" {
value = module.logs_bucket.bucket_id
}
🌍 Different Module Sources
Terraform modules can come from many sources — not just local folders.
1. Local Path
module "vpc" {
source = "./modules/vpc"
}
module "network" {
source = "../shared-modules/network"
}
2. Terraform Registry
You can use official or community modules from registry.terraform.io.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
}
3. GitHub
module "vpc" {
source = "github.com/terraform-aws-modules/terraform-aws-vpc"
}
# Use a specific branch or tag
module "vpc" {
source = "github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.0.0"
}
4. S3 Bucket
module "vpc" {
source = "s3::https://s3.amazonaws.com/mybucket/modules/vpc.zip"
}
📋 Module Input Variables (Passing Data In)
Modules use input variables to accept configuration from the root module.
Example – modules/ec2/variables.tf
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "instance_count" {
description = "Number of instances to launch"
type = number
}
variable "tags" {
description = "Tags for EC2 instances"
type = map(string)
default = {}
}
Using the Module:
module "web_servers" {
source = "./modules/ec2"
# passing the variable values from the root module main.tf
instance_type = "t2.small"
instance_count = 3
tags = {
Environment = "production"
Team = "platform"
Owner = "StackOps"
}
}
📤 Module Outputs — Getting Data Out of a Module
In Terraform, outputs are a way for a module to expose information about the resources it manages.
You can think of outputs like return values from a function in programming.
🧠 What Are Outputs?
When you define a module, it may create multiple resources — for example:
EC2 instances
S3 buckets
VPCs
Load balancers
Sometimes, you want to use information from those resources outside the module (in your root configuration or another module).
That’s where outputs come in.
📦 Outputs let you “export” specific data from a module so that other parts of your Terraform configuration can use it.
⚙️ Output Declaration — The Syntax
To define an output inside a module, use the output block:
output "<name>" {
description = "Description of what this output represents"
value = <expression>
}
Fields explained:
name→ The name you’ll use to reference this output later (e.g.,bucket_arn)description→ Optional but helpful when sharing modulesvalue→ The actual Terraform expression or attribute you want to expose
🧩 Example: S3 Bucket Module Outputs
Let’s look at a simple example.
Imagine you created an S3 bucket module (modules/s3-bucket) like this:
main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = var.tags
}
Now, you might want to expose:
The Bucket ID
The Bucket ARN
outputs.tf
output "bucket_id" {
description = "The ID of the S3 bucket"
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "The ARN (Amazon Resource Name) of the S3 bucket"
value = aws_s3_bucket.this.arn
}
🧱 Using Outputs in the Root Module
Now in your root module (the main Terraform project folder), you use the S3 module:
main.tf
module "logs_bucket" {
source = "./modules/s3-bucket"
bucket_name = "my-logs-bucket"
tags = {
Purpose = "Logs"
}
}
Once Terraform creates the S3 bucket, the module outputs can be accessed using the following syntax:
module.<module_name>.<output_name>
So in this case:
module.logs_bucket.bucket_id
module.logs_bucket.bucket_arn
You can use them in:
Other resource definitions
Other modules
Root-level outputs (to display after apply)
🖥️ Displaying Outputs in the Root Module
If you want Terraform to print those outputs after you run terraform apply, define an output block in your root module too:
output "logs_bucket_id" {
value = module.logs_bucket.bucket_id
}
output "logs_bucket_arn" {
value = module.logs_bucket.bucket_arn
}
When you run terraform apply, Terraform will show something like:
logs_bucket_id = "my-logs-bucket"
logs_bucket_arn = "arn:aws:s3:::my-logs-bucket"
🔁 Real-World Example — Using Outputs Between Modules
Let’s say you have:
A VPC module that creates a VPC and subnets.
An EC2 module that launches instances.
You want the EC2 module to automatically use the subnet IDs from the VPC module.
Step 1: VPC Module Outputs
modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnets" {
value = aws_subnet.public[*].id
}
Step 2: EC2 Module Inputs
modules/ec2/variables.tf
variable "subnet_ids" {
description = "List of subnet IDs where EC2 instances will be launched"
type = list(string)
}
Step 3: Pass Output Data Between Modules
Now in your root module:
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
}
module "ec2" {
source = "./modules/ec2"
subnet_ids = module.vpc.public_subnets # 👈 Using output from another module
}
Terraform will automatically pass the list of subnet IDs from the VPC module’s outputs into the EC2 module’s inputs.
🧪 Hands-On Lab: Build Your First Module
Let’s create a reusable VPC module!
Step 1: Create Project Structure
mkdir terraform-modules-lab
cd terraform-modules-lab
mkdir -p modules/vpc
Step 2: Create VPC Module
modules/vpc/variables.tf:
variable "vpc_name" {
description = "Name of the VPC"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "Availability zones"
type = list(string)
}
variable "public_subnets" {
description = "Public subnet CIDR blocks"
type = list(string)
}
variable "private_subnets" {
description = "Private subnet CIDR blocks"
type = list(string)
default = []
}
variable "enable_nat_gateway" {
description = "Enable NAT gateway"
type = bool
default = false
}
variable "tags" {
description = "Additional tags"
type = map(string)
default = {}
}
modules/vpc/main.tf:
# VPC
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(
var.tags,
{ Name = var.vpc_name }
)
}
# Internet Gateway
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(
var.tags,
{ Name = "${var.vpc_name}-igw" }
)
}
# Public Subnets
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]
map_public_ip_on_launch = true
tags = merge(
var.tags,
{
Name = "${var.vpc_name}-public-${count.index + 1}"
Type = "Public"
}
)
}
# Private Subnets
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]
tags = merge(
var.tags,
{
Name = "${var.vpc_name}-private-${count.index + 1}"
Type = "Private"
}
)
}
# 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.vpc_name}-public-rt" }
)
}
# Public Route Table Associations
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
}
# NAT Gateway (conditional)
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? length(var.azs) : 0
domain = "vpc"
tags = merge(
var.tags,
{ Name = "${var.vpc_name}-nat-eip-${count.index + 1}" }
)
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? length(var.azs) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(
var.tags,
{ Name = "${var.vpc_name}-nat-${count.index + 1}" }
)
}
# Private Route Tables (if NAT enabled)
resource "aws_route_table" "private" {
count = var.enable_nat_gateway ? length(var.azs) : 0
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.vpc_name}-private-rt-${count.index + 1}" }
)
}
# Private Route Table Associations
resource "aws_route_table_association" "private" {
count = var.enable_nat_gateway ? length(aws_subnet.private) : 0
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 = "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 "igw_id" {
description = "Internet Gateway ID"
value = aws_internet_gateway.this.id
}
output "nat_gateway_ids" {
description = "NAT Gateway IDs"
value = aws_nat_gateway.this[*].id
}
output "public_route_table_id" {
description = "Public route table ID"
value = aws_route_table.public.id
}
output "private_route_table_ids" {
description = "Private route table IDs"
value = aws_route_table.private[*].id
}
Step 3: Use the Module
Create root module files:
main.tf:
data "aws_availability_zones" "available" {
state = "available"
}
# Development VPC
module "dev_vpc" {
source = "./modules/vpc"
vpc_name = "dev-vpc"
vpc_cidr = "10.0.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 2)
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
enable_nat_gateway = false
tags = {
Environment = "development"
Project = "modules-lab"
Owner = "StackOps"
}
}
# Production VPC
module "prod_vpc" {
source = "./modules/vpc"
vpc_name = "prod-vpc"
vpc_cidr = "172.16.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
public_subnets = ["172.16.1.0/24", "172.16.2.0/24", "172.16.3.0/24"]
private_subnets = ["172.16.11.0/24", "172.16.12.0/24", "172.16.13.0/24"]
enable_nat_gateway = true
tags = {
Environment = "production"
Project = "modules-lab"
Owner = "StackOps"
}
}
variables.tf:
variable "aws_region" {
type = string
default = "us-east-1"
}
outputs.tf:
output "dev_vpc_info" {
description = "Development VPC information"
value = {
vpc_id = module.dev_vpc.vpc_id
public_subnet_ids = module.dev_vpc.public_subnet_ids
private_subnet_ids = module.dev_vpc.private_subnet_ids
}
}
output "prod_vpc_info" {
description = "Production VPC information"
value = {
vpc_id = module.prod_vpc.vpc_id
public_subnet_ids = module.prod_vpc.public_subnet_ids
private_subnet_ids = module.prod_vpc.private_subnet_ids
nat_gateway_ids = module.prod_vpc.nat_gateway_ids
}
}
providers.tf:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Step 4: Deploy
# Initialize (downloads module dependencies)
terraform fmt
terraform init
# Plan
terraform plan
# Apply
terraform apply
# View outputs
terraform output
# Destroy
terraform destroy -auto-approve
🔗 Module Best Practices
✅ DO:
Keep modules focused - One purpose per module
Use clear naming - Descriptive variable names
Document everything - README, variable descriptions
Version your modules - Use tags/releases
Provide defaults - Sensible default values
Use outputs - Expose useful information
❌ DON’T:
Don’t hardcode values - Use variables
Don’t create god modules - Keep them small
Don’t skip documentation
Don’t ignore versioning
📝 Summary
Today you learned:
✅ What modules are and their benefits
✅ Module structure and organization
✅ Module sources (local, registry, git)
✅ Input variables and outputs
✅ Creating and using custom modules
✅ Module best practices
🚀 Tomorrow’s Preview
Day 18: Creating Your Own Reusable Modules
Tomorrow we’ll:
Design production-ready modules
Handle optional resources
Create module composition patterns
Implement module testing
Publish modules to registry
Happy Learning! 🎉
Thanks For Reading, Follow Me For More
Subscribe youtube channel for the recap video
Have a great day!..
← Day 16: Dynamic Blocks | Day 18: Custom Modules →
Remember*: Modules are the foundation of scalable, maintainable infrastructure!*



