Terraform Modules: Building Blocks You Can Reuse Everywhere – T5

Hey there! Welcome back to our Terraform journey. Today we’re going to learn about Terraform modules – the secret to writing code once and using it everywhere.

Think of modules like LEGO blocks. Instead of building a new castle from scratch every time, you create reusable pieces (like walls, towers, gates) that you can combine in different ways to build different castles. That’s exactly what modules do for your infrastructure!

The Problem: Copying Code Everywhere

Let’s say you’ve built a great web server setup for one project. It works perfectly! Now you need the same setup for another project. What do you do?

Option 1: Copy and paste all the code 😱

Project A/
├── main.tf (web server code)
├── variables.tf
└── outputs.tf

Project B/
├── main.tf (same web server code - copied!)
├── variables.tf (copied!)
└── outputs.tf (copied!)

This works, but what happens when you find a bug or want to improve the web server? You have to fix it in both places. And if you have 10 projects? You fix it 10 times!

Option 2: Use modules 🎉

web-server-module/
├── main.tf
├── variables.tf
└── outputs.tf

Project A/
└── main.tf (uses web-server-module)

Project B/
└── main.tf (uses web-server-module)

Now when you improve the web server, you fix it once and all projects get the improvement automatically!

What Are Modules Really?

A module is just a folder with Terraform files that you can reuse. But let’s think deeper about what this means.

Every Terraform configuration is actually a module – even the simple files you’ve been writing! When you run terraform apply in a folder, you’re running the “root module.”

Types of modules:

  • Root module – your main Terraform files (where you run terraform commands)
  • Child modules – reusable pieces you call from your main files
  • Published modules – shared modules from Terraform Registry or Git
  • Local modules – modules you create for your own projects

Why Modules Matter: The Theory

Think about how we build things in the real world:

Houses aren’t built from scratch every time. Builders use:

  • Standard door frames (modules)
  • Pre-made windows (modules)
  • Standard electrical outlets (modules)
  • Common plumbing fixtures (modules)

Software isn’t written from scratch every time. Developers use:

  • Libraries (like modules)
  • Frameworks (like modules)
  • APIs (like modules)
  • Components (like modules)

Infrastructure shouldn’t be built from scratch every time. That’s where Terraform modules come in.

The Module Mindset: Composition Over Repetition

Instead of thinking “I need to build a web application,” think:

  • “I need a web server module”
  • “I need a database module”
  • “I need a load balancer module”
  • “I need to compose these together”

This is called composition – building complex things from simple, reusable parts.

Benefits of the Module Approach

1. Consistency Across Projects When you use the same database module everywhere, all your databases are configured the same way. No more “why does this database work differently?”

2. Reduced Errors A well-tested module has fewer bugs than code written from scratch every time. Fix a bug once, it’s fixed everywhere.

3. Faster Development Instead of spending days writing infrastructure code, you spend minutes configuring modules.

4. Knowledge Sharing Modules capture best practices. When someone figures out the perfect security configuration, everyone can use it.

5. Easier Maintenance Need to update all databases to a new version? Update one module, redeploy everywhere.

6. Better Testing It’s easier to thoroughly test one module than to test the same logic scattered across many projects.

Types of Modules by Purpose

Infrastructure Modules

  • VPC networks
  • Security groups
  • Load balancers
  • Databases

Application Modules

  • Web servers
  • API services
  • Worker processes
  • Monitoring setups

Security Modules

  • IAM roles and policies
  • Certificate management
  • Secret storage
  • Backup systems

Utility Modules

  • Naming conventions
  • Tagging standards
  • Environment configurations
  • Data transformations

Module Design Philosophy

Single Responsibility Principle Each module should do one thing well. A “web server” module shouldn’t also manage databases.

Interface Design Think of modules like appliances:

  • Inputs (variables) are like plugs and settings
  • Outputs are like displays and ports
  • Internal logic is hidden (you don’t need to know how a microwave works to use it)

Abstraction Levels Modules can work at different levels:

  • Low-level: Create a single AWS security group
  • Mid-level: Create a web server with security group, load balancer, and auto-scaling
  • High-level: Create an entire application environment

Flexibility vs Simplicity There’s always a balance:

  • Too simple: Module only works for one specific case
  • Too flexible: Module is complicated and hard to use
  • Just right: Module works for common cases but allows customization

Your First Simple Module

Let’s create a module that makes file backups. First, create this folder structure:

terraform-project/
├── main.tf
└── modules/
    └── file-backup/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

File: modules/file-backup/variables.tf

variable "file_name" {
  type        = string
  description = "Name of the file to backup"
}

variable "file_content" {
  type        = string
  description = "Content of the file"
}

variable "backup_folder" {
  type        = string
  description = "Folder to store backups"
  default     = "backups"
}

File: modules/file-backup/main.tf

# Create the backup folder
resource "local_file" "backup_folder" {
  filename = "${var.backup_folder}/.keep"
  content  = "This folder contains backups"
}

# Create the original file
resource "local_file" "original" {
  filename = var.file_name
  content  = var.file_content
}

# Create the backup file
resource "local_file" "backup" {
  filename = "${var.backup_folder}/${var.file_name}.backup"
  content  = var.file_content
  
  depends_on = [local_file.backup_folder]
}

# Create a backup info file
resource "local_file" "backup_info" {
  filename = "${var.backup_folder}/${var.file_name}.info"
  content = <<-EOF
    Backup Information
    ==================
    Original file: ${var.file_name}
    Backup created: ${timestamp()}
    File size: ${length(var.file_content)} characters
  EOF
  
  depends_on = [local_file.backup_folder]
}

File: modules/file-backup/outputs.tf

output "original_file" {
  description = "Path to the original file"
  value       = local_file.original.filename
}

output "backup_file" {
  description = "Path to the backup file"
  value       = local_file.backup.filename
}

output "backup_info" {
  description = "Path to the backup info file"
  value       = local_file.backup_info.filename
}

output "files_created" {
  description = "All files created by this module"
  value = {
    original = local_file.original.filename
    backup   = local_file.backup.filename
    info     = local_file.backup_info.filename
  }
}

Using Your Module

Now let’s use this module in your main Terraform file:

File: main.tf

# Use the module to backup important files
module "config_backup" {
  source = "./modules/file-backup"
  
  file_name    = "app-config.json"
  file_content = jsonencode({
    app_name = "MyApp"
    version  = "1.0"
    settings = {
      debug   = true
      timeout = 30
    }
  })
}

module "secrets_backup" {
  source = "./modules/file-backup"
  
  file_name     = "secrets.env"
  file_content  = "DATABASE_PASSWORD=super_secret_123\nAPI_KEY=abc123xyz"
  backup_folder = "secure-backups"
}

# Show what was created
output "backup_summary" {
  value = {
    config_files  = module.config_backup.files_created
    secret_files  = module.secrets_backup.files_created
  }
}

Run this and see the magic:

terraform init
terraform apply

You’ll get:

  • Two original files
  • Two backup files
  • Two info files
  • Files organized in different folders

All from one reusable module!

Module Inputs and Outputs

Module inputs are variables – they’re how you customize the module for different uses.

Module outputs are how the module tells you what it created – so you can use that information elsewhere.

module "my_module" {
  source = "./path/to/module"
  
  # Inputs (variables)
  input1 = "value1"
  input2 = "value2"
}

# Using outputs
output "something" {
  value = module.my_module.output_name
}

Real-World Example: Web Server Module

Let’s create something more practical – a web server module:

File: modules/web-server/variables.tf

variable "server_name" {
  type        = string
  description = "Name of the web server"
}

variable "environment" {
  type        = string
  description = "Environment (dev, staging, prod)"
  default     = "dev"
}

variable "port" {
  type        = number
  description = "Port for the web server"
  default     = 8080
}

variable "enable_ssl" {
  type        = bool
  description = "Enable SSL/HTTPS"
  default     = false
}

variable "custom_pages" {
  type        = map(string)
  description = "Custom pages to create"
  default     = {}
}

File: modules/web-server/main.tf

locals {
  server_config = {
    name        = var.server_name
    environment = var.environment
    port        = var.port
    ssl_enabled = var.enable_ssl
    protocol    = var.enable_ssl ? "https" : "http"
    url         = "${var.enable_ssl ? "https" : "http"}://localhost:${var.port}"
  }
  
  # Environment-specific settings
  settings = {
    dev = {
      workers      = 1
      log_level    = "debug"
      cache_ttl    = 60
    }
    staging = {
      workers      = 2
      log_level    = "info"
      cache_ttl    = 300
    }
    prod = {
      workers      = 4
      log_level    = "warn"
      cache_ttl    = 3600
    }
  }
  
  current_settings = local.settings[var.environment]
}

# Create server configuration
resource "local_file" "server_config" {
  filename = "${var.server_name}-server.json"
  content = jsonencode({
    server = local.server_config
    settings = local.current_settings
    created_at = timestamp()
  })
}

