Chapter 6: Modules
Topic 37

Calling and Sourcing Modules

ModulesSourcing

You use a module with a module block that names a source — a local path, a Registry reference, a git URL, or an archive in S3 or HTTP — plus the input values. terraform init downloads any remote module into .terraform/modules/. Where the source points, and how it is versioned, decides whether your module usage is reproducible or a moving target.

The single decision that matters most here is pinning. A source that resolves to a fixed version gives every init the same code; a source that points at a moving branch can pull different code on every run, and the resulting "it worked yesterday" failures are among the hardest to diagnose.

Three ways to source a module
Local path
./modules/vpc — edit in place, unversioned, resolved relative to the calling file.
Registry
terraform-aws-modules/vpc/aws — versioned, pinned with a separate version argument.
Git
git URL pinned with ?ref=tag — reproducible only when the ref names a fixed tag.

The module Block

A module block has a local name, a source, and the inputs you pass. The local name is how you address it: module.network.vpc_id reads an output, and the name is yours to choose. Everything else in the block is the module's inputs. The block below calls a local network module and feeds one of its outputs straight into a sibling resource.

Calling a module and using its output
module "network" {
  source     = "./modules/vpc"
  cidr_block = "10.0.0.0/16"
  az_count   = 3
}

resource "aws_lb" "web" {
  name    = "web-alb"
  subnets = module.network.public_subnet_ids
}

Source Types

The source argument takes several forms. A local path (./modules/vpc) points at a directory in the same repository. A Registry reference uses the three-part NAMESPACE/NAME/PROVIDER form — terraform-aws-modules/vpc/aws — paired with a version argument. A git source (git::https://...) pulls from a repository and pins with ?ref=. There are also S3 and HTTP archive sources for self-hosted modules.

Two forms pin differently and the distinction is load-bearing. A Registry module is pinned by a separate version argument, never inside the source string. A git module is pinned by a ?ref= pointing at an immutable tag — ?ref=v3.2.1 — not at a branch like main. Pinning a git source to a tag is the only way to make it reproducible.

Registry version vs git ref — both pinned to an immutable point
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"
  name    = "prod"
}

module "internal" {
  # pinned to a tag, never a branch like ?ref=main
  source = "git::https://github.com/acme/tf-modules.git//rds?ref=v3.2.1"
  name   = "orders-db"
}

init and Module Download

terraform init fetches every remote module into .terraform/modules/ and records what it fetched. Local-path modules are not downloaded — they are read in place each run — but remote ones are cached, and that caching is a footgun: after you change a source or a version, you must re-run init to pick up the new code. Skip it and the next plan fails: Terraform reports the module is not installed (or its installed version no longer matches the configuration) and tells you to run terraform init. It does not silently plan against the old cached code.

Local vs Remote Sourcing

A local path is the development source. You edit the module in place, run a plan in the calling configuration, and iterate with no publish step — the fast inner loop. The cost is that a local path is not independently versioned: every caller in that repository gets whatever the directory currently holds, and there is no way to pin a caller to "the version from last Tuesday."

A Registry or git-tagged source is the consumption form. It is versioned and reproducible across teams and repositories, which is exactly what you want for a module others depend on — and slower to iterate, because each change is a tag and a version bump. The usual pattern is to develop against a local path, then publish and pin once the module stabilizes.

Passing Providers

A module inherits the caller's default provider configurations automatically, so most modules need no provider wiring at all. You pass providers explicitly with a providers = {} map only when the module needs a specific or aliased provider — a module that must create resources in a second region or a second account, where the caller has an aliased aws.us_west the module should use. The map matches the module's expected provider name to the caller's configuration.

Passing an aliased provider into a module
module "dr_bucket" {
  source  = "./modules/s3-bucket"
  name    = "orders-backup"

  providers = {
    aws = aws.us_west
  }
}
Local path vs Registry or git source

Local path — a directory in the same repository, edited in place and read fresh each run. The fast development loop, but not independently versioned: every caller gets the current contents and nothing can pin to an older revision. Use it while a module is still changing.

Registry or git-tagged source — a published, pinned reference (version = "~> 6.0" or ?ref=v3.2.1) that resolves to the same code for every team and every run. Reproducible but slower to iterate, since each change is a tag and a bump. Use it for any module shared beyond one repository.

Common Mistakes
  • Sourcing a module from a git branch (?ref=main) so every init can pull different code, turning a reproducible build into a moving target that fails for reasons nobody can trace.
  • Forgetting to re-run init after changing a source or version, so the next plan errors with "Module not installed — Run terraform init" instead of running.
  • Putting a Registry module's version inside the source string instead of the separate version argument, which Terraform does not accept for Registry sources.
  • Referencing a module's internal resource across the boundary instead of a declared output — only outputs are exposed, so the reference does not resolve.
  • Assuming a module needing a second-region provider inherits it automatically, instead of passing it through providers = {}, and creating resources in the wrong region.
Best Practices
  • Pin every remote module source to an immutable version or tag — a Registry version constraint or a git ?ref= on a tag — never a moving branch.
  • Re-run terraform init after any change to a module's source or version so the cached copy is refreshed before the next plan.
  • Pass provider configurations explicitly with providers = {} into any module that needs a specific or aliased provider, such as a second region or account.
  • Develop against a local path for the fast loop, then publish and pin a versioned source once the module is stable and shared.
  • Reference a module's values only through its outputs, addressed as module.name.output.
Comparable tools CloudFormation references nested stacks by template URL Pulumi imports components as language packages npm / pip source-and-version model is the closest analogy

Knowledge Check

Why is sourcing a module from ?ref=main a problem?

  • A branch moves, so every init can pull different code and the build stops being reproducible
  • Terraform cannot read git sources at all, accepting only local paths and the public Registry
  • A branch ref disables the module's declared outputs from being read by the caller
  • A branch ref forces a fresh download on every plan rather than using the init cache

You change a module's version from 5.1.0 to 6.0.0 and run plan without re-running init. What happens?

  • The plan errors — Terraform reports the module is not installed for the new version and tells you to run terraform init
  • Terraform automatically downloads and installs the new 6.0.0 version during the plan
  • Terraform silently plans against the old cached 5.1.0 code without warning
  • Terraform resolves and then uses the newest matching version available regardless of what the local cache currently holds

When do you need to pass a provider into a module with providers = {}?

  • When the module needs a specific or aliased provider, such as a second region or account, rather than the caller's default
  • On every single module call, because a child module never inherits any provider configuration from the calling configuration
  • Only for modules sourced from the public Registry rather than a local path
  • Only when the module declares no outputs of its own to return to the caller

What is the trade-off of a local-path module source versus a published, pinned one?

  • A local path iterates fast but is not independently versioned; a pinned source is reproducible but slower to change
  • A local path is slower to apply because it is re-read from disk on every single run
  • A pinned published source cannot accept any input variables at all, whereas only a local-path module is able to take them
  • A local path stores its own separate state file while a pinned source shares the root's

You got correct