Skip to main content
RunBook Academy

TerraformXIV · Modules: Reusable Building BlocksModules

Modules: Reusable Building Blocks in Depth

Intermediate⏱ ~16 minbashterraformgit

What you'll learn

  • Structure a module for maintainability
  • Design a module interface that supports reuse
  • Distinguish good module abstraction from premature abstraction
  • Avoid the traps of god modules and over-engineered generics

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

A module is a directory of Terraform configuration. The convention is to split the module into logical files, but Terraform itself does not require it. The lesson teaches the production patterns: file structure, interface design, and the traps.

File structure

A simple module:

modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf

The role of each file:

  • main.tf — the resource declarations. The main resources.
  • variables.tf — the input declarations. The user interface.
  • outputs.tf — the output declarations. The user-visible results.
  • versions.tf — the terraform block with the required_version and required_providers.

A complex module:

modules/network/
├── main.tf              # the VPC
├── subnets.tf           # the public and private subnets
├── gateway.tf          # the internet gateway and NAT gateway
├── routes.tf           # the route tables
├── variables.tf         # the variables
├── outputs.tf           # the outputs
├── versions.tf          # the terraform block
└── README.md            # the documentation

The split is by logical responsibility. The reader can find the resource by category.

The Terraform documentation is the source of truth for the convention. The platforms modules follow it.

The interface

The modules interface is the variables and outputs. The interface is the contract between the module and its callers.

# modules/network/variables.tf

variable "vpc_cidr" {
  type        = string
  description = "The CIDR block for the VPC."
  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "Must be a valid CIDR block."
  }
}

variable "environment" {
  type        = string
  description = "The environment name (dev, staging, prod)."

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be one of dev, staging, prod."
  }
}

variable "availability_zones" {
  type        = list(string)
  description = "List of availability zones to span."
  default     = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

The interface is small. The three variables are what the consumer needs to specify; the defaults are reasonable.

Good interface design

A good module interface:

  • Has a single responsibility. The module does one thing, well.
  • Has a small number of inputs. A module with 30 inputs is a code smell.
  • Has a small number of outputs. A module that exposes every underlying resource is a thin wrapper.
  • Has documented inputs and outputs. Every variable has a description.
  • Has validation. Every variable has a validation block for inputs that need it.
  • Has sensible defaults. A variable that is always overridden is a sign that the default should be different.

The local-exec trap

A common module antipattern is to use local-exec or remote-exec to perform configuration:

# Bad: configuration in a module
resource "null_resource" "configure" {
  provisioner "local-exec" {
    command = "aws s3api put-bucket-policy --bucket ${var.bucket_name} --policy file://policy.json"
  }
}

The null_resource is a Terraform antipattern. The local-exec is a Terraform antipattern. Both together are a production antipattern.

Use Ansible, a CI/CD pipeline, or a custom provider for configuration. Modules should declare resources, not configure them.

God modules

A god module is a module that does everything:

# Bad: god module
module "production" {
  source = "./modules/production"

  vpc_cidr          = "10.0.0.0/16"
  vpc_name          = "production"
  public_subnets    = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets   = ["10.0.10.0/24", "10.0.11.0/24"]
  database_subnets  = ["10.0.20.0/24", "10.0.21.0/24"]
  instance_type     = "t3.medium"
  instance_count    = 5
  database_engine   = "postgres"
  database_version  = "15"
  database_password = var.db_password
  cluster_name      = "production-cluster"
  log_retention_days = 30
  # ... 30 more inputs
}

The god module has too many inputs and too many outputs. The consumer does not know which attributes are interesting. The module is not reusable.

The fix is to split the god module into focused modules:

# Good: focused modules
module "network" {
  source = "./modules/network"
  vpc_cidr = "10.0.0.0/16"
  environment = "production"
}

module "compute" {
  source = "./modules/compute"
  vpc_id = module.network.vpc_id
  subnet_id = module.network.public_subnet_ids["a"]
}

module "database" {
  source = "./modules/database"
  vpc_id = module.network.vpc_id
  subnet_id = module.network.database_subnet_ids["a"]
}

Each module has a single responsibility. The consumer picks the modules they need.

Premature abstraction

A common trap is to over-abstract. A wrapper module that exposes every underlying resource is not a module; it is a thin wrapper.

# Bad: wrapper module
module "instance" {
  source = "./modules/instance"

  ami           = "ami-0e1bed4f"
  instance_type = "t3.medium"
  tags = {
    Name = "web-01"
  }
}
# modules/instance/main.tf
resource "aws_instance" "main" {
  ami           = var.ami
  instance_type = var.instance_type
  tags          = var.tags
}

This module adds nothing. The consumer could have declared the resource directly. The module is a tax.

The fix is to delete the module. The consumer declares the resource directly.

Module documentation

The module has a README.md:

# Network module

Creates a VPC with public and private subnets.

## Inputs

| Name | Type | Description | Default |
|------|------|-------------|---------|
| `vpc_cidr` | `string` | The CIDR block for the VPC. | n/a |
| `environment` | `string` | The environment name. | n/a |
| `availability_zones` | `list(string)` | List of availability zones. | `["us-east-1a", "us-east-1b", "us-east-1c"]` |

## Outputs

| Name | Description |
|------|-------------|
| `vpc_id` | The ID of the VPC. |
| `public_subnet_ids` | The public subnets, keyed by availability zone. |
| `private_subnet_ids` | The private subnets, keyed by availability zone. |

The README is the documentation. The README is the contract between the module and its consumers.

What comes next

The next lesson is module interfaces — the design of the inputs and outputs that make a module reusable.

Verification

Knowledge check · 7 questions

  1. Q1. What is a module?

  2. Q2. What is the role of module inputs?

  3. Q3. Modules should be tracked from main branch.

  4. Q4. What is a god module?

  5. Q5. Which of the following are characteristics of a good module interface? (Select all that apply.)

  6. Q6. What is the role of module versions?

  7. Q7. A module upgrade changes a variable default. The plan proposes to recreate many resources. What is the fix?

Passing score: 75%. Answers are checked in this browser.