Modules are where Terraform stops being a scripting tool and starts being an engineering discipline. Every Terraform configuration you have ever run already uses one — the directory you run terraform apply in is a module — and yet module questions are some of the most commonly missed on the HashiCorp Terraform Associate exam. Candidates can write resources all day but stumble on source syntax, where the version argument is allowed, or how to loop a module with for_each.
This guide covers modules the way a practitioner actually uses them: what they are, how to structure one, every source type you can call, how versioning really works, how data flows in and out, and the module meta-arguments that show up in both production code reviews and exam questions. By the end, the modules objective should be one of the easiest parts of your exam — and your real-world Terraform should be cleaner for it.
If you’re earlier in your prep, start with the complete Terraform Associate exam guide for the big picture, then come back here to go deep on modules.
What a Module Actually Is
A Terraform module is just a directory of .tf (or .tf.json) files. That’s the whole definition. There is no special manifest, no registration step, no magic file that makes a directory a module.
Two terms matter:
- Root module — the working directory where you run
terraform init,plan, andapply. Its variables are set viaterraform.tfvars,-varflags, or environment variables. - Child module — any module called by another module using a
moduleblock. Its variables are set by the caller, as arguments in that block.
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
name = "prod-vpc"
}
When you run terraform init, Terraform downloads or links every referenced module into the .terraform/modules directory of the root module. You can re-fetch them without a full init using terraform get -update.
Why bother? Three reasons that both production teams and the exam care about:
- Reuse — write the VPC pattern once, instantiate it for dev, staging, and prod.
- Encapsulation — callers see a small set of inputs and outputs, not 40 resources.
- Consistency — the security group rules and tagging standards live in one place, so every environment inherits the fix when you patch them.
Standard Module Structure
HashiCorp publishes a standard module structure that the registry expects and that real teams converge on:
terraform-aws-vpc/
├── main.tf # the resources
├── variables.tf # input variables
├── outputs.tf # output values
├── README.md # what it does, how to call it
├── examples/
│ └── simple/
│ └── main.tf # a working example invocation
└── modules/
└── subnets/ # optional nested submodules
None of these filenames are technically required — Terraform reads every .tf file in the directory regardless — but the split is the convention the ecosystem expects, and the exam treats main.tf, variables.tf, and outputs.tf as the canonical layout.
Two rules worth internalizing for module content:
- A reusable module should not contain
providerblocks. Provider configuration belongs in the root module; child modules declare what they need viarequired_providersinside aterraformblock and inherit configured providers from the caller. Embeddedproviderblocks in shared modules are a legacy pattern that breaksfor_each/counton the module and complicates removal. - A module should expose everything a caller needs as outputs. Callers cannot reach into a module and reference its resources directly — if the VPC module doesn’t output
vpc_id, the caller can’t get it.
Module Sources: Every Way to Call a Module
The source argument tells Terraform where the module code lives, and it’s the single most-tested module detail on the Associate exam. Learn to recognize each form on sight:
| Source type | Example | Version pinning |
|---|---|---|
| Local path | source = "./modules/vpc" | None — code is local |
| Public registry | source = "terraform-aws-modules/vpc/aws" | version argument |
| Private registry | source = "app.terraform.io/acme/vpc/aws" | version argument |
| GitHub (HTTPS) | source = "github.com/acme/terraform-aws-vpc" | ?ref= query string |
| Generic Git | source = "git::https://example.com/vpc.git?ref=v1.2.0" | ?ref= query string |
| Git over SSH | source = "git::ssh://[email protected]/vpc.git" | ?ref= query string |
| HTTP archive | source = "https://example.com/vpc.zip" | None — URL is the pin |
| S3 bucket | source = "s3::https://s3-eu-west-1.amazonaws.com/bucket/vpc.zip" | None — object is the pin |
| GCS bucket | source = "gcs::https://www.googleapis.com/storage/v1/bucket/vpc.zip" | None — object is the pin |
The details the exam loves:
- Local paths must begin with
./or../. A baremodules/vpcis interpreted as a registry address, not a path. This trips up real engineers weekly. - The public registry format is exactly
<NAMESPACE>/<NAME>/<PROVIDER>— for exampleterraform-aws-modules/vpc/aws. - The private registry format adds a hostname in front:
<HOST>/<NAMESPACE>/<NAME>/<PROVIDER>— for exampleapp.terraform.io/acme/vpc/aws. - A subdirectory inside a repository uses a double slash:
github.com/acme/infra//modules/vpc?ref=v1.2.0. - A specific Git revision is selected with
?ref=, which accepts a tag, branch, or full commit SHA.
Versioning: Where version Is Allowed (and Where It Isn’t)
This is a guaranteed exam topic and a frequent production mistake, so it gets its own section.
The version argument works only with module registries — the public Terraform Registry and private registries such as HCP Terraform / Terraform Enterprise. For every other source type, version is an error:
# Correct — registry module with a version constraint
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
...
}
# Wrong — version is not supported for Git sources
module "vpc" {
source = "git::https://example.com/vpc.git"
version = "1.2.0" # ERROR
}
# Correct — Git sources pin with ?ref=
module "vpc" {
source = "git::https://example.com/vpc.git?ref=v1.2.0"
}
The constraint syntax is the same one used for providers:
| Constraint | Meaning |
|---|---|
version = "5.1.0" | Exactly 5.1.0 |
version = ">= 5.1" | 5.1 or newer, including 6.x |
version = "~> 5.1" | >= 5.1.0 and < 6.0.0 (pessimistic constraint) |
version = "~> 5.1.0" | >= 5.1.0 and < 5.2.0 |
version = ">= 5.1, < 5.30" | A bounded range |
The pessimistic constraint operator ~> (“allow only the rightmost component to increment”) is the recommended default for modules you don’t control: you get patch and minor updates without being surprised by a breaking major release.
To publish a module to the public registry, the repository must be on GitHub, be named terraform-<PROVIDER>-<NAME> (like terraform-aws-vpc), and use semantic version tags such as v1.2.0 — the registry builds its version list from those tags.
Inputs and Outputs: How Data Flows Through Modules
A module’s interface is its input variables and output values. Inside the child module:
# modules/vpc/variables.tf
variable "cidr_block" {
type = string
description = "CIDR range for the VPC"
}
variable "name" {
type = string
default = "main"
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
description = "ID of the created VPC"
}
The caller sets the variables as arguments and reads outputs with the module.<NAME>.<OUTPUT> reference syntax:
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
}
resource "aws_security_group" "app" {
vpc_id = module.vpc.vpc_id # <-- module output reference
}
Three behaviors to lock in:
- A variable with no default is required — omit it in the module block and
terraform validatefails. - Outputs are the only way a caller can read values from a module. There is no
module.vpc.aws_vpc.this.id. - To surface a child module’s output from the root module (so it appears in
terraform outputor remote state), you must re-export it with anoutputblock in the root that referencesmodule.vpc.vpc_id.
That last pattern — chaining outputs upward — is exactly how module composition works at scale: child modules produce values, the root module wires them between siblings (module.app.subnet_ids = module.vpc.private_subnets), and nothing reaches sideways into anything else.
Module Meta-Arguments: count, for_each, providers, depends_on
Since Terraform 0.13, module blocks accept the same meta-arguments as resources, and the exam tests this directly.
for_each — stamp out one module instance per item:
module "vpc" {
source = "./modules/vpc"
for_each = toset(["dev", "staging", "prod"])
name = each.key
cidr_block = each.key == "prod" ? "10.0.0.0/16" : "10.1.0.0/16"
}
# Reference a specific instance's output:
# module.vpc["prod"].vpc_id
count — numeric repetition or a conditional module:
module "bastion" {
source = "./modules/bastion"
count = var.enable_bastion ? 1 : 0
}
If you’re unsure when to reach for which, the trade-offs are identical to resources — the count vs for_each deep dive covers the index-shifting problem that makes for_each the safer default for collections.
providers — pass aliased providers explicitly:
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
module "cdn_certs" {
source = "./modules/acm"
providers = {
aws = aws.us_east_1 # module's default aws provider = our alias
}
}
Without a providers map, a child module automatically inherits the caller’s default (un-aliased) provider configurations. Aliased providers are never inherited implicitly — they must be passed.
depends_on works on modules too, forcing every resource in the module to wait — use it sparingly, because it serializes things Terraform could otherwise parallelize, and prefer implicit dependencies through references whenever possible.
One more operational detail worth knowing: resources inside modules are addressed with the full path in state commands — terraform state show 'module.vpc.aws_vpc.this', or terraform apply -replace='module.vpc.aws_subnet.public[0]' to force recreation. The Terraform commands cheat sheet has the full addressing syntax, and the state management guide covers what terraform state mv looks like when you refactor resources into a module without destroying them.
Module Best Practices That Double as Exam Answers
These come straight from HashiCorp’s guidance, which means they’re both the right way to work and the expected exam answer:
- Start with the root module; extract modules when a pattern repeats. Don’t wrap single resources in modules (“thin wrappers”) — a module should raise the level of abstraction, not just rename arguments.
- Keep the module tree flat. Prefer composition in the root module over deeply nested module-calling-module hierarchies; deep nesting hides the dependency graph and makes overriding behavior painful.
- Pin versions for anything remote. Registry modules get
version = "~> x.y", Git modules get?ref=with a tag — an unpinned module is an unreviewed change waiting to deploy itself. - No provider blocks in shared modules; no hardcoded credentials anywhere.
- Expose outputs generously. The cost of an extra output is near zero; the cost of a missing one is a fork of the module.
- Document with README and a working
examples/directory — the registry renders both, and teammates read them before they read your HCL.
How the Terraform Associate Exam Tests Modules
Modules are one of the nine exam objectives, and the questions are pleasantly predictable. Expect variations of:
| Question pattern | What they’re checking |
|---|---|
”Which source value is valid for a module in a subdirectory of a Git repo?” | The // subdirectory and ?ref= syntax |
”Why does version cause an error here?” | version is registry-only |
”How do you reference an output of module vpc?” | module.vpc.<output> syntax |
| ”Variables for a child module are set by…” | The caller’s module block arguments |
”Where are modules stored after terraform init?” | .terraform/modules |
| ”How do you create three instances of one module?” | count / for_each meta-arguments |
| ”What’s required to publish to the public registry?” | GitHub, terraform-<PROVIDER>-<NAME> naming, semver tags |
| ”A module needs an aliased provider — how?” | The providers map |
Drill these until the syntax is reflexive. The 25 free Terraform Associate practice questions include several module scenarios with explanations, and the free interactive TF-004 practice set lets you check your accuracy under a timer.
Putting It Into Practice
Reading about modules doesn’t build the syntax reflexes the exam (or a code review) demands — writing them does. A 90-minute hands-on session that covers the whole objective:
- Write a small module locally (a VPC, a bucket with policies, anything with 3+ resources), call it with a local path, and wire one of its outputs into a resource in the root module.
- Swap the local source for a public registry module with a
~>version constraint, runterraform init, and inspect what landed in.terraform/modules. - Add
for_eachover a set of environment names and reference one instance’s output with themodule.name["key"]syntax. - Pass an aliased provider in with the
providersmap and confirm the module’s resources land in the right region.
When the mechanics feel automatic, pressure-test yourself with full-length timed mocks. Sailor.sh’s HashiCorp Terraform Associate Mock Exam Bundle includes eight 60-question exams with detailed explanations across every objective — including the module source, versioning, and composition scenarios this guide covered — so you find your weak spots before exam day instead of during it. If you’re building a week-by-week plan around that practice, the 30-day Terraform Associate study plan slots modules into week two.
Frequently Asked Questions
What is the difference between a root module and a child module?
The root module is the directory where you run Terraform commands; its variables come from terraform.tfvars, -var flags, or environment variables. A child module is any module called via a module block, and its variables are set as arguments by the caller. The same directory of code can serve as either — the role depends on how it’s invoked.
Can I use the version argument with a GitHub module source?
No. The version argument only works with module registries (the public Terraform Registry and private registries like HCP Terraform). For Git-based sources, pin a revision with the ?ref= query parameter — a tag, branch, or commit SHA: source = "github.com/acme/terraform-aws-vpc?ref=v1.2.0".
How do I reference a resource inside a module from outside it?
You can’t reference the resource directly. The module must declare an output for the value, and the caller reads it as module.<MODULE_NAME>.<OUTPUT_NAME>. If the output doesn’t exist, the only fix is adding one to the module.
Where does Terraform store downloaded modules?
In the .terraform/modules directory of the root module, populated during terraform init. Use terraform get -update to refresh module code without re-running full initialization.
Do child modules inherit providers automatically?
Default (un-aliased) provider configurations are inherited automatically from the caller. Aliased providers are not — they must be passed explicitly with the providers meta-argument in the module block. Reusable modules should declare their provider requirements with required_providers but should not contain provider configuration blocks.
Can I use count and for_each on a module block?
Yes — since Terraform 0.13, module blocks support count, for_each, depends_on, and providers. Instances are referenced as module.name[0] (count) or module.name["key"] (for_each). A common pattern is the conditional module: count = var.enabled ? 1 : 0.
How much of the Terraform Associate exam covers modules?
Modules are one of the nine exam objectives, commonly estimated around 15% of the exam. Questions focus on source syntax, version constraints, input/output mechanics, and module meta-arguments — all directly covered by hands-on practice rather than memorization.
Conclusion
Modules are Terraform’s unit of reuse, and the exam’s questions about them all reduce to a handful of mechanics: a module is a directory, source tells Terraform where it lives, version only works for registries, data flows in through variables and out through outputs, and since 0.13 you can stamp out module instances with count and for_each just like resources.
Build one real module, call it three different ways, and wire its outputs into another module — that single exercise covers nearly every module question you’ll face. Then fold it into the rest of your prep: state, the core workflow, and configuration syntax round out the heavily-tested objectives, and timed practice ties it all together. The engineers who write good modules and the candidates who pass the Associate exam are doing the same thing: treating infrastructure as a composable, versioned, reviewable system instead of a pile of resources.