Skip to main content

Command Palette

Search for a command to run...

Day 18: Creating Your Own Reusable Modules

Published
11 min read
Day 18: Creating Your Own Reusable Modules
S

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 infrastructure

  • module.compute and module.database → consume network outputs

  • Each 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:

variables.tf

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
}

main.tf

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
}


We'll use:

  • ✅ Conditional logic (locals)

  • ✅ Clean module structure

  • ✅ Simple user-data.sh to 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!

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.