Skip to content

Building Terraform Modules: Reusable Infrastructure Components

DodaTech 5 min read

In this tutorial, you'll learn about Building Terraform Modules: Reusable Infrastructure Components. We cover key concepts, practical examples, and best practices.

Building Terraform modules transforms your infrastructure code into reusable, versioned, and composable components that teams can share across environments and projects.

What You'll Learn

In this tutorial, you will learn how to design Terraform module structure, define input variables with validation, expose outputs, version modules with semantic tags, publish to public and private registries, and compose modules into complete architectures.

Why It Matters

Modules eliminate copy-paste infrastructure code. A well-designed module enforces consistency, reduces errors, and lets teams provision complex infrastructure from a single source of truth. Organizations using modules report 50 percent faster environment provisioning.

Real-World Use

DodaTech maintains a registry of 20 Terraform modules used across all customer-facing services. Durga Antivirus Pro's VPC, EC2, RDS, and load balancer modules are shared between platform and security teams, ensuring consistent tagging, encryption, and security group rules.

Module Design Patterns

graph LR
    A[Root Module] --> B[VPC Module]
    A --> C[EC2 Module]
    A --> D[RDS Module]
    B --> E[Subnets]
    B --> F[Route Tables]
    B --> G[Internet Gateway]
    C --> H[Security Groups]
    C --> I[Auto Scaling]
    D --> J[DB Subnet Group]
    D --> K[Parameter Group]
    style A fill:#4a90d9,color:#fff
    style B fill:#50c878,color:#fff
    style C fill:#50c878,color:#fff
    style D fill:#50c878,color:#fff

Module Structure

Every module follows a standard layout:

terraform-aws-vpc/
  ├── main.tf           # Core resource definitions
  ├── variables.tf       # Input variable declarations
  ├── outputs.tf         # Output value definitions
  ├── versions.tf        # Provider and Terraform version constraints
  ├── README.md          # Documentation
  └── examples/
      └── basic/
          ├── main.tf
          └── terraform.tfvars

Variable Validation

# modules/rds/variables.tf
variable "instance_class" {
  description = "RDS instance type"
  type        = string

  validation {
    condition     = can(regex("^db\\.", var.instance_class))
    error_message = "Instance class must start with 'db.' prefix."
  }
}

variable "storage_gb" {
  description = "Allocated storage in GB"
  type        = number
  default     = 100

  validation {
    condition     = var.storage_gb >= 20 && var.storage_gb <= 16384
    error_message = "Storage must be between 20 and 16384 GB."
  }
}

variable "engine_version" {
  description = "PostgreSQL engine version"
  type        = string
  default     = "16.3"

  validation {
    condition     = contains(["15.8", "16.3", "17.0"], var.engine_version)
    error_message = "Engine version must be 15.8, 16.3, or 17.0."
  }
}

Expected output: Terraform validates inputs before creating resources. Invalid values produce clear error messages during <a href="/devops/terraform/">terraform</a> plan.

Output Design

# modules/rds/outputs.tf
output "endpoint" {
  description = "RDS instance connection endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = false
}

output "port" {
  description = "RDS instance port"
  value       = aws_db_instance.main.port
}

output "master_password" {
  description = "Master database password"
  value       = random_password.master.result
  sensitive   = true
}

output "security_group_id" {
  description = "Security group ID attached to the RDS instance"
  value       = aws_security_group.rds.id
}

Module Composition

Composing Modules Together

# main.tf - Root module composing child modules
module "network" {
  source  = "github.com/dodatech/terraform-aws-vpc"
  version = "2.1.0"

  name              = "production"
  cidr_block        = "10.0.0.0/16"
  public_subnets    = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets   = ["10.0.10.0/24", "10.0.11.0/24"]
  availability_zones = ["us-east-1a", "us-east-1b"]
}

module "database" {
  source  = "github.com/dodatech/terraform-aws-rds"
  version = "1.3.0"

