Structure #
A well-written module isn’t just about correct code — it’s about code that’s easy for other people to understand and use. Consistent file structure, an informative README, and clear naming are the difference between a module that’s immediately trusted and used, and a module that makes people hesitate and eventually rewrite it themselves. This article covers the structural conventions widely accepted in the Terraform community.
flowchart TD
subgraph MOD["Module Structure"]
A["main.tf\nMain resources"]
B["variables.tf\nInput variables"]
C["outputs.tf\nOutput values"]
D["versions.tf\nProvider & TF version"]
end
A --> E["🏗️ Infrastructure\nResources"]
B --> A
A --> C
style MOD fill:#f8f9fa,stroke:#495057
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#f59e0b,stroke:#d97706,color:#fff
style D fill:#8b5cf6,stroke:#6d28d9,color:#fffStandard File Structure #
The Terraform community and HashiCorp itself recommend a consistent minimal structure for every module.
modules/vpc/
├── main.tf ← Main resources — the module's core content
├── variables.tf ← All input variables with complete descriptions
├── outputs.tf ← All outputs with usage descriptions
├── versions.tf ← Terraform and provider version constraints
└── README.md ← Documentation: usage, inputs, outputs, examples
Additional files that may be needed for more complex modules:
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
├── locals.tf ← Local values used throughout the module
├── data.tf ← Data sources the module needs
└── examples/ ← Runnable usage examples
├── basic/
│ ├── main.tf
│ └── README.md
└── complete/
├── main.tf
└── README.md
versions.tf — Module Version Constraints #
Every module should define the minimum Terraform and provider versions it needs. This prevents the module from running on incompatible versions.
# modules/vpc/versions.tf
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
# Use >= rather than ~> in modules — let the root module pin specific versions
# Modules that are too strict about versions make life hard for users
}
}
}
In modules, use the>=constraint (minimum version) instead of~>(pessimistic constraint). The root module is responsible for choosing the specific version — a module only needs to state the minimum version required for the features it uses to be available.
A Useful README.md #
The README is the first thing someone sees before deciding whether to use a module. A good README answers three questions: what this module does, how to use it, and what you’ll get.
# Module: VPC
This module creates a standard VPC with public and private subnets,
an Internet Gateway, a NAT Gateway, and routing tables configured
correctly for each tier.
## Usage
```hcl
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
environment = "production"
public_subnet_count = 3
private_subnet_count = 3
}
Inputs #
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
cidr_block | CIDR block for the VPC | string | — | Yes |
environment | Environment name | string | — | Yes |
public_subnet_count | Number of public subnets | number | 2 | No |
private_subnet_count | Number of private subnets | number | 2 | No |
Outputs #
| Name | Description |
|---|---|
vpc_id | ID of the created VPC |
public_subnet_ids | List of public subnet IDs |
private_subnet_ids | List of private subnet IDs |
Requirements #
| Name | Version |
|---|---|
| terraform | >= 1.3.0 |
| aws | >= 5.0 |
---
## Organizing Modules in a Repository
There are two common approaches to organizing modules: monorepo and multirepo. For teams just starting out, a monorepo is the simpler starting point.
MONOREPO — all modules in one repository:
infrastructure/ ├── modules/ │ ├── vpc/ │ │ ├── main.tf │ │ ├── variables.tf │ │ ├── outputs.tf │ │ └── README.md │ ├── eks/ │ │ └── … │ ├── rds/ │ │ └── … │ └── iam-role/ │ └── … └── environments/ ├── dev/ │ └── main.tf ← source = “../../modules/vpc” ├── staging/ │ └── main.tf └── production/ └── main.tf
MULTIREPO — each module in a separate repository:
github.com/org/terraform-module-vpc/ ├── main.tf ├── variables.tf ├── outputs.tf └── README.md
github.com/org/terraform-module-eks/ └── …
Callers use the Git URL as the source: #
module “vpc” { source = “git::https://github.com/org/terraform-module-vpc.git?ref=v2.1.0”
… #
}
```mermaid
flowchart TD
A["Root Module"] -->|"source, variables"| B["modules/vpc"]
A -->|"source, variables"| C["modules/compute"]
B -->|"vpc_id, subnet_ids"| C
B -->|"outputs"| D["📡 Outputs to\nother infra"]
C -->|"instance_ids"| D
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#f59e0b,stroke:#d97706,color:#fff
style D fill:#8b5cf6,stroke:#6d28d9,color:#fff
Internal Module Naming Conventions #
Inside a module, there are naming conventions that help consistency and readability.
# CONVENTION: Use "this" for the main resource in single-purpose modules
# (modules created for one main resource type)
resource "aws_vpc" "this" { # Not "main" or a specific name
cidr_block = var.cidr_block
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
}
# Outputs use descriptive names from the caller's perspective
output "vpc_id" {
value = aws_vpc.this.id # Not "this_id" — clearer for callers
}
# CONVENTION: Prefix resources with the module name if there's ambiguity
# (modules that create several resource types)
resource "aws_security_group" "web" { # "web" is more descriptive than "this"
name = "${var.name}-web-sg"
}
resource "aws_security_group" "db" {
name = "${var.name}-db-sg"
}
Complex Module Structure #
For larger modules, consider splitting the logic into several files by component.
modules/eks-cluster/
├── main.tf ← Main EKS cluster resources
├── node-groups.tf ← Node group resources
├── iam.tf ← IAM roles and policies
├── security-groups.tf ← Security group resources
├── addons.tf ← EKS add-ons (CoreDNS, kube-proxy, etc.)
├── variables.tf ← All input variables
├── outputs.tf ← All outputs
├── locals.tf ← Local values and calculations
├── data.tf ← Data sources (AZs, AMIs, etc.)
├── versions.tf ← Version constraints
└── README.md
Module Testing Strategy #
# Testing a module before publishing
# 1. Unit tests with terraform validate
terraform init
terraform validate
terraform fmt -check
# 2. Integration tests with plan
terraform plan -out=test.tfplan
# 3. E2E tests with apply + destroy
terraform apply -auto-approve
# Run test assertions
terraform destroy -auto-approve
# Tools for automated testing:
# - Terratest (Go)
# - terraform test (built-in since 1.6)
# Terraform built-in tests (terraform test)
# tests/vpc_test.tftest.hcl
run "verify_vpc_cidr" {
command = plan
assert {
condition = output.vpc_cidr == "10.0.0.0/16"
error_message = "VPC CIDR should be 10.0.0.0/16"
}
}
run "verify_subnet_count" {
command = plan
assert {
condition = length(output.private_subnet_ids) == 2
error_message = "Should have 2 private subnets"
}
}
Module Documentation Best Practices #
# Every module MUST have:
# 1. README.md — explanation, usage, examples
# 2. variables.tf — all input variables with descriptions
# 3. outputs.tf — all outputs with descriptions
# 4. main.tf — resource definitions
# 5. versions.tf — provider version constraints
# Auto-generate docs with terraform-docs
brew install terraform-docs
terraform-docs markdown table . > README.md
Module Versioning #
# Git tags for module versioning
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
# Use the module with a version pin
module "networking" {
source = "git::ssh://[email protected]/my-org/modules.git//networking?ref=v1.0.0"
}
# Update to a new version
module "networking" {
source = "git::ssh://[email protected]/my-org/modules.git//networking?ref=v1.1.0"
}
# Semantic versioning:
# MAJOR: Breaking change (removing a variable, changing an output)
# MINOR: New feature (adding an optional variable/output)
# PATCH: Bug fix (fixing a typo, updating a default)
# Version constraint:
module "networking" {
source = "git::ssh://[email protected]/my-org/modules.git//networking?ref=v1.x"
# ~> 1.0 = v1.0.0 up to v1.99.99
}
Testing Modules #
# Terratest: automated testing for Terraform modules
# (Go-based testing framework)
# Install
go install github.com/gruntwork-io/terratest/modules/terraform@latest
# Basic test structure:
# test/
# ├── networking_test.go
# └── fixtures/
# └── main.tf
# Run the tests
cd test && go test -v -timeout 30m
# Lightweight alternative: terraform validate + plan
# scripts/test-module.sh
#!/bin/bash
cd "$1"
terraform init -backend=false
terraform validate
terraform fmt -check
echo "Module $1 passed validation"
Module File Organization #
RECOMMENDED FILE STRUCTURE:
modules/
└── networking/
├── main.tf # Main resources
├── variables.tf # All input variables
├── outputs.tf # All outputs
├── versions.tf # Provider requirements
├── locals.tf # Local values
├── data.tf # Data sources
└── README.md # Auto-generated docs
# Alternatives:
# - Split by resource type (vpc.tf, subnet.tf, etc.)
# - Split by concern (security.tf, routing.tf)
# - Single file for small modules
# Auto-generate documentation
terraform-docs markdown table ./modules/networking > ./modules/networking/README.md
# Validate all modules
find modules -name "main.tf" -exec dirname {} \; | while read dir; do
echo "Validating $dir"
cd "$dir" && terraform init -backend=false && terraform validate
done
Summary #
- Minimal module structure:
main.tf,variables.tf,outputs.tf,versions.tf, andREADME.md— this consistency lets anyone get oriented immediately.versions.tfin modules uses>=rather than~>— let the root module pin specific versions; a module only states the minimum.- The README is the primary documentation — answer three questions: what it does, how to use it, and what you get (input/output tables).
- Use
thisas the main resource name in single-purpose modules — reduces ambiguity when callers read module outputs.- A monorepo to start — easier to manage, easier to refactor, easier to discover before there’s a need for multirepo.
- Split files by component for complex modules —
iam.tf,security-groups.tf,node-groups.tfare easier to navigate than one giantmain.tf.