Skip to main content
RunBook Academy

TerraformXIV · Modules: Reusable Building BlocksProduction Terraform

Module Structure and Conventions

Foundation⏱ ~14 minbash

What you'll learn

  • Describe the standard files in a Terraform module and what each one is for
  • Split a module by logical responsibility rather than by file size
  • Use the examples/ directory to make a module testable
  • Document a module for the consumer who has never read the source

Prerequisites

None — start here.

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-13

Not yet marked complete on this device.

A Terraform module is a directory. The Terraform toolchain does not require any particular file inside the directory — every .tf file is loaded, regardless of name. The conventions in this lesson are the ones HashiCorp publishes and the ones the Terraform Registry expects. Consumers recognise the conventions. CI tooling assumes them. The terraform test framework expects them. The cost of diverging from the conventions is paid by every reader who ever looks at the module.

The minimum viable module

The simplest module that will be consumed by another configuration has four files:

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

Each file has a role:

  • main.tf — the resource declarations. The actual resources the module creates.
  • variables.tf — the variable blocks. The module’s input interface.
  • outputs.tf — the output blocks. The module’s output interface.
  • versions.tf — the terraform block with required_version and required_providers. The compatibility contract.

The split is logical, not compulsory. Terraform will load everything.tf if that is what the file is called. The split exists to make the module readable. A reader who opens variables.tf knows exactly what the input interface is. A reader who opens main.tf knows exactly where the resources live.

A larger module

A module with non-trivial resource count is split by logical responsibility. The file name describes the category of resources:

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 and route table associations
├── security.tf          # the network ACLs and default security group
├── variables.tf         # the input declarations
├── outputs.tf           # the output declarations
├── versions.tf          # the terraform block
├── README.md            # the documentation
└── examples/
    └── simple/
        ├── main.tf
        └── variables.tf

The file name is a label. The reader who wants to find the subnets opens subnets.tf. The reader who wants to find the route tables opens routes.tf. The reader who wants the contract opens variables.tf and outputs.tf. The file split is the table of contents.

The split is by logical responsibility, not by line count. A subnets.tf file with 80 lines is fine. A main.tf with 200 lines is a sign that the module is doing too much.

The optional files

Some files appear in modules that need them and not in modules that do not:

  • data.tf — the data sources the module reads. A module that reads AMI IDs or current AWS caller identity puts the data blocks here.
  • locals.tf — the locals blocks. Computed values reused across the module.
  • providers.tf — the provider blocks. Only required when the module configures its own provider aliases or overrides. Most modules do not have this file.
  • terraform.tfvars — variable values for the module itself. The Terraform Registry rejects this file when publishing. The convention is to keep variables in variables.tf and to pass values from the consumer.
  • backend.tf — the backend block. Modules that are applied as their own root module configure the backend here. Modules consumed by other modules do not have a backend.

A practical rule: if a file contains only one block, put it in main.tf. If a file’s contents would make main.tf longer than a hundred lines, split it.

The versions.tf file

The versions.tf file is the most underappreciated file in a module. It declares the compatibility contract:

# modules/network/versions.tf

terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.40.0, < 6.0.0"
    }
  }
}

The required_version constraint prevents the consumer from applying the module with a Terraform version that has not been tested. The required_providers block prevents the consumer from using a provider version with breaking changes that the module has not been adapted to.

The versions.tf file is the contract that protects the consumer from the module author’s surprises. The constraint range is set conservatively. The upper bound is bumped deliberately. The lower bound is bumped after the deprecation deadline.

The examples/ directory

The examples/ directory is the consumer’s onboarding path. Every module published to the Terraform Registry must have at least one example. The example is the first thing the consumer reads:

modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
└── examples/
    ├── simple/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── complete/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Each example is a complete root module that the consumer can copy and adapt. The simple/ example shows the minimum viable usage. The complete/ example shows every optional input. The convention is the same as the HashiCorp-published modules use.

The examples are also used by the terraform test framework. A test that runs terraform plan against the simple/ example verifies the module is wired correctly without needing cloud credentials.

The README.md

The README is the documentation. The README is the contract the consumer reads before reading the source. A module with a high-quality README has a low-quality requirement for the consumer to read the source. A module without a README forces every consumer to read the source.

The README contains:

# Network module

Creates a VPC with public and private subnets across
three availability zones, with NAT gateway egress for
private subnets and flow logs delivered to a central
S3 bucket.

## Usage

