What is a Module? #

Imagine needing to create the same infrastructure — a VPC with public and private subnets, a NAT gateway, and correct routing — for dev, staging, and production environments. Without modules, you write nearly identical configuration three times, then struggle to keep all three consistent every time something changes. Modules solve this by wrapping reusable configuration into a single unit that can be called repeatedly with different parameters, just like calling a function in programming.

flowchart TD
    A["Root Module"] -->|"module "vpc""| B["Child Module\n: ./modules/vpc"]
    B -->|"Outputs: vpc_id,\nsubnet_ids"| A
    A -->|"module "app""| C["Child Module\n: ./modules/app"]
    C -->|"Outputs: lb_dns,\ninstance_ids"| A

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#f59e0b,stroke:#d97706,color:#fff

What Is a Module #

A module is a directory containing one or more Terraform configuration files (.tf). Every Terraform directory is a module — including the directory you’re working in right now, called the root module. What distinguishes a child module: a separate directory called from another module with a module block.

WITHOUT MODULES — configuration repeated:

environments/
  ├── dev/
  │   ├── main.tf        ← vpc + subnet + igw + nat + routing (duplicated)
  │   ├── variables.tf
  │   └── outputs.tf
  ├── staging/
  │   ├── main.tf        ← vpc + subnet + igw + nat + routing (duplicated)
  │   ├── variables.tf
  │   └── outputs.tf
  └── production/
      ├── main.tf        ← vpc + subnet + igw + nat + routing (duplicated)
      ├── variables.tf
      └── outputs.tf

WITH MODULES — configuration called:

modules/
  └── vpc/               ← one correct VPC configuration
      ├── main.tf
      ├── variables.tf
      └── outputs.tf

environments/
  ├── dev/
  │   └── main.tf        ← module "vpc" { source = "../../modules/vpc" }
  ├── staging/
  │   └── main.tf        ← module "vpc" { source = "../../modules/vpc" }
  └── production/
      └── main.tf        ← module "vpc" { source = "../../modules/vpc" }

Module Anatomy #

A module consists of three main components working together.

modules/vpc/
  ├── main.tf        ← Resources created by this module
  ├── variables.tf   ← Inputs the module accepts (parameters)
  └── outputs.tf     ← Values the module exports to its callers
# modules/vpc/variables.tf — the module's "parameters"
variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
}

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

variable "public_subnet_count" {
  description = "Number of public subnets to create"
  type        = number
  default     = 2
}

variable "private_subnet_count" {
  description = "Number of private subnets to create"
  type        = number
  default     = 2
}
# modules/vpc/main.tf — the module's internal implementation
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name        = "${var.environment}-igw"
    Environment = var.environment
  }
}

# ... subnet, routing table, NAT gateway, etc.
# modules/vpc/outputs.tf — values exported to the caller
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

Calling Modules #

Modules are called from the root module or another module using the module block.

# environments/production/main.tf

module "vpc" {
  source = "../../modules/vpc"    # Path to the module directory

  # Module input variables — matching the variables.tf in the module
  cidr_block           = "10.0.0.0/16"
  environment          = "production"
  public_subnet_count  = 3
  private_subnet_count = 3
}

module "vpc_dev" {
  source = "../../modules/vpc"    # The SAME module called with different values

  cidr_block           = "10.1.0.0/16"
  environment          = "dev"
  public_subnet_count  = 1
  private_subnet_count = 1
}

# Access module outputs: module.<name>.<output_name>
resource "aws_eks_cluster" "main" {
  name     = "production-eks"

  vpc_config {
    subnet_ids = module.vpc.private_subnet_ids  # Output from the vpc module
  }
}

Modules Are Abstraction #

The biggest advantage of modules isn’t just reusability — it’s abstraction. Module callers don’t need to know how the VPC is created, only what they need to provide and what they’ll get back.

FROM THE CALLER'S PERSPECTIVE:

  module "vpc" {
    source      = "../../modules/vpc"
    cidr_block  = "10.0.0.0/16"   ← Input: what you need to provide
    environment = "production"
  }

  module.vpc.vpc_id             ← Output: what you get back
  module.vpc.private_subnet_ids

  # The caller doesn't need to know:
  # - How many subnets are created internally
  # - How routing is configured
  # - In which AZ resources are placed
  # - All implementation details are hidden inside the module
