SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Terraform modules

Terraform Modules: Sourcing, Using, and Versioning Explained

15 min readTerraform 004 · Terraform modulesUpdated

A Terraform module is a container for multiple resources that are used together, and every Terraform configuration is itself a module: the working directory you run terraform in is the root module, and any module it calls is a child module. You call a child module with a module block whose source argument tells Terraform where the code lives (a local path, the Terraform Registry, Git, HTTP, or an S3 or GCS bucket) and whose version argument, for registry sources only, pins which release to install. Child modules are isolated: values go in through declared input variables, and results come back out only through declared outputs, referenced as module.NAME.OUTPUT. This lesson covers all four exam sub-objectives, sourcing modules, variable scope, using modules in configuration (including count, for_each, depends_on, and providers), and managing module versions with terraform init and terraform get.

What you’ll learn
  • Explain what a module is and distinguish the root module from child modules
  • Identify every module source type Terraform supports, write correct source strings for each, and know what source may reference
  • Choose a Git ref that matches the reproducibility you need, and pick a module per environment without a dynamic source
  • Describe variable scope in modules, including passing values through nested modules and reading child outputs with module.NAME.OUTPUT
  • Call modules in configuration and apply the count, for_each, depends_on, and providers meta-arguments, including their limits
  • Pin registry module versions with the version argument and version constraint operators, including prerelease behaviour
  • Install and update modules with terraform init and terraform get

What a Terraform module is

A module is a container for multiple resources that are used together — a set of .tf files kept in a single directory. Modules are Terraform's primary unit of reuse and abstraction: instead of copy-pasting the same VPC, cluster, or database wiring into every configuration, you write it once as a module and call it wherever you need it.

Every Terraform configuration has at least one module, the root module, which consists of the .tf files in the directory where you run Terraform commands. A module that another module calls is a child module. Child modules can call further modules, forming a tree, though HashiCorp recommends keeping the hierarchy flat — a deeply nested module tree is harder to reason about than a root module that composes several shallow children.

Structurally, a module directory conventionally contains main.tf (resources), variables.tf (input variables), and outputs.tf (output values), but Terraform does not require these filenames — it loads every .tf file in the directory. What makes a directory a module is simply that it holds Terraform configuration; what makes it a child module is that a module block calls it.

How Terraform sources modules

Terraform finds a module's code through the source argument, which is required in every module block. Terraform resolves it during terraform init, before it evaluates ordinary expressions, so the value must be known at init time. On Terraform 1.12, the release the 004 exam is based on, that means a literal string. Terraform 1.15 and later also accept local values and input variables declared with const = true (in version as well as source), but never an ordinary input variable, a resource attribute or any other value that is only known later. The source string's format determines the installation method:

Source typeExampleSupports version argument
Local path./modules/network or ../shared/vpcNo — same code as caller
Terraform Registryterraform-aws-modules/vpc/awsYes
Private registry / HCP Terraformapp.terraform.io/acme/vpc/awsYes
GitHub shorthand (HTTPS)github.com/hashicorp/exampleNo — use ?ref=
GitHub over SSHgit@github.com:hashicorp/example.gitNo — use ?ref=
Bitbucket shorthandbitbucket.org/acme/netNo — use ?ref=
Generic Gitgit::https://example.com/net.git?ref=v1.2.0 or git::ssh://git@example.com/net.gitNo — use ?ref=
HTTP URLhttps://example.com/module.zipNo
S3 buckets3::https://s3-eu-west-1.amazonaws.com/bkt/vpc.zipNo
GCS bucketgcs::https://www.googleapis.com/storage/v1/bkt/vpc.zipNo

Local paths must begin with ./ or ../ — that prefix is how Terraform tells a local directory from a registry address, so modules/network without the prefix is looked up in the registry and fails. Local-path modules are loaded in place: edits to their files take effect on the next plan with no new init. An absolute path such as /srv/tf/modules/dns is not treated as a local path: init copies it into .terraform/modules like a downloaded package, so later edits in that directory are ignored until you run init again.

Registry sources use NAMESPACE/NAME/PROVIDER on the public registry (for example hashicorp/consul/aws); a private registry or HCP Terraform prefixes a hostname, giving HOSTNAME/NAMESPACE/NAME/PROVIDER.

