Terraform

Terraform Module Structure and Best Practices for Scalable Infrastructure

AGAnurag Gupta05 Apr 2025 Β· Updated 04 Oct 2026 Β· 8 min read
Terraform Module Structure and Best Practices for Scalable Infrastructure

Quick answer: A well-structured Terraform module is a small folder with a single, clear purpose, split into main.tf, variables.tf, outputs.tf and versions.tf, with a README and automated tests alongside. It exposes only the inputs callers actually need, returns useful outputs, never configures its own provider, and is versioned so that consumers can upgrade on their own schedule.

Modules are how Terraform code scales from one .tf file to an entire organisation’s infrastructure. In this guide you will learn the standard module layout, how to design inputs and outputs that stay stable, how to compose modules into larger systems, how to test them, and the structural mistakes that quietly turn a module into a maintenance burden.

What a Terraform module actually is

Technically, a module is any directory containing .tf files. The folder you run terraform apply in is the root module; any directory it calls with a module block is a child module. That means you have been writing modules from your very first Terraform project, whether you planned to or not.

The difference between an accidental module and a good one is design. A good module behaves like a function in a programming language: it takes named inputs, produces predictable outputs, hides its internals, and can be called many times with different arguments. If you are new to the language itself, start with Terraform core concepts and syntax and come back here once you are comfortable with resources and variables.

The standard module directory layout

HashiCorp publishes a recommended structure, and almost every public module on the Registry follows it. Here is the layout for a VPC module:

terraform-aws-vpc/
β”œβ”€β”€ README.md          # what it does, inputs, outputs, example usage
β”œβ”€β”€ versions.tf        # required_version + required_providers
β”œβ”€β”€ main.tf            # the primary resources
β”œβ”€β”€ variables.tf       # every input, with type, description, default
β”œβ”€β”€ outputs.tf         # every value callers may need
β”œβ”€β”€ subnets.tf         # optional: split large main.tf by concern
β”œβ”€β”€ security-groups.tf
β”œβ”€β”€ examples/
β”‚   └── complete/
β”‚       └── main.tf    # a working root module that calls this module
└── tests/
    └── vpc.tftest.hcl # native Terraform tests (1.6+)

A few conventions behind this layout:

  • Repository name for published modules is terraform-<PROVIDER>-<NAME>. The Terraform Registry requires this pattern.
  • One concern per file. When main.tf grows past a few hundred lines, split by resource group (subnets.tf, routes.tf), not by environment.
  • examples/ doubles as documentation and as a smoke test. If the example does not apply cleanly, the module is broken.
  • No provider blocks inside the module. Providers are inherited from, or explicitly passed by, the root module.

Designing inputs and outputs that last

Variables and outputs are your module’s public API. Changing them breaks every caller, so design them deliberately. Every variable should have a type, a description, and, where sensible, a default and a validation block.

# variables.tf
variable "name" {
  type        = string
  description = "Name prefix applied to every resource in this module."
}

variable "cidr_block" {
  type        = string
  description = "IPv4 CIDR range for the VPC, for example 10.0.0.0/16."

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block must be a valid IPv4 CIDR such as 10.0.0.0/16."
  }
}

variable "public_subnets" {
  type        = map(string)   # availability zone => CIDR
  description = "Public subnet CIDRs keyed by availability zone."
  default     = {}
}

variable "tags" {
  type        = map(string)
  description = "Tags merged onto every taggable resource."
  default     = {}
}

# outputs.tf
output "vpc_id" {
  description = "ID of the created VPC."
  value       = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "Map of availability zone to public subnet ID."
  value       = { for az, s in aws_subnet.public : az => s.id }
}

Notice the map keyed by availability zone rather than a plain list. With for_each over a map, adding or removing one subnet changes only that subnet; with count over a list, removing the first element shifts every index and Terraform wants to destroy and recreate the rest. For a deeper look at these expressions, see Using Variables and Expressions in Terraform.

Composing modules into larger systems

Good modules are building blocks. A VPC module should create a VPC; it should not also deploy an EKS cluster and an RDS database “for convenience”. Keep each module focused and let the root module (or a thin wrapper module) wire them together through outputs and inputs.

# environments/prod/main.tf
module "network" {
  source  = "git::https://github.com/tkh-labs/terraform-aws-vpc.git?ref=v2.3.0"
  name    = "prod"
  cidr_block = "10.20.0.0/16"
  public_subnets = {
    "ap-south-1a" = "10.20.1.0/24"
    "ap-south-1b" = "10.20.2.0/24"
  }
  tags = local.common_tags
}

