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.tfgrows 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
providerblocks 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
- Provider blocks inside the module. This prevents callers from using aliases and makes the module impossible to remove cleanly. Declare
required_providersinversions.tf, nothing more. - 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.
- Using
countfor lists of named things. Preferfor_eachwith maps so that adding or removing an item does not reshuffle resources. - 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. - No version pinning on
source. Always reference a tag (?ref=v2.3.0) or a Registryversionconstraint. Trackingmainmeans every teammate gets a different module. - Missing descriptions and types. Untyped variables accept anything and fail late with confusing errors. Types and descriptions cost seconds and save hours.
- 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_eachovercount, and automatefmt,validate,testand 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.