Git sources need either the git:: prefix or a recognised shorthand. The github.com/ORG/REPO shorthand (Bitbucket has an equivalent bitbucket.org/ORG/REPO form) clones over HTTPS; the git@github.com:ORG/REPO.git form clones over SSH, which is what you use when the runner authenticates to GitHub with an SSH key. A plain https:// URL that is not the GitHub shorthand and has no git:: prefix is treated as an HTTP source, not as a Git repository: Terraform requests the URL, follows an X-Terraform-Get header (or terraform-get meta tag) to another source address if the server returns one, and otherwise expects an archive.

For packages that keep the module in a subdirectory, append a double slash: git::https://example.com/repo.git//modules/vpc. Git sources select what to check out with the ref query parameter, not the version argument, and the choice of ref matters:

?ref= valueCan the code change under you?
Branch (?ref=main)Yes — each new init can fetch new commits
Tag (?ref=v1.2.0)Possibly — a tag can be deleted and re-pointed (force-moved)
Commit SHA (?ref=51d462976d84fdea54b47d80dcabbf680badcdb8)No — the only immutable Git pin

Choosing a module per environment. Because source cannot come from an ordinary variable or a conditional built on one, one configuration cannot switch a single module block between two sources at run time. Write two module blocks, each with its own literal source, and gate each one with count (for example count = var.env == "prod" ? 1 : 0), or keep separate root configurations per environment.

Calling a module: the module block

You use a module by writing a module block in the calling configuration. The label after module is a local name you choose; source is required; version is strongly recommended for registry sources; and every remaining argument supplies a value to one of the child module's input variables:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "prod-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}

Here name, cidr, azs, and private_subnets are not Terraform keywords — they match variable blocks declared inside the module. Passing an argument the module does not declare is an error, and omitting a required variable (one with no default) is also an error.

After adding, removing, or changing the source of a module block you must run terraform init (or terraform get) so Terraform can install the module before planning. A local-path module needs no download, but Terraform still records it in the module manifest during init.

Variable scope inside modules

Variables in Terraform are scoped to the module that declares them — they are not global. A child module cannot read the calling module's variables, locals, or resources, and the caller cannot reach into the child. The only interface between them is explicit: input variables in, output values out.

Inside the child module, inputs are declared with variable blocks and results are exposed with output blocks:

variable "cidr" {
  type        = string
  description = "VPC CIDR block"
}

resource "aws_vpc" "this" {
  cidr_block = var.cidr
}

output "vpc_id" {
  value = aws_vpc.this.id
}

The caller supplies cidr as an argument in the module block and reads the result with the module.NAME.OUTPUT syntax:

resource "aws_instance" "app" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
  subnet_id     = module.vpc.private_subnets[0]
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

Note the last snippet: a child module's outputs are not automatically shown by terraform output or exposed to its caller — the root module must re-export them with its own output block. This encapsulation is the point of modules: the caller depends only on the declared interface, so the module's internals can change freely.

Nested modules follow the same rule at every level. If the root calls module "stack" and stack calls module "vpc", the root cannot set a variable of vpc directly. To get a root value into vpc, declare a variable in stack, pass the value to it from the root, then pass var.NAME on from stack's module block to a variable declared in vpc. Outputs travel back up the same way, one level at a time.

Meta-arguments on module blocks

Module blocks accept four meta-arguments, the same names you know from resources: count, for_each, depends_on, and providers.

count and for_each

count and for_each create multiple instances of an entire module. With count you index instances numerically (module.net[0].vpc_id); with for_each you key them by map key or set element (module.net["eu"].vpc_id):

module "net" {
  source   = "./modules/network"
  for_each = toset(["eu", "us"])

  region_key = each.key
}

To read one output from every instance of a counted module, use a splat: module.web[*].id returns a list with one element per instance. For a for_each module, which is a map of instances, use a for expression such as [for m in module.net : m.vpc_id]. A common use of count is making a whole module optional: count = var.enable_logging ? 1 : 0 creates zero or one instance.

depends_on

depends_on on a module block makes every resource in the module wait for the listed objects. Use it only when the dependency is hidden from Terraform. It has a cost beyond ordering: Terraform treats the whole module as dependent, so plans become more conservative — whenever any object named in depends_on has changes pending in the plan, data sources inside the module are read during apply instead of plan, so more values show as known after apply (with nothing pending upstream they are still read at plan time). When possible, express the dependency by passing an attribute of the upstream object (for example the IAM role ARN or the policy ID) as an input variable; Terraform then orders only the resources that actually use it.

providers

Default (unaliased) provider configurations are inherited by child modules implicitly. Aliased configurations are never inherited — you pass them with the providers map, for example to deploy the same module into a second AWS Region:

module "dr" {
  source = "./modules/network"

  providers = {
    aws = aws.secondary
  }
}

When a child needs two configurations of the same provider, it declares them in its required_providers block with configuration_aliases, and the caller maps each one:

# in the child module
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      configuration_aliases = [aws.primary, aws.backup]
    }
  }
}

# in the root module
module "replica" {
  source = "./modules/replica"
  providers = {
    aws.primary = aws.use1
    aws.backup  = aws.euw1
  }
}

Provider configurations belong in the root module. A legacy child module that contains its own provider blocks is incompatible with count, for_each and depends_on on the module block. The fix is to remove the provider blocks from the child, keep its required_providers declaration, and configure the provider in the root (passing it implicitly or with providers).

Managing module versions

The version argument constrains which release of a module Terraform installs — and it works only for registry sources: the public Terraform Registry, a private registry, or the HCP Terraform registry. Adding version to a module with a local, Git, HTTP, S3, or GCS source is an error; Git sources version through ?ref=, and local paths always use whatever code is on disk.

Version constraints use the same operators as provider requirements: = (exact), !=, >, >=, <, <=, and the pessimistic operator ~>, which allows only the rightmost version component to increase — ~> 5.1 accepts 5.2 but not 6.0, while ~> 5.1.0 accepts 5.1.4 but not 5.2. When multiple constraints are combined, Terraform selects the newest available version that satisfies all of them.

Prerelease versions (such as 3.4.0-rc1) are selected only by an exact constraint naming that version (version = "3.4.0-rc1"). Ranges and ~> skip them, so a constraint like ~> 3.2 resolves to the newest stable release it allows, never to a newer prerelease.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = ">= 5.0.0, < 6.0.0"
}

Pin versions in anything shared or production-bound. An unconstrained registry module installs the newest version at init time, so two runs of the same configuration can install different code and a new major release can break your plan without any change on your side. Note one difference from providers: the dependency lock file records provider versions, not module versions, so your version constraint is the only thing holding a module release steady. A range such as ~> 4.2 lets a fresh clone install a newer release than an existing working directory already has, even with a committed lock file. When every engineer and CI runner must install the identical release, use an exact constraint (version = "4.2.3"), and pin Git sources to a commit SHA.

Installing modules: terraform init and terraform get

Terraform installs modules during terraform init, alongside backend initialization and provider installation. Downloaded module code is cached in the .terraform/modules directory of the working directory, with a manifest mapping each module call to its source. Whenever you add a new module block or change a source or version, run init again — terraform plan will refuse to run and tell you the module is not yet installed.

terraform get does the module-download step alone, without touching the backend or providers. It is useful when iterating on module calls, but in practice terraform init is the command you run day to day and the one the workflow expects. Do not confuse it with terraform init -backend=false: that skips backend configuration but still installs providers as well as modules.

Neither command upgrades an already-installed module by default. To move to a newer version allowed by your constraint, run terraform init -upgrade (or terraform get -update), which re-checks the registry and installs the newest acceptable release. This is a favorite exam distinction: plain init keeps what is already selected; -upgrade is what actually picks up newer module and provider versions.

Tip. Expect questions on source formats: the ./ or ../ prefix for local paths (and that absolute paths are copied into the module cache), registry NAMESPACE/NAME/PROVIDER addresses, the GitHub HTTPS shorthand versus the git@github.com SSH form, and ?ref= with branches, tags or commit SHAs. Know that source cannot use an ordinary variable and that version works only for registry sources. Version questions cover ~> arithmetic, combined ranges, prerelease exclusion and why a fresh init can install a newer module than a teammate has. Variable scope is heavily tested, including nested modules and splat access to counted module outputs. Know the four module meta-arguments, the provider-block restriction, the cost of module depends_on, and init versus init -upgrade versus terraform get.

Key takeaways
  • A module is a container for multiple resources used together; the directory where you run Terraform is the root module, and anything called via a module block is a child module.
  • The source argument is required and must be known at init: a literal string on Terraform 1.12 (1.15+ also allows locals and const variables, never ordinary variables or resource attributes). To pick a module per environment, use separate module blocks gated by count.
  • Local paths start with ./ or ../ and load in place; absolute paths are copied into .terraform/modules and need another init after edits.
  • github.com/ORG/REPO clones over HTTPS, git@github.com:ORG/REPO.git over SSH; an https URL without git:: is an HTTP source. Only a commit SHA in ?ref= is an immutable Git pin.
  • Module variables are not global: values enter a child only through declared inputs passed in the module block — at every level of a nested tree — and leave only through declared outputs read as module.NAME.OUTPUT (module.NAME[*].OUTPUT for all count instances).
  • Module blocks support count, for_each, depends_on and providers. A child with its own provider blocks cannot be used with count, for_each or depends_on, and module depends_on defers data reads to apply whenever the depended-on objects have pending changes — prefer passing an attribute as an input.
  • The version argument works only for registry modules; ~> lets only the rightmost component rise, prereleases match only an exact constraint, and the lock file does not record module versions.
  • terraform init installs modules into .terraform/modules; terraform get downloads modules only; only -upgrade (or get -update) moves an installed module to a newer acceptable version.

Frequently asked questions

What is the difference between the root module and a child module?

The root module is the set of .tf files in the directory where you run Terraform commands — every configuration has one. A child module is any module called from another module with a module block. The code can be identical; the distinction is purely about how it is being used in the current run.

Why can't I use the version argument with a Git or local module source?

The version argument relies on a registry protocol that lists available releases, so it only works with the public Terraform Registry, a private registry, or HCP Terraform. Git sources are pinned by appending a ref query parameter to the source (for example ?ref=v1.2.0), and local paths always use the code currently on disk, so there is nothing to version-select.

Can the module source argument use a variable?

Not an ordinary one. Terraform resolves source during terraform init, before it evaluates normal expressions. On Terraform 1.12 source must be a literal string; Terraform 1.15 and later also accept local values and input variables declared with const = true, but never ordinary variables or resource attributes. To use a different module in each environment, write one module block per source and gate each with count, or keep separate root configurations.

How do I access an output from a child module?

Reference it as module.NAME.OUTPUT, where NAME is the label on your module block and OUTPUT is an output declared inside the module — for example module.vpc.vpc_id. If the module uses count or for_each, index the instance first: module.vpc[0].vpc_id or module.vpc["eu"].vpc_id. To surface a child output from terraform output, the root module must re-export it in its own output block.

Can a child module read variables defined in the root module?

No. Variables are scoped to the module that declares them, and child modules see nothing from their caller automatically. Every value a child needs must be declared as an input variable in the child and passed explicitly as an argument in the module block. This isolation is deliberate — it keeps the module's interface explicit and its internals free to change.

Does terraform init update my modules to newer versions?

Not by default. A plain terraform init installs modules that are missing but keeps already-installed selections. Run terraform init -upgrade (or terraform get -update) to re-check sources and install the newest versions your constraints allow. This same distinction applies to provider upgrades.

Why does ~> 5.1 accept version 5.2 but ~> 5.1.0 does not?

The pessimistic constraint operator ~> allows only the rightmost specified component to increase. In ~> 5.1 the rightmost component is the minor version, so any 5.x at or above 5.1 matches, including 5.2. In ~> 5.1.0 the rightmost component is the patch version, so only 5.1.x releases at or above 5.1.0 match — 5.2.0 is excluded.

How do I pin a Git module so its code can never change?

Put a full commit SHA in the ref query parameter, for example ?ref=51d462976d84fdea54b47d80dcabbf680badcdb8. A branch moves with every push and a tag can be deleted and re-created on another commit, so only a commit SHA guarantees the same code on every terraform init.

Source

This lesson covers the "Terraform modules" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
Terraform 004 practice questions, with full explanations.
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?