  name              = "main-db"
  engine            = "postgres"
  engine_version    = "16.3"
  instance_class    = "db.r6g.large"
  storage_gb        = 500
  vpc_id            = module.network.vpc_id
  subnet_ids        = module.network.private_subnet_ids
  allowed_cidrs     = module.network.vpc_cidr
}

module "application" {
  source  = "github.com/dodatech/terraform-aws-ecs"
  version = "3.0.1"

  name               = "api-service"
  container_port     = 8080
  desired_count      = 3
  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.public_subnet_ids
  db_endpoint        = module.database.endpoint
  db_password_secret = module.database.master_password
}

Expected output: Terraform resolves the dependency graph automatically. Network provisions first, then database, then application. Each module receives outputs from upstream modules as inputs.

Module Versioning and Publishing

Git-Based Versioning

# Tag a module release
git tag -a "v1.2.0" -m "Add support for PostgreSQL 16"
git push origin v1.2.0
# Consuming the versioned module
module "vpc" {
  source = "github.com/dodatech/terraform-aws-vpc?ref=v1.2.0"
}

Expected output: Terraform downloads the exact tagged version. The .<a href="/devops/terraform/">terraform</a>.lock.hcl file records the module version for reproducible builds.

Terraform Registry Module

# versions.tf
terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 6.0"
    }
  }
}

Module Testing

Terratest for Module Testing

// test/vpc_test.go
package test

import (
  "testing"
  "github.com/gruntwork-io/terratest/modules/terraform"
)

func TestVPCModule(t *testing.T) {
  terraformOptions := &terraform.Options{
    TerraformDir: "../examples/basic",
  }

  defer terraform.Destroy(t, terraformOptions)
  terraform.InitAndApply(t, terraformOptions)

  vpcID := terraform.Output(t, terraformOptions, "vpc_id")
  if vpcID == "" {
    t.Error("VPC ID should not be empty")
  }
}
go test -v -timeout 30m

Expected output:

=== RUN   TestVPCModule
  TestVPCModule: vpc_module.go:25 - Running terraform init
  TestVPCModule: vpc_module.go:25 - Running terraform apply
  TestVPCModule: vpc_module.go:25 - Running terraform destroy
--- PASS: TestVPCModule (142.63s)
PASS

Common Mistakes

1. Missing Variable Validation

Without validation blocks, invalid inputs pass silently and fail at apply time with confusing provider errors.

2. Tight Coupling Through Outputs

Exposing raw resource ARNs instead of computed values creates tight coupling between modules. Output only what consumers need.

3. Hardcoded Provider Configurations

Modules should not contain provider blocks. Let the root module configure providers and pass them implicitly.

4. Ignoring Module Versioning

Using source = "./modules/vpc" without tags means any change affects all consumers. Always version modules.

5. Overly Large Modules

A module managing networks, databases, and compute violates the single-responsibility principle. Keep modules focused on one domain.

Practice Questions

1. What three files are required in every Terraform module? main.tf for resources, variables.tf for inputs, and outputs.tf for return values.

2. How do you validate variable inputs in a module? Using validation blocks with condition expressions and error_message strings in variable declarations.

3. Why should modules avoid hardcoded provider configurations? Provider blocks belong in the root module. Hardcoded providers in modules prevent flexible provider configuration and multi-region deployments.

4. Challenge: Build a security group module that accepts ingress and egress rules as maps, validates CIDR format, and outputs the security group ID. Write a Terratest that provisions and destroys the module.

Mini Project: Multi-Module Architecture

Create three modules: network (VPC + subnets), database (RDS + security group), and app (ECS + ALB). Compose them in a root module with environment-specific tfvars files for dev, staging, and production. Use Git tags for versioning and verify composition with <a href="/devops/terraform/">terraform</a> graph.

Terraform State Management
Terraform CI/CD Pipelines

What's Next

Build reusable Terraform modules for your infrastructure components, then integrate them into CI/CD Pipelines for automated testing and deployment. Study Cloud Computing patterns for multi-environment architectures.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro