Terraform Modules Explained
A module in Terraform is simply a directory containing .tf files. Every Terraform configuration has at least one module — the root module, which is whatever directory you run terraform apply from. Anything beyond that is a child module, called from the root (or from another module) via a module block. Modules exist to encapsulate a piece of infrastructure — a VPC, an application's compute layer, a standard set of IAM policies — behind a clean interface of inputs (variables) and outputs, so that pattern can be reused across environments, teams, or projects without copy-pasting the underlying resource definitions.
Cricket analogy: A Terraform module is like a franchise's standard training-drill playbook — the root module is the specific ground you're running drills on today, and a child module like a 'fielding drill package' can be reused across every franchise ground without rewriting the drills each time.
Calling a Module
A module block requires a source argument telling Terraform where to find the module's code — a local relative path, a Git repository, a Terraform Registry address, or an S3/GCS bucket, among others. Any other arguments in the block correspond to the child module's declared input variables. After running terraform init, which downloads and caches remote module sources, resources inside the module are addressed as module.<name>.<resource_type>.<resource_name> for state and CLI purposes.
Cricket analogy: A module's source argument pointing to a Git repo is like a coaching manual specifying which academy's drill archive to pull from, and after 'downloading' that archive once before the season, drills get addressed by academy name and drill name, just like module.name.resource_type.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
name = "${var.project}-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
tags = local.common_tags
}
module "app" {
source = "./modules/app-service"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}Versioning and the Registry
Modules sourced from the public Terraform Registry (or a private registry) support a version constraint argument, following the same operators as provider version constraints (e.g. ">= 5.0, < 6.0"). Pinning a specific version, rather than leaving it unconstrained, is important: an unpinned registry module can silently pull in breaking changes on the next terraform init, whereas local path modules always use whatever code is currently on disk with no separate versioning of their own — their 'version' is whatever your VCS commit reflects.
Cricket analogy: Pinning a module's version constraint like '>=5.0, <6.0' is like a team locking in a specific edition of the coaching manual for the season, so an unpinned reference could silently swap in next year's revised rules mid-tournament, while a locally photocopied manual always reflects whatever's on the physical page today.
Think of a module as a function in a general-purpose programming language: variables are its parameters, the resources inside are its implementation, and outputs are its return values. Composing infrastructure from well-designed modules is analogous to composing software from well-designed functions and libraries.
Modules can call other modules, forming a tree with the root module at the top. This lets you build a layered architecture — for example, a top-level environment module that calls a networking module and an application module, wiring the networking module's outputs into the application module's inputs. Terraform resolves the entire dependency graph across module boundaries, so resources in one module can safely depend on resources in another as long as the values flow through declared inputs and outputs.
Cricket analogy: Modules calling other modules forming a tree is like a national board's top-level 'season module' calling a 'ground-prep module' and a 'squad-selection module,' wiring the ground module's pitch report into the selection module's team-balance inputs.
Deeply nested modules (more than two or three levels) tend to become hard to reason about — variables get passed through multiple layers unchanged just to reach where they're used, and the resulting indirection can make it difficult to trace which resource is actually affected by a given input. Favor a flatter composition where practical.
- Every configuration has a root module (the directory you run Terraform in); anything called via a module block is a child module.
- A module block requires source, and its other arguments map to the child module's input variables.
- terraform init downloads and caches remote module sources; resources inside are addressed as module.<name>.<resource>.
- Registry modules support version constraints; pin versions to avoid silently pulling breaking changes.
- Modules can call other modules, forming a dependency graph that spans module boundaries.
- Excessive nesting adds indirection; prefer flatter module composition where practical.
Practice what you learned
1. What is the root module in a Terraform configuration?
2. Which argument is required in every module block regardless of where the module comes from?
3. What risk does leaving a registry module's version constraint unpinned introduce?
4. How are resources inside a child module addressed in the state and CLI (e.g. for terraform state commands)?
5. What is a downside of deeply nested module hierarchies (many layers of modules calling modules)?
Was this page helpful?
You May Also Like
Writing a Reusable Module
Turning ad-hoc configuration into a well-designed reusable module requires thoughtful input/output design, sensible defaults, and internal structure. This topic walks through the practical craft of module authoring.
Outputs and Locals
Output values expose data from a module for use by callers or the CLI, while local values let you name and reuse computed expressions within a module. Together they make configurations readable and composable.
Variables and Variable Types
Input variables let Terraform configurations be parameterized and reused across environments. This topic covers declaring variables, type constraints, defaults, validation, and how to supply values.
File and Directory Structure Conventions
Consistent file layout and naming conventions keep Terraform projects navigable as they grow, separating variables, outputs, providers, and resources predictably.
Related Reading
Related Study Notes in DevOps
Browse all study notesNginx Study Notes
DevOps · 30 topics
DevOpsAnsible Study Notes
DevOps · 30 topics
DevOpsAdvanced Kubernetes Study Notes
Kubernetes · 30 topics
DevOpsAdvanced Bash Scripting Study Notes
Bash · 30 topics
DevOpsApache Kafka Study Notes
Kafka · 30 topics
DevOpsDocker & Kubernetes Study Notes
YAML · 40 topics