Introduction to Modules
As a Terraform configuration grows, copying the same VPC, security group, and instance blocks into every environment becomes a maintenance trap — fix a bug in one place and you have to remember the other five. Modules solve this by packaging a set of resources into a reusable unit with defined inputs and outputs, so you write the logic once and instantiate it many times. They are the primary way Terraform configurations stay DRY, consistent, and shareable across teams. Modules are a core language feature and work identically in OpenTofu.
What a module is
A module is simply a directory containing .tf files. There is nothing special about the files themselves — any folder of Terraform configuration is a module. What makes a module useful is a deliberate interface: input variables that callers set, resources that do the work, and output values that expose results back to the caller. Treated this way, a module behaves like a function in a programming language: you pass arguments in, it produces infrastructure, and it returns values out.
Because a module is just a folder, you can move a working configuration into reusable form with no rewrite — group the resources, replace hardcoded values with variable declarations, and surface the useful IDs as output blocks.
# modules/web-server/main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = var.name
}
}
Root modules and child modules
Every Terraform configuration has exactly one root module — the directory where you run terraform init, plan, and apply. The root module can call other modules, called child modules, using a module block. Those children can in turn call further modules, forming a tree.
| Term | What it is | How it runs |
|---|---|---|
| Root module | The working directory you run Terraform from | Invoked directly via the CLI |
| Child module | A module called from another module | Invoked through a module block |
| Published module | A child module distributed via a registry or Git | Referenced by a versioned source |
A child module is invoked with a module block. The label is a local name; source points at where the module lives, and the remaining arguments map to the module’s input variables:
module "web" {
source = "./modules/web-server"
name = "app-server"
ami_id = data.aws_ami.amazon_linux.id
instance_type = "t3.small"
}
You read a child module’s outputs with the module.<NAME>.<OUTPUT> syntax — for example module.web.instance_id. This is the only way the root module sees inside a child; resource addresses within a module are not directly accessible from outside it.
Why modules matter
Modules deliver three things that copy-paste cannot:
- DRY — a single definition for a pattern (a “standard” VPC, an RDS instance with backups, an ECS service) instead of duplicated blocks that drift apart.
- Consistency — every team that calls the module gets the same encryption, tagging, and naming defaults baked in, which is how platform teams enforce guardrails.
- Abstraction — callers supply a handful of inputs (
cidr_block,environment) without needing to understand the dozens of resources inside.
A module is a unit of organization and reuse, not a unit of isolation. All modules in a configuration share the same state, providers, and a single plan/apply. To split state or apply boundaries, use separate root configurations or workspaces — not nested modules.
The Terraform Registry
You do not have to write every module yourself. The Terraform Registry hosts thousands of community and partner modules — VPCs, EKS clusters, RDS databases, and more — versioned and documented. Reference a registry module with its NAMESPACE/NAME/PROVIDER source and a version constraint:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
name = "production"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}
After adding any module block, run terraform init so Terraform downloads the source into .terraform/modules:
terraform init
Output:
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.8.1 for vpc...
- vpc in .terraform/modules/vpc
Initializing provider plugins...
- Installing hashicorp/aws v5.62.0...
Terraform has been successfully initialized!
OpenTofu users can pull the same modules from the public registry or the OpenTofu registry, since the source and version syntax is shared.
Best Practices
- Build modules around a logical component (a network, a service, a database), not around a single resource — wrapping one resource one-to-one adds indirection without value.
- Keep the module interface small: expose the inputs callers genuinely vary, and provide sensible defaults for the rest.
- Always pin published and registry modules with a
versionconstraint soinitis reproducible and upgrades are deliberate. - Return everything a caller might need as
outputvalues; resource attributes inside a module are otherwise invisible to the caller. - Let providers be passed in or inherited rather than configured inside reusable modules, so one module works across regions and accounts.
- Treat the root module as thin “glue” that wires child modules together, keeping reusable logic in the children.