```hcl
module "network" {
  source = "./modules/network"

  vpc_cidr        = "10.0.0.0/16"
  environment     = "production"
  enable_flow_logs = true
}

Inputs

NameTypeDescriptionDefaultRequired
vpc_cidrstringThe CIDR block for the VPC.n/ayes
environmentstringThe environment name.n/ayes
enable_flow_logsboolWhether to enable VPC flow logs.trueno

Outputs

NameDescription
vpc_idThe ID of the VPC.
public_subnet_idsThe public subnets, keyed by AZ.
private_subnet_idsThe private subnets, keyed by AZ.

The README is generated by `terraform-docs` in most
shops. The generation is part of the CI pipeline. The
README is regenerated on every release. The contract is
always in sync with the source.

## A complete module skeleton

The full skeleton of a module that will be published to
the Terraform Registry:

```text
modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
├── LICENSE
├── examples/
│   ├── simple/
│   │   ├── main.tf
│   │   └── variables.tf
│   └── complete/
│       ├── main.tf
│       └── variables.tf
└── tests/
    └── network.tftest.hcl

The LICENSE file is required by the Registry. The tests/ directory is the native test framework. The examples/ directory is the contract demonstration. Every file has a purpose.

Validation commands

The reader validates the module locally before publishing:

# Severity: READ-ONLY
terraform fmt -check -recursive
modules/network/main.tf
modules/network/variables.tf

The -check flag exits non-zero if any file is not formatted. The -recursive flag checks the module directory and the examples/ directory.

# Severity: READ-ONLY
terraform validate
Success! The configuration is valid.

The validate command checks syntax and types. It does not call the provider. It does not require credentials.

# Severity: READ-ONLY
terraform test
tests/network.tftest.hcl... in progress
run "basic"... pass
run "with_flow_logs"... pass

Success! 2 passed.

The terraform test command runs the test files in the tests/ directory. The lesson on testing covers the test framework in detail.

Production failure modes

The most common structural failures in modules:

  1. Single main.tf over 500 lines. The reader cannot find the resources. The split is missing. The fix is to split by logical responsibility.

  2. providers.tf blocks in a published module. The module overrides the consumer’s provider configuration. The fix is to remove the provider block from the module and let the consumer configure the provider.

  3. backend.tf in a published module. The module forces a backend. The consumer cannot use the module’s state. The fix is to remove the backend block.

  4. terraform.tfvars in the module. The Terraform Registry rejects the publication. The values should be passed from the consumer.

  5. No versions.tf block. The module does not declare its compatibility. The consumer’s terraform init succeeds with an incompatible Terraform version. The fix is to add the required_version and required_providers constraints.

  6. No examples/ directory. The consumer cannot see the module in use. The Registry rejects the publication. The fix is to add at least one example.

Security implications

The module structure has clear security implications:

  • The versions.tf file prevents the consumer from applying the module with a provider version that has known CVEs. The required_providers constraint is mandatory.
  • The LICENSE file is a legal commitment. The chosen licence affects redistribution rights. Apache 2.0, MPL 2.0, and BSD-3-Clause are the common choices.
  • The README.md should not contain secrets. The Registry publishes the README. Anything in the README is public.
  • The examples/ directory is also public. The example should not reference real ARNs, real account IDs, or real resource names.

Performance implications

A larger module takes longer to parse. The cost is small — Terraform 1.9 parses a 2000-line module in under 100 milliseconds. The cost that matters is the cognitive cost. A 2000-line main.tf is hard to read. A 200-line main.tf is readable. The split is for the reader.

The examples/ directory is parsed by the Registry and by the terraform test framework. Each example is a root module. The total cost is the sum of the examples. Limit examples to three or four.

What comes next

The next lesson is the module interface: the variables and outputs that form the module’s contract with the consumer.

Verification

  • terraform fmt -check -recursive returns zero against the module directory.
  • terraform validate returns Success! The configuration is valid.
  • The module has at least one example in the examples/ directory.
  • The versions.tf file declares required_version and required_providers.
  • The README is generated by terraform-docs and matches the variable declarations.

Knowledge check · 6 questions

  1. Q1. Which file declares the module's required Terraform and provider versions?

  2. Q2. Terraform requires a module to be split into main.tf, variables.tf, and outputs.tf.

  3. Q3. Why is the examples/ directory required for a module published to the Terraform Registry?

  4. Q4. A module has a 700-line main.tf. What is the right fix?

  5. Q5. Which files should NOT appear in a module that is published to the Terraform Registry? (Select all that apply.)

  6. Q6. A team publishes a module. The README's inputs table does not match the variables.tf declarations. What is the right fix?

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