Terraform Lesson 27 – Creating Reusable Modules | Dataplexa
Section III · Lesson 27

Creating Reusable Modules

You know what a module is. Now you build one from scratch — a real, reusable module that any project in your organisation can call. This lesson walks through the complete process: designing the interface, writing the resources, testing it locally, and calling it from a root configuration.

This lesson covers

What makes a module reusable → Standard module file structure → Building a reusable VPC module → Input variables with validation → Outputs that callers actually need → Calling the module from a root configuration → Testing locally before sharing

What Makes a Module Reusable

A module is reusable when it solves one well-defined problem, accepts inputs for everything that varies between uses, and hides implementation details behind a clean interface. A module that hard-codes values is not reusable — it is a copy. A module that exposes too many internals is fragile — every caller breaks when you refactor it.

The test of a good module interface: a caller should be able to use it without reading the source code. The variable descriptions and outputs should tell the complete story.

The Analogy

A reusable module is like an electrical socket in a wall. You do not need to know how the wiring behind the wall works — you just plug in your device. The socket has a standard interface. Any device that fits the interface works. If the electrician rewires the wall, your devices still work because the interface did not change. That is what a well-designed module does for infrastructure.

Standard Module File Structure

Every reusable module follows the same file layout. Terraform reads all .tf files in the directory regardless of name, but the convention is universal and every engineer who works with Terraform expects it.

# Standard reusable module directory layout

modules/
└── vpc/
    ├── README.md       # Documents what the module does, inputs, outputs, usage example
    ├── versions.tf     # required_terraform + required_providers — NO backend block ever
    ├── variables.tf    # All input variable declarations — the module's public interface
    ├── main.tf         # All resources and data sources
    ├── outputs.tf      # All output declarations — what callers can reference
    └── locals.tf       # Computed values derived from variables (optional)

# Root configuration that calls the module
root/
├── versions.tf         # Backend + provider configuration
├── main.tf             # Calls the module
└── outputs.tf          # Surfaces module outputs

The rule that cannot be broken

Modules must never contain a backend block. A module is called by a root configuration that already manages state. If a module declared its own backend, Terraform would attempt to initialise a second state store — which fails with an error. The backend lives only in the root configuration, never inside a module.

Building a Reusable VPC Module

We will build a VPC module that every project in the organisation can use. It creates a VPC, public and private subnets, an internet gateway, and route tables. The caller controls the CIDR ranges, environment name, and tags. Everything else is handled internally by the module.

mkdir -p terraform-lesson-27/modules/vpc
mkdir -p terraform-lesson-27/root
cd terraform-lesson-27

New terms:

  • validation block — inside a variable, runs a condition when the value is set. If false, Terraform shows the error_message and stops at plan time — before any API call is made. Use validations to catch configuration mistakes early.
  • can() — a function that returns true if an expression evaluates without error, false otherwise. Used in validations with functions like regex() and cidrnetmask() to test whether a value is valid without throwing an error.
  • toset() — converts a list to a set. Required when using a list as the value for for_each — sets have unique keys, lists do not.

Add this to modules/vpc/versions.tf:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"  # Use >= in modules — the root configuration controls the upper bound
    }
  }
  # No backend block — modules never declare a backend
}

Add this to modules/vpc/variables.tf:

# The module's public interface — every input a caller can supply

variable "name" {
  description = "Name prefix applied to all resources created by this module"
  type        = string

  validation {
    # Name used in AWS resource names — must be lowercase with hyphens only
    condition     = can(regex("^[a-z][a-z0-9-]*$", var.name))
    error_message = "name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens."
  }
}

variable "vpc_cidr" {
  description = "IPv4 CIDR block for the VPC — e.g. 10.0.0.0/16"
  type        = string
  default     = "10.0.0.0/16"

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))  # Validates legal CIDR notation
    error_message = "vpc_cidr must be a valid CIDR block such as 10.0.0.0/16."
  }
}

variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets — one per availability zone"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]

  validation {
    condition     = length(var.public_subnet_cidrs) >= 1  # At least one public subnet required
    error_message = "At least one public subnet CIDR must be provided."
  }
}

variable "private_subnet_cidrs" {
  description = "List of CIDR blocks for private subnets — one per availability zone"
  type        = list(string)
  default     = ["10.0.10.0/24", "10.0.11.0/24"]
}

variable "enable_dns_hostnames" {
  description = "Enable DNS hostnames in the VPC — required for EC2 public DNS names"
  type        = bool
  default     = true  # On by default — most workloads need this
}

