BLOG POSTS
How to Build a Custom Terraform Module

How to Build a Custom Terraform Module

Terraform modules are reusable components that encapsulate infrastructure code into logical, shareable units. They’re essentially the functions of Infrastructure as Code, allowing you to package up complex resource configurations and reuse them across multiple projects or environments. Building custom Terraform modules becomes crucial when you need to standardize infrastructure patterns, reduce code duplication, and maintain consistency across your infrastructure deployments. In this guide, you’ll learn how to design, build, and distribute custom Terraform modules from scratch, including proper structure, variable management, output handling, and testing strategies.

How Terraform Modules Work

At its core, a Terraform module is just a collection of .tf files in a directory. Every Terraform configuration is technically a module – what we usually call the “root module.” Custom modules take this concept further by creating reusable packages of infrastructure code.

Modules accept input variables, create resources, and return outputs. Think of them as black boxes that take parameters and produce infrastructure. The module system uses three key components:

  • Input Variables – Parameters passed into the module
  • Resources – The actual infrastructure components created
  • Outputs – Values returned from the module for use elsewhere

When Terraform runs, it processes modules by downloading them (if remote), parsing their configuration, and executing them with the provided inputs. The module graph determines execution order based on dependencies between resources and data sources.

Step-by-Step Module Creation Guide

Let’s build a practical module for creating a standardized web server setup. This example will demonstrate proper module structure and best practices.

Setting Up the Module Structure

First, create the directory structure for your module:

web-server-module/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
└── examples/
    └── basic/
        ├── main.tf
        └── terraform.tfvars.example

Defining Input Variables

Start with variables.tf to define what inputs your module accepts:

variable "instance_name" {
  description = "Name tag for the EC2 instance"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "vpc_id" {
  description = "VPC ID where resources will be created"
  type        = string
}

variable "subnet_id" {
  description = "Subnet ID for the EC2 instance"
  type        = string
}

variable "allowed_cidr_blocks" {
  description = "List of CIDR blocks allowed to access the web server"
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

variable "enable_monitoring" {
  description = "Enable detailed monitoring for the instance"
  type        = bool
  default     = false
}

variable "tags" {
  description = "Additional tags to apply to resources"
  type        = map(string)
  default     = {}
}

Creating the Main Resource Configuration

In main.tf, define the resources your module creates:

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

resource "aws_security_group" "web_server" {
  name_prefix = "${var.instance_name}-web-"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = var.allowed_cidr_blocks
  }

  ingress {
    from_port   = 443
    to_port     = 443
    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 = merge(
    {
      Name = "${var.instance_name}-web-sg"
    },
    var.tags
  )
}

resource "aws_instance" "web_server" {
  ami                     = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [aws_security_group.web_server.id]
  monitoring             = var.enable_monitoring

  user_data = base64encode(templatefile("${path.module}/user_data.sh", {
    instance_name = var.instance_name
  }))

  tags = merge(
    {
      Name = var.instance_name
    },
    var.tags
  )
}

Defining Outputs

In outputs.tf, specify what values the module should return:

output "instance_id" {
  description = "ID of the created EC2 instance"
  value       = aws_instance.web_server.id
}

output "instance_public_ip" {
  description = "Public IP address of the instance"
  value       = aws_instance.web_server.public_ip
}

output "instance_private_ip" {
  description = "Private IP address of the instance"
  value       = aws_instance.web_server.private_ip
}

output "security_group_id" {
  description = "ID of the created security group"
  value       = aws_security_group.web_server.id
}

output "web_url" {
  description = "URL to access the web server"
  value       = "http://${aws_instance.web_server.public_ip}"
}

Setting Version Constraints

Define provider requirements in versions.tf:

terraform {
  required_version = ">= 1.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

Real-World Examples and Use Cases

Here’s how you’d use the web server module in a real project:

module "production_web_server" {
  source = "./modules/web-server-module"

  instance_name        = "prod-web-01"
  instance_type       = "t3.medium"
  vpc_id              = "vpc-12345678"
  subnet_id           = "subnet-87654321"
  allowed_cidr_blocks = ["10.0.0.0/8"]
  enable_monitoring   = true

  tags = {
    Environment = "production"
    Team        = "web-team"
    CostCenter  = "engineering"
  }
}

module "staging_web_server" {
  source = "./modules/web-server-module"

  instance_name        = "staging-web-01"
  instance_type       = "t3.small"
  vpc_id              = var.staging_vpc_id
  subnet_id           = var.staging_subnet_id
  allowed_cidr_blocks = ["0.0.0.0/0"]

  tags = {
    Environment = "staging"
    Team        = "web-team"
  }
}

# Use outputs from the module
resource "aws_route53_record" "web" {
  zone_id = var.hosted_zone_id
  name    = "web.example.com"
  type    = "A"
  ttl     = 300
  records = [module.production_web_server.instance_public_ip]
}

Common real-world use cases for custom modules include:

  • Multi-tier applications – Database, application, and load balancer tiers
  • Networking patterns – VPC setups with public/private subnets
  • Security configurations – Standard security groups and IAM roles
  • Monitoring stacks – CloudWatch, alerting, and logging setup
  • Compliance frameworks – SOC2 or HIPAA compliant infrastructure patterns

Comparison with Alternative Approaches

Approach Reusability Maintenance Learning Curve Best For
Copy-paste code Low High effort None One-off projects
Custom modules High Medium effort Medium Standardized patterns
Public modules High Low effort Low Common use cases
Terragrunt Very high Low effort High Complex multi-env setups

Advanced Module Patterns

Conditional Resource Creation

Use the count parameter to conditionally create resources:

variable "create_load_balancer" {
  description = "Whether to create an application load balancer"
  type        = bool
  default     = false
}

resource "aws_lb" "web" {
  count              = var.create_load_balancer ? 1 : 0
  name               = "${var.instance_name}-alb"
  load_balancer_type = "application"
  subnets           = var.public_subnet_ids
  security_groups   = [aws_security_group.alb[0].id]

  tags = var.tags
}

resource "aws_security_group" "alb" {
  count  = var.create_load_balancer ? 1 : 0
  name   = "${var.instance_name}-alb-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = var.tags
}

Using for_each for Multiple Resources

Create multiple similar resources with different configurations:

variable "web_servers" {
  description = "Map of web server configurations"
  type = map(object({
    instance_type = string
    subnet_id     = string
  }))
  default = {}
}

resource "aws_instance" "web_servers" {
  for_each = var.web_servers

  ami                     = data.aws_ami.amazon_linux.id
  instance_type          = each.value.instance_type
  subnet_id              = each.value.subnet_id
  vpc_security_group_ids = [aws_security_group.web_server.id]

  tags = merge(
    {
      Name = each.key
    },
    var.tags
  )
}

Testing and Validation

Proper testing is crucial for reliable modules. Here’s a basic testing approach using Terratest:

package test

import (
    "testing"
    "time"

    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/gruntwork-io/terratest/modules/test-structure"
    "github.com/stretchr/testify/assert"
)

func TestWebServerModule(t *testing.T) {
    t.Parallel()

    // Create a temporary directory for the test
    tempTestFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/basic")

    // Configure Terraform options
    terraformOptions := &terraform.Options{
        TerraformDir: tempTestFolder,
        Vars: map[string]interface{}{
            "instance_name": "test-web-server",
            "vpc_id":       "vpc-12345678",
            "subnet_id":    "subnet-87654321",
        },
    }

    // Clean up resources after test
    defer terraform.Destroy(t, terraformOptions)

    // Run terraform init and apply
    terraform.InitAndApply(t, terraformOptions)

    // Validate outputs
    instanceId := terraform.Output(t, terraformOptions, "instance_id")
    assert.NotEmpty(t, instanceId)

    publicIp := terraform.Output(t, terraformOptions, "instance_public_ip")
    assert.NotEmpty(t, publicIp)
}

Best Practices and Common Pitfalls

Module Design Best Practices

  • Keep modules focused – Each module should have a single, well-defined purpose
  • Use semantic versioning – Tag your module releases with proper version numbers
  • Provide comprehensive documentation – Include README with examples and variable descriptions
  • Follow naming conventions – Use consistent resource naming patterns within modules
  • Make outputs useful – Return values that consumers will actually need
  • Use data sources wisely – Prefer data sources over hardcoded values for better flexibility

Common Pitfalls to Avoid

  • Overly complex modules – Don’t try to solve every use case in one module
  • Missing validation – Use variable validation blocks to catch errors early
  • Hardcoded values – Always parameterize values that might change
  • Poor state management – Be careful with resource naming to avoid state conflicts
  • Ignoring backwards compatibility – Plan for module evolution without breaking existing usage

Variable Validation Example

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"

  validation {
    condition = contains([
      "t3.micro", "t3.small", "t3.medium", "t3.large",
      "t3.xlarge", "t3.2xlarge"
    ], var.instance_type)
    error_message = "Instance type must be a valid t3 instance type."
  }
}

variable "environment" {
  description = "Environment name"
  type        = string

  validation {
    condition     = can(regex("^(dev|staging|prod)$", var.environment))
    error_message = "Environment must be dev, staging, or prod."
  }
}

Publishing and Sharing Modules

For private modules, you can use Git repositories with version tags:

module "web_server" {
  source = "git::https://github.com/yourorg/terraform-aws-web-server.git?ref=v1.0.0"

  instance_name = "my-web-server"
  vpc_id       = var.vpc_id
  subnet_id    = var.subnet_id
}

For public modules, consider publishing to the Terraform Registry. The registry provides automatic documentation generation and versioning.

When working with infrastructure that requires significant compute resources for testing and development, consider using VPS instances for cost-effective development environments, or dedicated servers for production-grade infrastructure testing.

Module development becomes much more efficient when you establish proper CI/CD pipelines. Set up automated testing that runs on every commit, validates module syntax with terraform validate, checks formatting with terraform fmt, and runs security scans with tools like tfsec. This ensures your modules maintain high quality and security standards as they evolve.

The key to successful module adoption in your organization is starting small with commonly repeated patterns, gathering feedback from users, and iterating based on real-world usage. Well-designed modules can dramatically reduce infrastructure code duplication and improve consistency across your environments.



This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.

This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.

Leave a reply

Your email address will not be published. Required fields are marked