flowchart TD
    A["🏗️ VPC Module\nReusable"] --> B["dev\nenvironment"]
    A --> C["staging\nenvironment"]
    A --> D["production\nenvironment"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#f59e0b,stroke:#d97706,color:#fff
    style D fill:#8b5cf6,stroke:#6d28d9,color:#fff

This lets teams work at different levels of abstraction: the platform team writes modules, the application team uses modules without needing to understand network details.


When to Write Modules #

Not every configuration needs to be wrapped in a module. There are signals that indicate it’s time to create a module.

TIME TO CREATE A MODULE IF:
  ✓ The same configuration is used in more than one place
    (at least 2-3 uses before abstraction is worth it)
  ✓ There's a group of resources always created together
    that make sense as one unit (VPC + subnet + IGW + NAT)
  ✓ Another team needs to use the same infrastructure
    without understanding the implementation details
  ✓ The configuration contains complex logic you want to hide

NO MODULE NEEDED YET IF:
  ✗ The configuration is only used in one place
  ✗ Resources have nothing to do with each other
  ✗ You just want to "organize" files — use separate files
    in the same directory (networking.tf, compute.tf, etc.)
  ✗ The module would only have 1-2 resources


Module Registries and Reusability #

Modules can be published to a registry for use by other teams or the community.

# Using a module from the Terraform Registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-southeast-1a", "ap-southeast-1b"]
  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
  single_nat_gateway = true
}

# Using a module from a private registry
module "app" {
  source = "app.terraform.io/my-org/app/aws"
  version = "~> 2.0"
}

# Using a module from Git
module "custom" {
  source = "git::ssh://[email protected]/my-org/terraform-modules.git//vpc?ref=v1.2.0"
}
flowchart TD
    A["Module\nSource"] --> B{"Registry?"}
    B -->|"Public"| C["registry.terraform.io\nterraform-aws-modules/vpc/aws"]
    B -->|"Private"| D["app.terraform.io\nmy-org/module/aws"]
    B -->|"Git"| E["git::github.com\nmy-org/modules.git//vpc"]
    B -->|"Local"| F["./modules/vpc\n(relative path)"]

    style A fill:#e3f2fd,stroke:#1565c0
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#e65100
    style E fill:#f3e5f5,stroke:#6a1b9a
    style F fill:#fce4ec,stroke:#c62828

Module Testing #

Modules should be tested before being used in production.

# Terraform test (built-in since v1.6)
# Create a test file: tests/vpc_test.tftest.hcl

# Run the tests
terraform test
# Output:
# tests/vpc_test.tftest.hcl... pass
#   "verify_vpc_cidr"... pass
#   "verify_subnet_count"... pass

# Test with Terratest (Go)
# go test -v -timeout 30m
# tests/vpc_test.tftest.hcl
run "verify_vpc" {
  command = plan

  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR should be 10.0.0.0/16"
  }

  assert {
    condition     = length(aws_subnet.private) == 2
    error_message = "Should have exactly 2 private subnets"
  }

  assert {
    condition     = aws_vpc.main.tags["Environment"] != ""
    error_message = "Environment tag is required"
  }
}

Module Input Design #

# GOOD: A module with clear, typed inputs
module "web_server" {
  source = "./modules/ec2-instance"
  
  name          = "web-server"
  instance_type = "t3.micro"
  subnet_id     = module.networking.public_subnet_ids[0]
  ami_id        = data.aws_ami.latest.id
  
  tags = {
    Environment = var.environment
  }
}

# Inside the module:
variable "name" {
  description = "Name tag for the instance"
  type        = string
}

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

variable "subnet_id" {
  description = "Subnet ID for launching the instance"
  type        = string
  
  validation {
    condition     = can(regex("^subnet-", var.subnet_id))
    error_message = "Must be a valid subnet ID."
  }
}

Module Output Design #

# Module outputs should:
# 1. Return all values consumers need
# 2. Use type annotations
# 3. Mark sensitive if needed

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

output "private_ip" {
  description = "Private IP address"
  value       = aws_instance.web.private_ip
}

output "security_group_id" {
  description = "Security group ID"
  value       = aws_security_group.web.id
}

# Sensitive output
output "password" {
  description = "Generated password"
  value       = random_password.result.result
  sensitive   = true
}

Module Refactoring #

# When a module gets too large, split it into sub-modules

# BEFORE: one big module
module "infrastructure" {
  source = "./modules/infrastructure"
  # 50+ variables...
}

# AFTER: modular structure
module "networking" {
  source     = "./modules/networking"
  vpc_cidr   = var.vpc_cidr
  azs        = var.availability_zones
}

module "compute" {
  source        = "./modules/compute"
  subnet_ids    = module.networking.private_subnet_ids
  instance_type = var.instance_type
}

module "database" {
  source     = "./modules/database"
  subnet_ids = module.networking.database_subnet_ids
  engine     = var.db_engine
}

Summary #

  • A module is a directory containing .tf files — every Terraform configuration is already a module; what distinguishes a child module is being called as one.
  • Three module components: variables.tf (inputs), main.tf (implementation), outputs.tf (what’s exported).
  • Modules are abstraction — callers only need to know the inputs and outputs, not the implementation details inside.
  • Call with a module block and access outputs with module.<name>.<output>.
  • The same module can be called many times with different values — this is what makes multi-environment configurations efficient.
  • Create modules when there’s a real need (at least 2-3 uses) — don’t abstract prematurely just because it feels “cleaner”.

← Previous: Datasource Anti-Pattern   Next: Structure →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact