---
title: "Terraform Modules: Structure, Composition, and Reuse"
description: "Building reusable Terraform modules with proper structure, versioning, composition patterns, and testing fundamentals."
url: https://agent-zone.ai/knowledge/infrastructure/terraform-modules/
section: knowledge
date: 2026-02-22
categories: ["infrastructure"]
tags: ["terraform","modules","hcl","reusability","terratest"]
skills: ["terraform-modules","infrastructure-as-code"]
tools: ["terraform","terratest","go"]
levels: ["intermediate"]
word_count: 775
formats:
  json: https://agent-zone.ai/knowledge/infrastructure/terraform-modules/index.json
  html: https://agent-zone.ai/knowledge/infrastructure/terraform-modules/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Terraform+Modules%3A+Structure%2C+Composition%2C+and+Reuse
---


## What Modules Are

A Terraform module is a directory containing `.tf` files. Every Terraform configuration is already a module (the "root module"). When you call another module from your root module, that is a "child module." Modules let you encapsulate a set of resources behind a clean interface of input variables and outputs.

## Module Structure

A well-organized module looks like this:

```
modules/vpc/
  main.tf           # resource definitions
  variables.tf      # input variables
  outputs.tf        # output values
  versions.tf       # required providers and terraform version
  README.md         # usage documentation
```

The module itself has no backend, no provider configuration, and no hardcoded values. Everything configurable comes in through variables. Everything downstream consumers need comes out through outputs.

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

variable "cidr" {
  type        = string
  description = "CIDR block for the VPC"
}

variable "private_subnets" {
  type        = list(string)
  description = "List of private subnet CIDR blocks"
}

variable "public_subnets" {
  type        = list(string)
  description = "List of public subnet CIDR blocks"
}

variable "azs" {
  type        = list(string)
  description = "Availability zones"
}
```

```hcl
# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  tags                 = { Name = var.name }
}

resource "aws_subnet" "private" {
  for_each          = { for i, cidr in var.private_subnets : var.azs[i] => cidr }
  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value
  availability_zone = each.key
  tags              = { Name = "${var.name}-private-${each.key}" }
}

resource "aws_subnet" "public" {
  for_each                = { for i, cidr in var.public_subnets : var.azs[i] => cidr }
  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value
  availability_zone       = each.key
  map_public_ip_on_launch = true
  tags                    = { Name = "${var.name}-public-${each.key}" }
}
```

```hcl
# modules/vpc/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

output "private_subnet_ids" {
  value = [for s in aws_subnet.private : s.id]
}

output "public_subnet_ids" {
  value = [for s in aws_subnet.public : s.id]
}
```

## Calling Modules

From the root module, call your module and wire it to others:

```hcl
module "vpc" {
  source          = "./modules/vpc"
  name            = "prod"
  cidr            = "10.0.0.0/16"
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]
  azs             = ["us-east-1a", "us-east-1b"]
}

module "ecs_cluster" {
  source     = "./modules/ecs"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}
```

## Module Sources

Modules can come from multiple locations:

```hcl
# Local path
module "vpc" {
  source = "./modules/vpc"
}

# Terraform Registry (versioned)
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.1"
}

# Git repository with tag
module "vpc" {
  source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v2.1.0"
}

# Git over SSH
module "vpc" {
  source = "git::ssh://git@github.com/myorg/terraform-modules.git//vpc?ref=v2.1.0"
}
```

Always pin versions. For registry modules, use `version`. For Git sources, use `?ref=` with a tag. Never point at `main` branch for production.

## Using terraform-aws-modules

The `terraform-aws-modules` GitHub organization provides battle-tested modules for most AWS services. They handle edge cases you probably have not thought of:

```hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.1"

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

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway   = true
  single_nat_gateway   = false
  enable_dns_hostnames = true
}
```

These modules handle NAT gateways, route tables, internet gateways, and tagging. Read their inputs carefully -- defaults may not match your security requirements.

## Module Composition Patterns

Instead of one giant module, create focused modules and compose them:

```hcl
module "vpc"      { source = "./modules/vpc"      ... }
module "alb"      { source = "./modules/alb"      vpc_id = module.vpc.vpc_id ... }
module "ecs"      { source = "./modules/ecs"      vpc_id = module.vpc.vpc_id ... }
module "rds"      { source = "./modules/rds"      subnet_ids = module.vpc.private_subnet_ids ... }
```

The root module is the composition layer. Child modules communicate only through the root module wiring outputs to inputs.

## Module Best Practices

**No hardcoded values.** Every region, account ID, CIDR, name, and size should be a variable.

**Explicit outputs.** If a downstream module or a human might need a value, output it.

**No provider blocks in child modules.** Let the root module configure providers. Child modules inherit them. Pass provider aliases via the `providers` argument.

**Validate inputs.** Use `validation` blocks to catch errors early:

```hcl
variable "cidr" {
  type = string
  validation {
    condition     = can(cidrhost(var.cidr, 0))
    error_message = "Must be a valid CIDR block."
  }
}
```

## Testing with Terratest

Terratest is a Go library that applies your Terraform, validates the infrastructure, and destroys it:

```go
package test

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

func TestVpcModule(t *testing.T) {
    opts := &terraform.Options{
        TerraformDir: "../modules/vpc",
        Vars: map[string]interface{}{
            "name":            "test-vpc",
            "cidr":            "10.99.0.0/16",
            "private_subnets": []string{"10.99.1.0/24"},
            "public_subnets":  []string{"10.99.101.0/24"},
            "azs":             []string{"us-east-1a"},
        },
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    vpcId := terraform.Output(t, opts, "vpc_id")
    assert.Contains(t, vpcId, "vpc-")
}
```

Run with `go test -v -timeout 30m ./test/`. Tests create real infrastructure, so run them in an isolated account. The `defer terraform.Destroy` ensures cleanup even if assertions fail. Reserve full Terratest runs for nightly or pre-release pipelines.

