Chapter 7: Modules
Topic 43

Calling and Sourcing Modules

SourcingModules

You call a module with a module block, and its source argument tells Terraform where to fetch the code — a local path during development, the Terraform Registry for published modules like Google's Cloud Foundation Toolkit, a git repository for your private modules, or even a GCS bucket. The block name is the local instance name, source is the only required argument, and everything else is an input variable to the child.

terraform init is what actually downloads non-local sources into .terraform/modules/. That single fact drives most of the operational rules in this topic: where the source points decides whether your module usage is reproducible or a moving target, and a change to the source or its version means re-running init before the new code takes effect.

The module Block

A module block has a local name, a source, and the inputs you pass to the child. You reference its outputs as module.<name>.<output>module.ingest_pipeline.topic_id — and that is the only way to read anything out of it. The local name is yours to choose and is what shows up in state addresses; the source is what Terraform resolves at init.

Calling the ingest-pipeline module from a local path
module "ingest_pipeline" {
  source     = "./modules/ingest-pipeline"   # local path — no download, no version
  project_id = "hatch-pipeline-prod"
  event_name = "clickstream"
}

# reference an output elsewhere in the config
resource "google_bigquery_data_transfer_config" "loader" {
  params = {
    topic = module.ingest_pipeline.topic_id
  }
}

Source Types

There are four sources you will actually use. A local path (./modules/ingest-pipeline) references a directory in the same repo — the default while you develop a module, and the one source that needs no init re-download because there is nothing to fetch. A Registry source (terraform-google-modules/network/google) pulls a published module from registry.terraform.io; this is how you consume the Cloud Foundation Toolkit, and it is the only source type where the version argument applies.

A git source pins a private module by tag — git::https://github.com/hatch-io/modules.git//ingest-pipeline?ref=v1.2.0 — and a GCS source fetches an archive from a bucket, gcs::https://www.googleapis.com/storage/v1/hatch-modules/ingest.zip. Both are how a team shares private modules without publishing them to a registry. The double-slash in the git source (//ingest-pipeline) selects a subdirectory inside the repo, which is how a monorepo of modules resolves to the right one.

The four source types, side by side
module "local"    { source = "./modules/ingest-pipeline" }                                  # dev
module "registry" { source = "terraform-google-modules/network/google", version = "~> 9.0" }  # CFT
module "git"      { source = "git::https://github.com/hatch-io/modules.git//ingest-pipeline?ref=v1.2.0" }
module "gcs"      { source = "gcs::https://www.googleapis.com/storage/v1/hatch-modules/ingest.zip" }
How each source pins the version
local path
A directory in the same repo; no fetch, no pin — for the author mid-change.
Registry
A published module; the only source that takes version.
git source
A private repo pinned by ?ref=tag, never a branch.

init and the Module Cache

terraform init resolves and downloads every non-local source into .terraform/modules/, then caches it. Change a source or a registry version and you must re-run init, or Terraform happily runs the stale cached copy and your change appears to do nothing. This is the single most common confusion in the topic: a plan that ignores your edit is almost always a cache that init never refreshed.

A corollary catches people who try to debug a remote module in place: editing a file under .terraform/modules/ to "test a fix" is futile, because that directory is a cache init overwrites. The edit vanishes on the next init and was never part of your source. To change a module, change it at the source — the local directory, the git tag, or the published version — never in the cache.

Local vs Remote Sourcing

The practical workflow is to develop against a local path and consume against a versioned source. While you are iterating on the ingest-pipeline module, a local path means every edit applies immediately with no init dance. Once it stabilizes and other teams depend on it, you publish it — to git with a tag, or to a registry — and callers pin a version. Local is for the author mid-change; versioned remote is for the consumer who wants reproducibility.

Passing Providers

Most modules need no provider plumbing — a child inherits the caller's default google provider automatically. You only pass a provider explicitly when a child needs a non-default one, with a providers = { ... } argument in the module block — a second region, a different project, or google-beta. The full mechanics of that, and why a reusable child must never declare its own provider block, are the subject of Topic 46; here it is enough to know the explicit-passing escape hatch exists and is rarely needed.

Local vs Registry vs Git Source

Local path (./modules/x) — for a module you develop in the same repo. Edits apply immediately, no version argument, no init re-download. Use it while iterating.

Registry source (terraform-google-modules/network/google) — for published, versioned modules. The only source that takes a version argument. Use it for public modules like the CFT.

Git source (git::...?ref=v1.2.0) — for your private shared modules, pinned by a git ref instead of a version constraint. Use it for internal modules you share across repos.

Common Mistakes
  • Using a version argument with a git or local sourceversion only works with registry sources; on a git source you pin with ?ref= instead, and mixing them up means no pin at all.
  • Pointing a git source at a branch (?ref=main) instead of a tag — the module changes under you on the next init -upgrade with no version bump to review.
  • Editing a module under .terraform/modules/ to "test a fix" — that directory is a cache init overwrites; the edit vanishes and was never in your source.
  • Changing a module's source or version and running plan without re-running init — Terraform uses the stale cached copy and your change appears to do nothing.
  • Dropping the double-slash subdirectory in a git source over a monorepo — Terraform resolves the repo root instead of the specific module and fails to find it.
Best Practices
  • Use a local path (./modules/...) while developing a module, switching to a versioned registry or git source once it stabilizes.
  • Pin git sources to an immutable tag with ?ref=v1.2.0, never to a branch, so every init resolves the same code.
  • Re-run terraform init after changing any source or version, and let CI run init fresh rather than trusting a cached .terraform/.
  • Keep the double-slash module subdirectory syntax (git::...//ingest-pipeline) explicit so a monorepo of modules resolves to the right one.
  • Reference a child only through module.<name>.<output>, never the internal resource address, so source changes never break callers.
Comparable tools Pulumi packages from npm or PyPI registries Helm chart repositories and OCI registries git submodules as the cruder private-sharing analog

Knowledge Check

Which module source type takes a version argument?

  • Only a registry source; local paths take none and git sources pin with ?ref= instead
  • Every source type accepts a version argument, including local paths and remote git URLs
  • Only git sources, which resolve version against the repository's published tags
  • Only local paths, where version selects a tagged subdirectory under the module root

How do you pin a git-sourced module to an immutable version?

  • Append a tag ref to the source: ?ref=v1.2.0
  • Add a version = "~> 1.2" argument to the module block
  • Point the source at the main branch and trust it not to change
  • Commit the module's code into .terraform/modules/

You change a module's source and run plan, but nothing changes. What is the likely cause?

  • You did not re-run init, so Terraform is using the stale cached copy in .terraform/modules/
  • Terraform detected the new source resolves to identical content and silently deduplicated it
  • Module source changes are deferred and only take effect on the next apply, never during plan
  • You must delete the state file first, since the old source is still recorded there

Why is editing a file under .terraform/modules/ to test a fix futile?

  • That directory is a cache init overwrites, so the edit vanishes and was never part of your source
  • The cache is mounted read-only, so your editor cannot write the file and the edit silently fails to save
  • Terraform recomputes a checksum and skips loading any cached module whose contents have changed
  • Edits there are honored by plan for previewing but are discarded before any apply

You got correct