TerraformXIV · Modules: Reusable Building BlocksProduction Terraform
Module Structure and Conventions
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
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— thevariableblocks. The module’s input interface.outputs.tf— theoutputblocks. The module’s output interface.versions.tf— theterraformblock withrequired_versionandrequired_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— thedatasources the module reads. A module that reads AMI IDs or current AWS caller identity puts thedatablocks here.locals.tf— thelocalsblocks. Computed values reused across the module.providers.tf— theproviderblocks. 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 invariables.tfand to pass values from the consumer.backend.tf— thebackendblock. 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
| Name | Type | Description | Default | Required |
|---|---|---|---|---|
vpc_cidr | string | The CIDR block for the VPC. | n/a | yes |
environment | string | The environment name. | n/a | yes |
enable_flow_logs | bool | Whether to enable VPC flow logs. | true | no |
Outputs
| Name | Description |
|---|---|
vpc_id | The ID of the VPC. |
public_subnet_ids | The public subnets, keyed by AZ. |
private_subnet_ids | The 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:
-
Single
main.tfover 500 lines. The reader cannot find the resources. The split is missing. The fix is to split by logical responsibility. -
providers.tfblocks in a published module. The module overrides the consumer’s provider configuration. The fix is to remove theproviderblock from the module and let the consumer configure the provider. -
backend.tfin a published module. The module forces a backend. The consumer cannot use the module’s state. The fix is to remove thebackendblock. -
terraform.tfvarsin the module. The Terraform Registry rejects the publication. The values should be passed from the consumer. -
No
versions.tfblock. The module does not declare its compatibility. The consumer’sterraform initsucceeds with an incompatible Terraform version. The fix is to add therequired_versionandrequired_providersconstraints. -
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.tffile prevents the consumer from applying the module with a provider version that has known CVEs. Therequired_providersconstraint is mandatory. - The
LICENSEfile 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.mdshould 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 -recursivereturns zero against the module directory. -
terraform validatereturnsSuccess! The configuration is valid. - The module has at least one example in the
examples/directory. - The
versions.tffile declaresrequired_versionandrequired_providers. - The README is generated by
terraform-docsand matches the variable declarations.
Knowledge check · 6 questions
Q1. Which file declares the module's required Terraform and provider versions?
Q2. Terraform requires a module to be split into main.tf, variables.tf, and outputs.tf.
Q3. Why is the examples/ directory required for a module published to the Terraform Registry?
Q4. A module has a 700-line main.tf. What is the right fix?
Q5. Which files should NOT appear in a module that is published to the Terraform Registry? (Select all that apply.)
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.