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:#fff

Standard 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 #

NameDescriptionTypeDefaultRequired
cidr_blockCIDR block for the VPCstringYes
environmentEnvironment namestringYes
public_subnet_countNumber of public subnetsnumber2No
private_subnet_countNumber of private subnetsnumber2No

Outputs #

NameDescription
vpc_idID of the created VPC
public_subnet_idsList of public subnet IDs
private_subnet_idsList of private subnet IDs

Requirements #

NameVersion
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, and README.md — this consistency lets anyone get oriented immediately.
  • versions.tf in 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 this as 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.tf are easier to navigate than one giant main.tf.

← Previous: What is a Module?   Next: Root vs Child Module →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact