Calling and Sourcing Modules
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.
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.
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" }
version.?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 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.
- Using a
versionargument with a git or localsource—versiononly 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
sourceat a branch (?ref=main) instead of a tag — the module changes under you on the nextinit -upgradewith no version bump to review. - Editing a module under
.terraform/modules/to "test a fix" — that directory is a cacheinitoverwrites; the edit vanishes and was never in your source. - Changing a module's
sourceorversionand runningplanwithout re-runninginit— 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.
- 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 everyinitresolves the same code. - Re-run
terraform initafter changing anysourceorversion, and let CI runinitfresh 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.
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
versionargument, including local paths and remote git URLs - Only git sources, which resolve
versionagainst the repository's published tags - Only local paths, where
versionselects 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
mainbranch 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 duringplan - 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
initoverwrites, 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
planfor previewing but are discarded before anyapply
You got correct