SaveMyCert
Log in
5 of 5 free questions left today·for unlimited practice
Terraform modules

Terraform Modules: Sourcing, Using, and Versioning Explained

11 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 and write correct source strings for each
  • Describe variable scope in modules: pass inputs through the module block and read child outputs with module.NAME.OUTPUT
  • Call modules in configuration and apply the count, for_each, depends_on, and providers meta-arguments
  • Pin registry module versions with the version argument and standard version constraint operators
  • 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 and must be a literal string — it cannot reference variables, because Terraform resolves sources during terraform init, before evaluating expressions. 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 shorthandgithub.com/hashicorp/exampleNo — use ?ref=
Generic Gitgit::https://example.com/net.git?ref=v1.2.0No — 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 distinguishes a local directory from a registry address. Registry sources use the NAMESPACE/NAME/PROVIDER format (a private registry prefixes a hostname). For packages that keep the module in a subdirectory, append a double slash: git::https://example.com/repo.git//modules/vpc. Git sources select a branch, tag, or commit with the ref query parameter, not the version argument.

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.

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 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
}

depends_on forces every resource in the module to wait for the listed objects — use it sparingly, because it hides the real dependency and blocks Terraform's normal fine-grained ordering. Prefer expressing dependencies through input and output references, which Terraform tracks automatically.

providers maps provider configurations from the caller into the child, which is how you pass an aliased configuration — for example deploying the same module into two AWS regions:

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

  providers = {
    aws = aws.secondary
  }
}

A module that uses count or for_each must not contain its own provider blocks; provider configurations belong in the root module and are passed down implicitly (by matching names) or explicitly via providers. The child declares what it needs in required_providers, including configuration_aliases when it expects an aliased configuration.

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 installed-or-available version that satisfies all of them.

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.

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 occasionally useful when iterating on module sources, but in practice terraform init is the command you run day to day and the one the workflow expects.

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 which source strings support the version argument (registry only) versus ?ref= for Git, and on the exact module.NAME.OUTPUT syntax for reading child outputs. Variable scope is heavily tested: know that child modules see nothing from the caller except explicitly passed inputs. Also know the four module meta-arguments (count, for_each, depends_on, providers) and that terraform init installs modules while -upgrade is required to pick up newer allowed versions.

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, must be a literal string, and its format selects the install method: local paths start with ./ or ../, registry sources use NAMESPACE/NAME/PROVIDER, and Git, HTTP, S3, and GCS each have their own address forms.
  • Module variables are not global: values enter a child only through declared input variables passed in the module block, and leave only through declared outputs read as module.NAME.OUTPUT.
  • Module blocks support four meta-arguments — count, for_each, depends_on, and providers — and a module used with count or for_each must not contain its own provider blocks.
  • The version argument works only for registry modules; Git sources pin with ?ref= and local paths cannot be versioned at all.
  • Use constraint operators to pin versions — ~> allows only rightmost-component increases — because the lock file tracks provider versions, not module versions.
  • terraform init installs modules into .terraform/modules; terraform get downloads modules only; and only the -upgrade (or -update) flag 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.

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.

Test yourself on this topic
Practice questions with full explanations.
Practice now

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