Skip to main content

Command Palette

Search for a command to run...

🚀 Day 17: Introduction to Terraform Modules

Updated
12 min read
🚀 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:

BenefitDescription
ReusabilityDefine once, use anywhere — just like a function.
OrganizationGroup related resources (e.g., VPC, EC2, S3) neatly.
EncapsulationHide internal complexity — consumers only see inputs and outputs.
ConsistencyEnforce best practices and naming conventions.
CollaborationShare your modules across your team or projects easily.

💡 Think of Modules Like Functions

Terraform modules are similar to programming functions:

Programming ConceptTerraform Equivalent
FunctionModule
ParametersInput Variables
Return ValuesOutput 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 modules

  • value → 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:

  1. Keep modules focused - One purpose per module

  2. Use clear naming - Descriptive variable names

  3. Document everything - README, variable descriptions

  4. Version your modules - Use tags/releases

  5. Provide defaults - Sensible default values

  6. Use outputs - Expose useful information

DON’T:

  1. Don’t hardcode values - Use variables

  2. Don’t create god modules - Keep them small

  3. Don’t skip documentation

  4. 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!*

More from this blog

S

StackOps - Diary

33 posts

Welcome to the StackOps - Diary. We’re dedicated to empowering the tech community. We delve into cloud-native and microservices technologies, sharing knowledge to build modern, scalable solutions.