variable "tags" {
  description = "Additional tags merged onto every resource the module creates"
  type        = map(string)
  default     = {}  # Empty by default — callers add their own tags here
}

Add this to modules/vpc/main.tf:

locals {
  # Common tags applied to every resource — caller-supplied tags merged in
  common_tags = merge(var.tags, {
    Module    = "vpc"        # Identifies which module created these resources
    ManagedBy = "Terraform"
  })
}

# Fetch available AZs for whichever region the caller's provider targets
data "aws_availability_zones" "available" {
  state = "available"  # Only AZs currently open — excludes zones under maintenance
}

# The VPC — the network boundary for all resources
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = var.enable_dns_hostnames
  enable_dns_support   = true  # Required for Route 53 private zones and ECS task DNS

  tags = merge(local.common_tags, {
    Name = "${var.name}-vpc"
  })
}

# Internet gateway — connects the VPC to the public internet
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id  # Implicit dependency: VPC must exist before IGW

  tags = merge(local.common_tags, {
    Name = "${var.name}-igw"
  })
}

# Public subnets — one per CIDR in var.public_subnet_cidrs
# toset() converts the list to a set so for_each can use it as map keys
resource "aws_subnet" "public" {
  for_each = toset(var.public_subnet_cidrs)

  vpc_id     = aws_vpc.this.id
  cidr_block = each.value  # Each subnet gets one CIDR from the list

  # Distribute across AZs by position — subnet 0 → AZ 0, subnet 1 → AZ 1, etc.
  availability_zone = data.aws_availability_zones.available.names[
    index(var.public_subnet_cidrs, each.value) % length(data.aws_availability_zones.available.names)
  ]

  map_public_ip_on_launch = true  # Instances in public subnets automatically get public IPs

  tags = merge(local.common_tags, {
    Name = "${var.name}-public-${index(var.public_subnet_cidrs, each.value) + 1}"
    Tier = "public"
  })
}

# Private subnets — no public IP assignment, no direct internet access
resource "aws_subnet" "private" {
  for_each = toset(var.private_subnet_cidrs)

  vpc_id     = aws_vpc.this.id
  cidr_block = each.value

  availability_zone = data.aws_availability_zones.available.names[
    index(var.private_subnet_cidrs, each.value) % length(data.aws_availability_zones.available.names)
  ]

  # map_public_ip_on_launch defaults to false — private subnets never expose public IPs

  tags = merge(local.common_tags, {
    Name = "${var.name}-private-${index(var.private_subnet_cidrs, each.value) + 1}"
    Tier = "private"
  })
}

# Public route table — routes all internet-bound traffic through the IGW
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"                # All non-local traffic
    gateway_id = aws_internet_gateway.this.id  # Goes out through the IGW
  }

  tags = merge(local.common_tags, {
    Name = "${var.name}-rt-public"
  })
}

# Associate every public subnet with the public route table
resource "aws_route_table_association" "public" {
  for_each = aws_subnet.public  # One association per public subnet resource

  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

# Private route table — no internet route, internal traffic only
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id

  # No routes added — add a NAT gateway route here if private instances need internet access

  tags = merge(local.common_tags, {
    Name = "${var.name}-rt-private"
  })
}

# Associate every private subnet with the private route table
resource "aws_route_table_association" "private" {
  for_each = aws_subnet.private

  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}

Add this to modules/vpc/outputs.tf:

# Expose IDs and CIDRs as plain strings — not whole objects — to minimise coupling

output "vpc_id" {
  description = "ID of the VPC — pass to any resource that must live in this network"
  value       = aws_vpc.this.id
}

output "vpc_cidr_block" {
  description = "CIDR block of the VPC — use in security group rules for internal traffic"
  value       = aws_vpc.this.cidr_block
}

output "public_subnet_ids" {
  description = "List of public subnet IDs — for load balancers and internet-facing resources"
  value       = [for s in aws_subnet.public : s.id]
}

output "private_subnet_ids" {
  description = "List of private subnet IDs — for databases, internal services, ECS tasks"
  value       = [for s in aws_subnet.private : s.id]
}

output "internet_gateway_id" {
  description = "ID of the internet gateway attached to this VPC"
  value       = aws_internet_gateway.this.id
}

output "private_route_table_id" {
  description = "ID of the private route table — use to add a NAT gateway route"
  value       = aws_route_table.private.id
}

Calling the Module from a Root Configuration

New terms:

  • source argument — required on every module block. A path starting with ./ or ../ points to a local module on disk. Local modules do not need a version argument.
  • module.NAME.OUTPUT — reads a module's output from the root configuration. Creates an implicit dependency — any resource referencing this expression waits for the module to complete before it is created.

Add this to root/versions.tf:

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}
provider "aws" { region = "us-east-1" }

Add this to root/main.tf:

# Call the reusable VPC module — callers only provide what varies
module "vpc" {
  source = "../modules/vpc"  # Relative path to the local module directory

  name    = "acme-dev"       # Required — no default in the module

  # Override defaults to match this environment's network design
  vpc_cidr             = "10.0.0.0/16"
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]

  tags = {
    Environment = "dev"
    Project     = "acme"
  }
}

# EC2 instance placed in the module's first public subnet
# This reference creates an implicit dependency on the vpc module
resource "aws_instance" "bastion" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  subnet_id = module.vpc.public_subnet_ids[0]  # First public subnet from the module

  tags = {
    Name        = "bastion"
    Environment = "dev"
    ManagedBy   = "Terraform"
  }
}
cd root
terraform init
terraform plan
$ terraform init

Initializing modules...
- vpc in ../modules/vpc

Initializing provider plugins...
- Installing hashicorp/aws v5.31.0...

$ terraform plan

Plan: 13 to add, 0 to change, 0 to destroy.

  # module.vpc.aws_vpc.this will be created
  + resource "aws_vpc" "this" {
      + cidr_block           = "10.0.0.0/16"
      + enable_dns_hostnames = true
    }

  # module.vpc.aws_internet_gateway.this will be created
  # module.vpc.aws_subnet.public["10.0.1.0/24"] will be created
      + availability_zone       = "us-east-1a"
      + map_public_ip_on_launch = true
  # module.vpc.aws_subnet.public["10.0.2.0/24"] will be created
      + availability_zone       = "us-east-1b"
  # module.vpc.aws_subnet.private["10.0.10.0/24"] will be created
  # module.vpc.aws_subnet.private["10.0.11.0/24"] will be created
  # module.vpc.aws_route_table.public
  # module.vpc.aws_route_table.private
  # module.vpc.aws_route_table_association.public (x2)
  # module.vpc.aws_route_table_association.private (x2)

  # aws_instance.bastion will be created
  + resource "aws_instance" "bastion" {
      + subnet_id = (known after apply)  # Will be module.vpc.public_subnet_ids[0]
    }

Outputs:
  + private_subnet_ids = (known after apply)
  + public_subnet_ids  = (known after apply)
  + vpc_id             = (known after apply)

What just happened?

  • terraform init registered the local module. The line - vpc in ../modules/vpc confirms Terraform found and cached the module. For local modules it creates a symlink in .terraform/modules/ — no file copying. Re-run init whenever you add a new module block or change a source path.
  • Module resources are namespaced with module.NAME. Every resource the module creates appears as module.vpc.aws_vpc.this — not aws_vpc.this. This namespacing prevents collisions when multiple modules create resources of the same type. The full Terraform state address always includes the module path.
  • The bastion instance waits for the module. subnet_id = module.vpc.public_subnet_ids[0] creates a dependency — Terraform will not start creating the instance until the VPC module's subnets are complete. The dependency is inferred automatically from the reference.

Common Mistakes When Creating Modules

Hardcoding values that should be variables

A module that hardcodes region = "us-east-1" or a specific CIDR can only be used in one way. Every value that could reasonably differ between callers should be a variable. The test: if a second team called this module, would they need a different value for this? If yes — it is a variable.

Forgetting terraform init after adding a module call

Every new module block requires terraform init before plan. Skip it and you get: Error: Module not installed. Run "terraform init" to install all modules required by this configuration.

Writing variables without descriptions

The description field is the documentation. It appears in the registry, in error messages, and in terraform console. A module with no descriptions forces callers to read the source to understand the interface. Write descriptions as if you will not be available to answer questions.

Practice Questions

1. You have a module block named "vpc" with an output called "vpc_id". What expression reads this output?



2. You add a new module block to main.tf. Before running terraform plan, what command must you run?



3. Should a reusable module ever contain a terraform backend block?



Quiz

1. How does Terraform distinguish between required and optional module inputs?


2. What is the best practice for module outputs when callers need to reference created resources?


3. When should you extract infrastructure into a reusable module rather than just splitting into separate .tf files?


Up Next · Lesson 28

Module Versioning

You have built a reusable module. Lesson 28 covers how to version it — semantic versioning for Terraform modules, publishing to the Terraform Registry, using Git tags as version pins for private modules, and how callers safely upgrade between versions without breaking their infrastructure.