Calling and Sourcing Modules
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.
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.
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.
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.
module "dr_bucket" { source = "./modules/s3-bucket" name = "orders-backup" providers = { aws = aws.us_west } }
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.
- Sourcing a module from a git branch (
?ref=main) so everyinitcan pull different code, turning a reproducible build into a moving target that fails for reasons nobody can trace. - Forgetting to re-run
initafter changing asourceorversion, 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
versionargument, 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.
- Pin every remote module source to an immutable version or tag — a Registry
versionconstraint or a git?ref=on a tag — never a moving branch. - Re-run
terraform initafter any change to a module'ssourceorversionso 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.
Knowledge Check
Why is sourcing a module from ?ref=main a problem?
- A branch moves, so every
initcan 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.0version during the plan - Terraform silently plans against the old cached
5.1.0code 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