# Create startup script
resource "local_file" "startup_script" {
  filename = "start-${var.server_name}.sh"
  content = <<-EOF
    #!/bin/bash
    echo "Starting ${var.server_name} web server..."
    echo "Environment: ${var.environment}"
    echo "Port: ${var.port}"
    echo "SSL: ${var.enable_ssl ? "enabled" : "disabled"}"
    echo "URL: ${local.server_config.url}"
    echo "Workers: ${local.current_settings.workers}"
    echo "Log Level: ${local.current_settings.log_level}"
    echo ""
    echo "Server configuration loaded from ${var.server_name}-server.json"
    echo "Server is ready!"
  EOF
}

# Create custom pages
resource "local_file" "custom_pages" {
  for_each = var.custom_pages
  
  filename = "${var.server_name}-${each.key}.html"
  content = <<-EOF
    <!DOCTYPE html>
    <html>
    <head>
        <title>${var.server_name} - ${title(each.key)}</title>
    </head>
    <body>
        <h1>${var.server_name} - ${title(each.key)} Page</h1>
        <p>${each.value}</p>
        <hr>
        <p>Environment: ${var.environment} | Port: ${var.port}</p>
    </body>
    </html>
  EOF
}

# Create nginx-style config
resource "local_file" "nginx_config" {
  filename = "${var.server_name}-nginx.conf"
  content = <<-EOF
    server {
        listen ${var.port}${var.enable_ssl ? " ssl" : ""};
        server_name ${var.server_name};
        
        # Environment: ${var.environment}
        # Workers: ${local.current_settings.workers}
        
        %{if var.enable_ssl}
        ssl_certificate /path/to/cert.pem;
        ssl_certificate_key /path/to/key.pem;
        %{endif}
        
        # Custom pages
        %{for page_name, _ in var.custom_pages}
        location /${page_name} {
            try_files /${var.server_name}-${page_name}.html =404;
        }
        %{endfor}
        
        # Default location
        location / {
            return 200 "Welcome to ${var.server_name}!";
            add_header Content-Type text/plain;
        }
        
        # Health check
        location /health {
            return 200 "OK";
            add_header Content-Type text/plain;
        }
    }
  EOF
}

File: modules/web-server/outputs.tf

output "server_info" {
  description = "Complete server information"
  value = {
    name         = var.server_name
    environment  = var.environment
    port         = var.port
    ssl_enabled  = var.enable_ssl
    url          = local.server_config.url
    workers      = local.current_settings.workers
    log_level    = local.current_settings.log_level
  }
}

output "files_created" {
  description = "List of all files created"
  value = concat(
    [
      local_file.server_config.filename,
      local_file.startup_script.filename,
      local_file.nginx_config.filename
    ],

[for page in local_file.custom_pages : page.filename]

) } output “server_url” { description = “URL to access the server” value = local.server_config.url } output “startup_command” { description = “Command to start the server” value = “bash start-${var.server_name}.sh” }

Using the Web Server Module

Now let’s use this module to create different types of servers:

File: main.tf

# Development web server
module "dev_web" {
  source = "./modules/web-server"
  
  server_name = "dev-frontend"
  environment = "dev"
  port        = 3000
  enable_ssl  = false
  
  custom_pages = {
    about   = "This is the about page for our development server."
    contact = "Contact us at dev-team@company.com"
  }
}

# Production web server
module "prod_web" {
  source = "./modules/web-server"
  
  server_name = "prod-frontend"
  environment = "prod"
  port        = 443
  enable_ssl  = true
  
  custom_pages = {
    about    = "Welcome to our production website!"
    contact  = "Contact us at support@company.com"
    privacy  = "Your privacy is important to us."
    terms    = "Please read our terms of service."
  }
}

# API server
module "api_server" {
  source = "./modules/web-server"
  
  server_name = "api-backend"
  environment = "prod"
  port        = 8080
  enable_ssl  = true
  
  custom_pages = {
    docs = "API documentation is available here."
    status = "API Status: All systems operational"
  }
}

# Show summary of all servers
output "servers_summary" {
  value = {
    dev_web = {
      info  = module.dev_web.server_info
      url   = module.dev_web.server_url
      start = module.dev_web.startup_command
    }
    prod_web = {
      info  = module.prod_web.server_info
      url   = module.prod_web.server_url
      start = module.prod_web.startup_command
    }
    api_server = {
      info  = module.api_server.server_info
      url   = module.api_server.server_url
      start = module.api_server.startup_command
    }
  }
}

output "all_files_created" {
  value = concat(
    module.dev_web.files_created,
    module.prod_web.files_created,
    module.api_server.files_created
  )
}

This creates three different servers with different configurations, all from the same reusable module!

Module Architecture Patterns

Layered Architecture Organize modules in layers, where higher layers depend on lower layers:

Application Layer    │ app-server, web-frontend, api-gateway
     ↑              │
Platform Layer      │ database, cache, message-queue  
     ↑              │
Infrastructure Layer │ vpc, security-groups, load-balancer
     ↑              │
Foundation Layer     │ naming, tagging, policies

Composition Pattern Build complex infrastructure by combining simple modules:

# Instead of one giant "web-application" module
module "network" { ... }
module "database" { ... }
module "web_server" { ... }
module "monitoring" { ... }

# Compose them together

Factory Pattern Use modules to create different “flavors” of the same thing:

module "dev_environment" {
  source = "./modules/environment"
  size   = "small"
  features = ["basic-monitoring"]
}

module "prod_environment" {
  source = "./modules/environment"  
  size   = "large"
  features = ["monitoring", "backup", "security"]
}

Module Communication Patterns

Direct Output/Input Modules share data through explicit connections:

module "database" { ... }

module "web_server" {
  database_url = module.database.connection_string
}

Shared Data Sources Modules discover each other through external data:

data "aws_vpc" "main" {
  tags = { Name = "main-vpc" }
}

module "web_server" {
  vpc_id = data.aws_vpc.main.id
}

Configuration Management Modules read from shared configuration:

locals {
  config = yamldecode(file("infrastructure-config.yaml"))
}

module "web_server" {
  config = local.config.web_server
}

Module Evolution and Backwards Compatibility

Additive Changes (Safe)

  • Adding new optional variables
  • Adding new outputs
  • Adding new optional resources

Breaking Changes (Dangerous)

  • Removing or renaming variables
  • Changing variable types
  • Removing outputs
  • Changing resource names

Migration Strategies

# Deprecation approach
variable "old_variable_name" {
  type        = string
  default     = null
  description = "DEPRECATED: Use new_variable_name instead"
}

variable "new_variable_name" {
  type    = string
  default = null
}

locals {
  # Use new variable if provided, fallback to old one
  actual_value = var.new_variable_name != null ? var.new_variable_name : var.old_variable_name
}

Using Modules from Terraform Registry

You don’t have to build everything yourself. The Terraform Registry has thousands of pre-built modules.

Registry Benefits:

  • Community tested: Modules used by thousands of people
  • Best practices: Modules often follow cloud provider recommendations
  • Documentation: Usually well-documented with examples
  • Versioning: Stable releases you can depend on
  • Maintenance: Updated by maintainers when cloud providers change APIs

Registry Considerations:

  • External dependency: You depend on someone else’s code
  • Learning curve: Each module has its own interface
  • Customization limits: May not fit your exact needs
  • Security: Need to trust the module author

Evaluation criteria for registry modules:

  • Download count: Popular modules are usually more reliable
  • Recent updates: Actively maintained modules
  • Documentation quality: Good examples and clear explanations
  • Issue tracker: How responsive are maintainers to problems?
  • Source code: Is the code clean and understandable?

Here’s how to use a module from the registry:

# Use a VPC module from the registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
  
  name = "my-vpc"
  cidr = "10.0.0.0/16"
  
  azs             = ["us-west-2a", "us-west-2b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]
  
  enable_nat_gateway = true
  enable_vpn_gateway = true
  
  tags = {
    Environment = "dev"
    Project     = "my-project"
  }
}

# Use the VPC outputs in other resources
output "vpc_info" {
  value = {
    vpc_id          = module.vpc.vpc_id
    private_subnets = module.vpc.private_subnets
    public_subnets  = module.vpc.public_subnets
  }
}

What’s different:

  • source points to the registry instead of a local path
  • version specifies which version to use
  • The module has different variables than our custom ones

Passing Data Between Modules

Modules can share data with each other through outputs and inputs:

# First module creates network
module "network" {
  source = "./modules/web-server"
  
  server_name = "network-config"
  environment = "prod"
}

# Second module uses data from first module
module "application" {
  source = "./modules/web-server"
  
  server_name = "app-server"
  environment = "prod"
  
  # Use output from network module
  port = 8080  # Could use module.network.server_info.port if it made sense
}

# Show how they're connected
output "module_connection" {
  value = {
    network_url = module.network.server_url
    app_url     = module.application.server_url
    connection  = "App server talks to network on ${module.network.server_info.url}"
  }
}

Module Best Practices

1. Keep modules focused Each module should do one thing well:

# Good - focused modules
module "database" { ... }
module "web_server" { ... }
module "load_balancer" { ... }

# Not great - does too many things
module "entire_application" { ... }

2. Use clear variable names and descriptions

variable "database_instance_type" {
  type        = string
  description = "The instance type for the database (e.g., db.t3.micro)"
  default     = "db.t3.micro"
}

3. Provide useful outputs

output "database_connection_string" {
  description = "Connection string for the database"
  value       = "postgresql://${aws_db_instance.main.endpoint}:${aws_db_instance.main.port}/${aws_db_instance.main.db_name}"
}

4. Use locals for complex logic

locals {
  environment_config = {
    dev  = { instance_type = "t2.micro", backup_retention = 7 }
    prod = { instance_type = "t3.large", backup_retention = 30 }
  }
  
  current_config = local.environment_config[var.environment]
}

Organizing Your Module Files

Here’s a good structure for organizing modules:

terraform-project/
├── main.tf                    # Your main configuration
├── variables.tf               # Your main variables
├── outputs.tf                 # Your main outputs
├── terraform.tfvars          # Your variable values
└── modules/
    ├── web-server/
    │   ├── main.tf
    │   ├── variables.tf
    │   ├── outputs.tf
    │   └── README.md          # Explain how to use the module
    ├── database/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── networking/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

File naming conventions:

  • main.tf – main resources
  • variables.tf – input variables
  • outputs.tf – output values
  • versions.tf – provider requirements (optional)
  • README.md – documentation (highly recommended)

Quick Module Checklist

When creating a module, ask yourself:

✅ Purpose: Does this module have a clear, single purpose? ✅ Variables: Are all the variables well-named and documented? ✅ Outputs: Does it output useful information for other modules? ✅ Defaults: Do variables have sensible default values? ✅ Documentation: Would someone else understand how to use this? ✅ Testing: Have you tested it with different input values?

Common Module Patterns

Pattern 1: Feature Toggles

variable "enable_monitoring" {
  type    = bool
  default = false
}

variable "enable_backups" {
  type    = bool
  default = false
}

# Only create monitoring resources if enabled
resource "local_file" "monitoring_config" {
  count = var.enable_monitoring ? 1 : 0
  
  filename = "monitoring.conf"
  content  = "Monitoring enabled"
}

Pattern 2: Environment-Specific Configs

variable "environment" {
  type = string
}

locals {
  env_configs = {
    dev  = { size = "small", replicas = 1 }
    prod = { size = "large", replicas = 3 }
  }
  
  config = local.env_configs[var.environment]
}

Pattern 3: Flexible Resource Creation

variable "additional_files" {
  type    = map(string)
  default = {}
}

resource "local_file" "extra_files" {
  for_each = var.additional_files
  
  filename = each.key
  content  = each.value
}

What’s Next?

Congratulations! You’ve learned how to build reusable infrastructure components:

Module Basics – Packaging code for reuse:

  • ✅ Creating your first module
  • ✅ Module inputs (variables) and outputs
  • ✅ Using modules in your main configuration
  • ✅ Organizing module files properly

Advanced Module Usage:

  • ✅ Using modules from Terraform Registry
  • ✅ Passing data between modules
  • ✅ Module best practices and patterns
  • ✅ File organization and documentation

Real-World Applications:

  • ✅ Building flexible, configurable modules
  • ✅ Environment-specific configurations
  • ✅ Feature toggles and conditional resources
  • ✅ Complex data transformations in modules

In our next post, we’ll explore Terraform Workspaces and Remote State Management – how to manage multiple environments and collaborate with teams. You’ll learn:

  • Creating separate workspaces for dev/staging/prod
  • Managing state files securely
  • Setting up remote backends
  • Team collaboration best practices
  • State locking and consistency

The modular thinking you’ve developed today will be crucial for organizing complex, multi-environment infrastructure!


Ready to learn how to manage multiple environments like a pro? The next post will show you advanced state management techniques!

Akhilesh Mishra

Akhilesh Mishra

I am Akhilesh Mishra, a self-taught Devops engineer with 11+ years working on private and public cloud (GCP & AWS)technologies.

I also mentor DevOps aspirants in their journey to devops by providing guided learning and Mentorship.

Topmate: https://topmate.io/akhilesh_mishra/