Skip to content
Codeloom
DevOps

Terraform Modules for Reusable Infrastructure

Learn how to build, structure, and publish Terraform modules to create reusable, composable infrastructure as code.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why Terraform modules matter for scaling IaC
  • How to structure a module with inputs, outputs, and defaults
  • Composing modules together for full environments
  • Versioning and publishing modules to a registry

Prerequisites

  • Basic Terraform knowledge (resources, providers, state)
  • Comfortable with HCL syntax

Why Modules

When your Terraform codebase grows past a few hundred lines, you start copy-pasting resource blocks across projects. A VPC here, an RDS instance there, each with slightly different variable names and forgotten security group rules. Modules solve this by letting you package a set of resources into a reusable, versioned unit with a clean interface.

Think of a module like a function in programming. It takes inputs (variables), does something internally (creates resources), and returns outputs. The caller does not need to know the implementation details. They just pass in a CIDR block and get a fully configured VPC back.

Module Structure

A well-structured module lives in its own directory with three core files:

# modules/vpc/variables.tf
variable "name" {
  description = "Name prefix for all resources"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "az_count" {
  description = "Number of availability zones to use"
  type        = number
  default     = 2
}

variable "enable_nat_gateway" {
  description = "Whether to create NAT gateways for private subnets"
  type        = bool
  default     = true
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.name}-vpc"
  }
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_subnet" "public" {
  count             = var.az_count
  vpc_id            = aws_vpc.this.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name = "${var.name}-public-${count.index}"
    Tier = "public"
  }
}

resource "aws_subnet" "private" {
  count             = var.az_count
  vpc_id            = aws_vpc.this.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index + var.az_count)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.name}-private-${count.index}"
    Tier = "private"
  }
}

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

  tags = {
    Name = "${var.name}-igw"
  }
}
# modules/vpc/outputs.tf
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
}

The key convention is that every variable has a description and a sensible default where possible, and every output has a description. This makes the module self-documenting.

Calling a Module

From your root configuration, you call the module like this:

# environments/production/main.tf
module "network" {
  source = "../../modules/vpc"

  name               = "prod"
  cidr_block         = "10.0.0.0/16"
  az_count           = 3
  enable_nat_gateway = true
}

module "database" {
  source = "../../modules/rds"

  name              = "prod-db"
  subnet_ids        = module.network.private_subnet_ids
  vpc_id            = module.network.vpc_id
  instance_class    = "db.r6g.large"
  engine_version    = "15.4"
  allocated_storage = 100
}

Notice how module.network.private_subnet_ids flows directly into the database module. This is composition: modules producing outputs that become inputs for other modules.

Composing Modules Together

Real infrastructure is built from layers of modules. A common pattern is a “stack” module that composes lower-level modules:

# stacks/web-app/main.tf
module "network" {
  source     = "../../modules/vpc"
  name       = var.environment
  cidr_block = var.vpc_cidr
  az_count   = var.az_count
}

module "cluster" {
  source            = "../../modules/ecs-cluster"
  name              = var.environment
  vpc_id            = module.network.vpc_id
  private_subnet_ids = module.network.private_subnet_ids
}

module "service" {
  source         = "../../modules/ecs-service"
  name           = "${var.environment}-api"
  cluster_id     = module.cluster.cluster_id
  container_image = var.api_image
  container_port  = 8080
  desired_count   = var.api_replicas
  subnet_ids     = module.network.private_subnet_ids
}

module "database" {
  source        = "../../modules/rds"
  name          = "${var.environment}-db"
  subnet_ids    = module.network.private_subnet_ids
  vpc_id        = module.network.vpc_id
  instance_class = var.db_instance_class
}

Now spinning up a full environment is a single terraform apply with different variable files for staging versus production.

Validation and Preconditions

Terraform 1.2 and later supports validation blocks and preconditions that make modules more robust:

variable "cidr_block" {
  type        = string
  description = "VPC CIDR block"

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "Must be a valid CIDR block."
  }
}

variable "instance_class" {
  type        = string
  description = "RDS instance class"

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

Preconditions inside resources catch runtime issues:

resource "aws_db_instance" "this" {
  # ...

  lifecycle {
    precondition {
      condition     = var.allocated_storage >= 20
      error_message = "Minimum storage for production databases is 20 GB."
    }
  }
}

Versioning Modules

When modules are shared across teams, you need versioning. The two main approaches are Git tags and a Terraform registry.

With Git tags, you pin a specific version in the source:

module "vpc" {
  source = "git::https://github.com/your-org/terraform-modules.git//modules/vpc?ref=v2.1.0"
  # ...
}

With a private registry (Terraform Cloud, Spacelift, or even S3-backed), you use semantic versioning:

module "vpc" {
  source  = "app.terraform.io/your-org/vpc/aws"
  version = "~> 2.1"
  # ...
}

The ~> constraint means “any 2.x release at 2.1 or higher.” This lets you receive patch fixes without unexpected major changes.

Testing Modules

You should test modules before publishing. Terratest (Go) and terraform test (built-in since Terraform 1.6) are the main options:

# tests/vpc.tftest.hcl
run "creates_vpc_with_correct_cidr" {
  command = plan

  variables {
    name       = "test"
    cidr_block = "10.99.0.0/16"
    az_count   = 2
  }

  assert {
    condition     = aws_vpc.this.cidr_block == "10.99.0.0/16"
    error_message = "VPC CIDR block does not match input"
  }
}

run "creates_correct_number_of_subnets" {
  command = plan

  variables {
    name     = "test"
    az_count = 3
  }

  assert {
    condition     = length(aws_subnet.public) == 3
    error_message = "Expected 3 public subnets"
  }
}

Run with terraform test from the module directory. Plan-only tests are fast and do not require cloud credentials in CI.

Common Mistakes

Hardcoding provider configuration inside modules. The caller should configure providers. Modules should declare required_providers but never set region or profile.

Too many variables. If your module has forty variables, it is doing too much. Split it into smaller, focused modules.

No outputs. If downstream modules cannot reference your resources, they will resort to data sources or hardcoded IDs, which defeats the purpose of composition.

Skipping validation. Without input validation, users discover misconfiguration at apply time instead of plan time. Add validation blocks to catch bad inputs early.

What to Do Next

Start by extracting one repeated pattern from your codebase into a module. A VPC or a security group is a good first candidate. Add variables, outputs, and a README. Once it works, version it with a Git tag and reference it from a second project. That single loop will teach you more about module design than any tutorial can.