module "cluster" {
  source  = "git::https://github.com/tkh-labs/terraform-aws-eks.git?ref=v1.8.1"
  name    = "prod"
  vpc_id     = module.network.vpc_id
  subnet_ids = values(module.network.public_subnet_ids)
  tags       = local.common_tags
}

Terraform builds the dependency graph automatically from these references: the cluster waits for the network because it consumes module.network.vpc_id. No depends_on needed.

Flat vs. layered module structures

Approach How it looks Best for Watch out for
Resource modules One module per logical component (vpc, eks, rds) Reuse across many teams and projects Too many tiny modules can feel like boilerplate
Composition / wrapper modules A module that calls several resource modules with opinionated defaults Standardising a “golden path” for an organisation Deep nesting makes debugging plans harder
Monolithic root Everything in one directory, no child modules Tiny proofs of concept Unmaintainable beyond a few dozen resources

Most teams settle on two layers: focused resource modules in their own repositories, and per-environment root modules that compose them. Avoid going deeper than modules-calling-modules-calling-modules; each extra layer hides inputs and slows down plans.

Testing and documenting your module

Since Terraform 1.6, you can write tests in HCL itself using .tftest.hcl files, with no Go required. A test runs an apply (or a plan only, with command = plan) against your examples/ directory and asserts on outputs:

# tests/vpc.tftest.hcl
run "creates_two_public_subnets" {
  command = plan

  variables {
    name       = "test"
    cidr_block = "10.0.0.0/16"
    public_subnets = {
      "ap-south-1a" = "10.0.1.0/24"
      "ap-south-1b" = "10.0.2.0/24"
    }
  }

  assert {
    condition     = length(aws_subnet.public) == 2
    error_message = "Expected exactly two public subnets."
  }
}

Run it with terraform test. For deeper integration tests that hit real cloud APIs, Terratest (Go) is still widely used. Either way, run terraform fmt -check, terraform validate and a linter such as tflint in CI on every pull request. Generate the README’s input and output tables automatically with terraform-docs so documentation never drifts from code.

Seven module structure mistakes to avoid

  1. Provider blocks inside the module. This prevents callers from using aliases and makes the module impossible to remove cleanly. Declare required_providers in versions.tf, nothing more.
  2. Exposing every attribute as a variable. Forty optional inputs is not flexibility, it is a leaky abstraction. Expose what varies between callers; hard-code sensible defaults for the rest.
  3. Using count for lists of named things. Prefer for_each with maps so that adding or removing an item does not reshuffle resources.
  4. Environment logic inside the module. if var.env == "prod" conditionals belong in the root module’s inputs, not in the child. The module should not know which environment it is in.
  5. No version pinning on source. Always reference a tag (?ref=v2.3.0) or a Registry version constraint. Tracking main means every teammate gets a different module.
  6. Missing descriptions and types. Untyped variables accept anything and fail late with confusing errors. Types and descriptions cost seconds and save hours.
  7. Shipping without an example. If there is no examples/ directory, nobody knows how the module is meant to be called, including you in six months.

Frequently asked questions

How big should a Terraform module be?

Big enough to be useful on its own, small enough to describe in one sentence. “Creates a VPC with public and private subnets and NAT gateways” is a module. “Sets up our whole platform” is a composition of modules.

Should I put modules in the same repository as my environments?

For a single team, a modules/ folder in the same repo works and keeps changes atomic. Once several teams consume the same module, move it to its own repository so it can be tagged and versioned independently.

Do I need versions.tf if the root module already pins providers?

Yes. The module’s required_providers declares the minimum provider version it was tested with. Terraform intersects all constraints, so the module protects itself from being used with an incompatible provider.

Can a module call another module?

Yes, and composition modules do exactly that. Keep nesting shallow, though. Two levels is normal; four levels makes terraform plan output hard to read and debugging painful.

Key takeaways

  • Treat a module like a function: one purpose, typed inputs, documented outputs, no hidden side effects.
  • Follow the standard layout (main.tf, variables.tf, outputs.tf, versions.tf, examples/, tests/) so every engineer knows where to look.
  • Keep provider configuration and environment logic in the root module, not in the child.
  • Pin module versions, prefer for_each over count, and automate fmt, validate, test and docs in CI.

Want to practise building production-grade modules on AWS and Azure with mentor feedback? Our DevOps course walks through Terraform modules, state, CI/CD and real deployment projects, with placement support included. You can also follow the hands-on tutorials on our YouTube channel.

AG
Written byAnurag Gupta

Part of the Techknowledgehub team of industry mentors, writing practical guides to help you build a job-ready tech career.

More articles by Anurag Gupta β†’
Keep reading

Related articles

Leave a Reply