Day 18: Creating Your Own Reusable Modules

I'm a cloud-native enthusiast and tech blogger, sharing insights on Kubernetes, AWS, CI/CD, and Linux across my blog and Facebook page. Passionate about modern infrastructure and microservices, I aim to help others understand and leverage cloud-native technologies for scalable, efficient solutions.
Welcome to Day 18! Today, you’ll take that skill to the next level — by learning how to design and build your own reusable, production-ready Terraform modules that can scale across teams, projects, and environments.
If you want to work like a DevOps or cloud engineer in a real-world team, mastering this topic is absolutely essential.
🎯 Today’s Learning Goals
By the end of this lesson, you’ll be able to:
✅ Design production-quality Terraform modules that follow best practices
✅ Handle optional and conditional resources inside modules
✅ Build modular and composable infrastructure
✅ Make your modules flexible and reusable through inputs, locals, and outputs
✅ Use advanced module design patterns used by large organizations
🎨 Terraform Module Design Principles
When creating reusable modules, always design with clarity, flexibility, and simplicity in mind.
Here are the core principles used by top Terraform practitioners.
1. Single Responsibility Principle
Each module should do one thing well and be easy to understand in isolation.
This is similar to how a function in programming should only have one purpose.
❌ Bad Example — “God Module” (Too Big)
modules/infrastructure/
├── vpc, subnets, instances, databases, load balancers...
This “mega-module” mixes everything — networking, compute, database, and load balancers.
It’s hard to maintain, reuse, or debug because a small change can break unrelated components.
✅ Good Example — Focused Modules
modules/
├── networking/ # Handles VPC, subnets, route tables
├── compute/ # Manages EC2 instances or Auto Scaling Groups
├── database/ # Creates RDS or DynamoDB
└── load-balancer/ # Manages ALB or NLB
Now, each module has a single responsibility, making it:
Easier to test
Easier to reuse
Easier to maintain and version
2. Composable Design — Modules Working Together
Think of modules like Lego blocks — small, independent, and connectable.
Each module should expose outputs that can be consumed by another module.
Example:
# Create a network (VPC and subnets)
module "network" {
source = "./modules/networking"
}
# Compute module (EC2) depends on VPC outputs
module "compute" {
source = "./modules/compute"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
}
# Database module reuses same network data
module "database" {
source = "./modules/database"
vpc_id = module.network.vpc_id
subnet_ids = module.network.database_subnet_ids
}
Here:
module.network→ provides foundational infrastructuremodule.computeandmodule.database→ consume network outputsEach module can be updated independently
3. Sensible Defaults
Always define defaults for common cases so users can use the module with minimal setup.
When building reusable modules, your goal is to make them simple for beginners but flexible for experts.
Example:
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro" # Common low-cost instance
}
variable "monitoring" {
description = "Enable detailed monitoring"
type = bool
default = false # Optional feature, off by default
}
Now, your module can be used without providing extra inputs:
module "web" {
source = "./modules/compute"
}
Or overridden easily when needed:
module "web" {
source = "./modules/compute"
instance_type = "t3.medium"
monitoring = true
}
4. Input Validation — Guard Against Mistakes
Validation helps catch invalid configurations early before Terraform even runs.
Without validation, a user could accidentally input something wrong — like instance_count = 0 — and cause unexpected errors.
Example:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "instance_count" {
type = number
validation {
condition = var.instance_count > 0 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
🧠 Tip:
Validations help prevent “human error” and enforce team-wide standards.
🧩 Advanced Module Patterns
Now let’s dive into advanced design patterns that make modules dynamic, flexible, and smart.
🧱 Pattern 1: Optional Resources (Using count)
Sometimes, you want certain resources (like bastion hosts or ALBs) to be optional — depending on configuration.
Example:
variable "create_bastion" {
description = "Whether to create a bastion host"
type = bool
default = false
}
variable "create_alb" {
description = "Whether to create an Application Load Balancer"
type = bool
default = true
}
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
}
resource "aws_lb" "app" {
count = var.create_alb ? 1 : 0
name = "${var.name}-alb"
load_balancer_type = "application"
}
This makes your module conditional — creating resources only when needed.
🚩 Pattern 2: Feature Flags (Turn Features On or Off)
Feature flags allow users to toggle specific features of your module without changing the logic.
Example:
variable "features" {
description = "Feature toggles for the module"
type = object({
auto_scaling = bool
monitoring = bool
backup = bool
})
default = {
auto_scaling = false
monitoring = false
backup = false
}
}
resource "aws_autoscaling_group" "this" {
count = var.features.auto_scaling ? 1 : 0
# ...
}
resource "aws_cloudwatch_dashboard" "this" {
count = var.features.monitoring ? 1 : 0
# ...
}
Users can now control features easily:
module "app" {
source = "./modules/app"
features = {
auto_scaling = true
monitoring = true
backup = false
}
}
🏗️ Pattern 3: Environment-Specific Behavior
Use environment names (
dev,staging,prod) to dynamically change resource settings.
This is one of the most realistic and powerful Terraform techniques — commonly used in enterprise setups.
Example:
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
}
locals {
is_production = var.environment == "prod"
instance_config = {
dev = {
type = "t2.micro"
count = 1
}
staging = {
type = "t2.small"
count = 2
}
prod = {
type = "t3.medium"
count = 3
}
}
selected_config = local.instance_config[var.environment]
}
resource "aws_instance" "app" {
count = local.selected_config.count
instance_type = local.selected_config.type
monitoring = local.is_production
}
✅ Result:
Dev → 1 small instance
Staging → 2 medium instances
Prod → 3 large instances with monitoring enabled
This makes a single module automatically adapt to different environments.
🏷️ Pattern 4: Flexible Tagging System
Consistent tagging helps with cost tracking, ownership, and automation.
You can merge “standard tags” with user-provided ones.
Example:
variable "tags" {
description = "Additional custom tags"
type = map(string)
default = {}
}
variable "name" {
description = "Resource name"
type = string
}
locals {
common_tags = merge(
{
Name = var.name
ManagedBy = "Terraform"
Module = "app"
},
var.tags # Allow users to override or add
)
}
resource "aws_instance" "this" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
tags = local.common_tags
}
✅ Resulting Tags Example:
tags = {
Name = "web-server"
ManagedBy = "Terraform"
Module = "app"
Environment = "staging" # Custom user tag
}
🧪 Hands-On Practice (Recommended)
We'll use:
✅ Conditional logic (
locals)✅ Clean module structure
✅ Simple
user-data.shto run a web server✅ A reusable module you can plug anywhere
🌟 Goal
A Terraform project that:
Creates a VPC (basic networking)
Creates 1 EC2 instance
Automatically configures instance type based on environment (
dev,qa,staging,prod)Outputs the public IP so you can open it in your browser
📂 Folder Structure
terraform-env-ec2-lab/
├── main.tf
├── outputs.tf
├── providers.tf
└── modules/
├── vpc/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── web-app/
├── main.tf
├── variables.tf
├── locals.tf
├── outputs.tf
└── templates/
└── user-data.sh
🌐 1. VPC Module
modules/vpc/variables.tf
variable "vpc_name" {
description = "VPC name"
type = string
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "Availability zones"
type = list(string)
}
variable "public_subnets" {
description = "Public subnet CIDRs"
type = list(string)
}
variable "tags" {
description = "Additional tags"
type = map(string)
default = {}
}
modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(
{
Name = var.vpc_name
},
var.tags
)
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.vpc_name}-igw"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
map_public_ip_on_launch = true
availability_zone = var.azs[count.index % length(var.azs)]
tags = {
Name = "${var.vpc_name}-public-${count.index + 1}"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.vpc_name}-public-rt"
}
}
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
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
}
modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
💻 2. Environment-Aware EC2 Module
modules/web-app/variables.tf
variable "name" {
description = "Application name"
type = string
}
variable "environment" {
description = "Environment name (dev, qa, staging, prod)"
type = string
validation {
condition = contains(["dev", "qa", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, qa, staging, prod."
}
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "subnet_id" {
description = "Subnet ID for EC2 instance"
type = string
}
variable "key_name" {
description = "Key pair name for SSH access"
type = string
}
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed to access EC2"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "tags" {
description = "Additional resource tags"
type = map(string)
default = {}
}
modules/web-app/locals.tf
locals {
# Define instance configuration based on environment
instance_config = {
dev = {
type = "t2.micro"
}
qa = {
type = "t3.medium"
}
staging = {
type = "t3.large"
}
prod = {
type = "t3.xlarge"
}
}
instance_type = lookup(local.instance_config[var.environment], "type", "t2.micro")
name_prefix = "${var.name}-${var.environment}"
common_tags = merge(
{
Name = local.name_prefix
Environment = var.environment
ManagedBy = "Terraform"
Module = "web-app"
},
var.tags
)
}
modules/web-app/main.tf
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
# Security Group
resource "aws_security_group" "this" {
name = "${local.name_prefix}-sg"
description = "Allow HTTP and SSH"
vpc_id = var.vpc_id
ingress {
description = "Allow HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks
}
ingress {
description = "Allow SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}
# EC2 Instance
resource "aws_instance" "this" {
ami = data.aws_ami.amazon_linux.id
instance_type = local.instance_type
subnet_id = var.subnet_id
key_name = var.key_name
vpc_security_group_ids = [aws_security_group.this.id]
associate_public_ip_address = true
user_data = templatefile("${path.module}/templates/user-data.sh", {
app_name = var.name
environment = var.environment
})
tags = local.common_tags
}
modules/web-app/outputs.tf
output "instance_id" {
value = aws_instance.this.id
}
output "public_ip" {
value = aws_instance.this.public_ip
}
output "private_ip" {
value = aws_instance.this.private_ip
}
output "instance_type" {
value = aws_instance.this.instance_type
}
modules/web-app/templates/user-data.sh
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
cat > /var/www/html/index.html <<EOF
<html>
<head><title>${app_name}</title></head>
<body>
<h1>${app_name}</h1>
<p>Environment: ${environment}</p>
<p>Instance Type: $(curl -s http://169.254.169.254/latest/meta-data/instance-type)</p>
<p>Instance ID: $(curl -s http://169.254.169.254/latest/meta-data/instance-id)</p>
<p>Availability Zone: $(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone)</p>
</body>
</html>
EOF
🧩 3. Root Configuration
main.tf
# VPC module
module "vpc" {
source = "./modules/vpc"
vpc_name = "env-vpc"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
tags = {
Project = "env-based-ec2"
}
}
# Dev Environment EC2
module "dev_app" {
source = "./modules/web-app"
name = "webapp"
environment = "dev"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnet_ids[0]
key_name = "stackops" # replace this
tags = {
Environment = "Dev"
Team = "Platform"
Owner = "StackOps"
}
}
# QA Environment EC2
module "qa_app" {
source = "./modules/web-app"
name = "webapp"
environment = "qa"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnet_ids[1]
key_name = "stackops" # replace this
tags = {
Environment = "QA"
Team = "Platform"
Owner = "StackOps"
}
}
# Prod Environment EC2
module "prod_app" {
source = "./modules/web-app"
name = "webapp"
environment = "prod"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnet_ids[0]
key_name = "stackops" # replace this
tags = {
Environment = "production"
Team = "Platform"
Owner = "StackOps"
}
}
providers.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
profile = "stackops"
}
outputs.tf
output "dev_public_ip" {
value = module.dev_app.public_ip
}
output "qa_public_ip" {
value = module.qa_app.public_ip
}
output "prod_public_ip" {
value = module.prod_app.public_ip
}
4. Run
terraform fmt
terraform init
terraform validate
terraform plan
terraform apply
✅ Terraform will:
Create a VPC
Deploy 3 EC2 instances (dev, qa, prod)
Each instance will use the right instance type:
dev → t2.micro
qa → t3.medium
staging → t3.large (if added)
prod → t3.xlarge
Then you’ll get outputs like
dev_public_ip = 3.87.122.14
qa_public_ip = 18.205.33.19
prod_public_ip = 54.165.211.75
🌐 Test in Browser
Open in your browser:
http://<dev_public_ip>
http://<qa_public_ip>
http://<prod_public_ip>
Each page shows:
Environment name
Instance type
Instance ID and AZ
🧹 Cleanup
terraform destroy -auto-approve
📝 Summary
Today you learned:
✅ Module design principles
✅ Optional and conditional resources
✅ Feature flags pattern
✅ Environment-specific behavior
✅ Production-ready module structure
✅ Module composition
🚀 Tomorrow’s Preview
Day 19: Module Sources & Versioning
Tomorrow we’ll:
Publish modules to registries
Version modules properly
Use remote module sources
Manage module dependencies
Implement module upgrades
← Day 17: Introduction to Modules | Day 19: Module Sources →
Remember: Well-designed modules are reusable, flexible, and easy to understand!



