100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
HCL

Terraform Modules Explained

Modules are Terraform's mechanism for packaging and reusing configuration. This topic explains what a module is, root vs. child modules, and how module calls connect inputs, resources, and outputs.

Organizing Terraform CodeIntermediate9 min readJul 9, 2026
Analogies

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.

hcl
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

Was this page helpful?

Topics covered

#HCL#TerraformInfrastructureAsCodeStudyNotes#DevOps#TerraformModulesExplained#Terraform#Modules#Explained#Calling#StudyNotes#SkillVeris