Skip to content
intermediate Phase 7 · Module Design and the Module Registry

Module Source Addresses

Choosing between local paths, registry namespaces, and Git repository sources.

55m
0 problems
Topic Progress 0%

Local Sources: the ./ Prefix, Parent Resolution, and the Package Rule

The smallest useful source address is a path inside the repository you are already in:

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

Two rules govern every local source, and both are enforced with errors rather than conventions.

Rule one: the path must start with ./ or ../. Without the prefix Terraform does not read the address as a filesystem path at all — it tries to parse it as a registry address, fails, and tells you how to fix it:

Error: Module not found

The module address "modules/network" could not be resolved.

If you intended this as a path relative to the current module, use
"./modules/network" instead. The "./" prefix indicates that the address is
a relative filesystem path.

Rule two: the path resolves against the directory of the module that declares it, not against your shell's working directory and not against the repository root. A module block sitting in envs/prod/main.tf resolves ./modules/network to envs/prod/modules/network. If a child module at modules/app declares its own call with source = "./helpers", that resolves to modules/app/helpers — relative to the caller, always.

That second rule creates the trap people hit when they reach upward with ../:

# inside modules/app — this fails once modules/app is installed remotely
module "labels" {
  source = "../shared/labels"
}

Inside one repository this works, because the nearest external package is your root. But publish modules/app to a registry or Git repo and ../shared points outside the downloaded package. Terraform rejects it during init:

Error: Local module path escapes module package

The given source directory for labels would be outside of its containing
package. Local source addresses starting with "../" must stay within the same
package that the calling module belongs to.

A module and its local children ship together or not at all. Anything the module needs must live under the directory you publish.

Situation Source to write Why
One repo, team owns both sides ./modules/network Reviewed, versioned, and released in the same PR
Second repo needs the same code registry or Git source A path cannot cross a repository boundary
Module needs an internal helper ./helpers inside the module Keeps the package self-contained
Trying to share ../../common across repos restructure instead Escapes the package; init fails

Local sources are read in place — Terraform does not copy them into .terraform, so an edit to the child shows up on your next plan with no re-download. terraform init still records the mapping in .terraform/modules/modules.json, which is how later commands find the directory. The manifest line for a local call looks like this:

{"Key": "network", "Source": "./modules/network", "Dir": "modules/network"}

That Source string is the fingerprint Terraform compares against your configuration on every subsequent run — it is what later produces Module source has changed when you edit the address but skip init.

Local composition also has a shape worth defending: the declaring module resolves each child relative to itself, so nesting stays self-contained.

envs/prod/main.tf          →  source = "../../modules/network"
modules/network/main.tf    →  source = "./subnets"      (→ modules/network/subnets)
modules/network/main.tf    →  source = "../shared"      (→ only valid while inside the root package)

Because local code has no publisher and no versions, Module Block Anatomy already showed that attaching version to a path is a hard Invalid version constraint error. The commit you are sitting on is the version — which is exactly why a helper used by three repositories must eventually move to a registry or a tagged Git source.

Registry Addresses: namespace, name, provider, and What Init Asks the Registry

A registry address is a path with segments, and each segment answers one question:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5.0"
}
terraform-aws-modules/vpc/aws
└── namespace ────┘ └─ name ┘ └provider┘     (public host: registry.terraform.io)

Three segments mean "public registry, look it up on registry.terraform.io". Four segments mean a specific host, and that is how private registries are addressed:

Source Host Namespace Name Provider
terraform-aws-modules/vpc/aws registry.terraform.io (implicit) terraform-aws-modules vpc aws
app.terraform.io/acme-corp/vpc/aws app.terraform.io acme-corp vpc aws
modules.corp.example.com/platform/eks/aws modules.corp.example.com platform eks aws

The provider segment is metadata, not code selection — it declares the ecosystem the module targets, and it is how registries keep vpc from one team distinct from vpc from another.

During terraform init, a registry source triggers a short conversation. Terraform asks the host for its service-discovery document (/.well-known/terraform.json), finds the module registry endpoint, requests the version list for <namespace>/<name>/<provider>, picks the newest published version that satisfies your constraint, then asks where to download that exact version and pulls the archive into .terraform/modules/<label>. With no version argument at all, "newest published" is the answer — convenient in a demo, and a reproducibility hole in production: tomorrow's init can silently install a different module.

The selection is visible in init's output when a module must be fetched:

Initializing modules...
- vpc in app.terraform.io/acme-corp/vpc/aws
Downloading app.terraform.io/acme-corp/vpc/aws 1.4.0 for vpc...

And the constraint itself is ordinary Terraform version syntax, resolved against whatever the registry lists:

Constraint Resolves to (given 1.2.0 … 1.9.3, 2.0.0 published) Refuses when
version = "1.9.3" Exactly 1.9.3 Any other version
version = "~> 1.9.0" 1.9.3 — patch updates, minor ceiling locked 1.10.0 or 2.0.0
version = ">= 1.2, < 2.0" 1.9.3 — the newest inside the window 2.0.0
(omitted) 2.0.0 — newest published, always nothing

When the conversation goes wrong, the error tells you which leg failed:

Error: Unresolvable module version constraint

There is no available version of module "vpc" (main.tf:1) which matches the
given version constraint. The newest available version is 5.10.0.
Error: Module not found

Module "vpc" (from main.tf:1) cannot be found in the module registry at
app.terraform.io.

The first means the namespace and name resolved but your constraint excluded every version — fix the constraint. The second means the address itself missed — check the namespace (often the organization name), the module name, and the host. If both resolve and the archive fetch fails instead, you get Error: Failed to download module, quoting the download address Terraform was handed.

Two registry behaviors are worth knowing before an interview: pre-release versions (1.5.0-rc1) are only ever selected when the constraint asks for them exactly, and version selection is identical whether you are on the public registry or a private one — the protocol is the same, only the hostname changes. That consistency is the main reason to prefer a registry source over a raw Git URL, whose pinning story you are about to see is strictly weaker.

One operational difference does appear immediately: a private host answers 401 until your machine holds a token for it, so the first init in a clean container fails before any version logic runs. Keep the hostname in the source address and the credential in the CLI config or an environment variable — never in the source string itself.

Git Sources: Pinning With ref, Subdirectories With //, and Moving Branches

No registry, no shared path — just a repository. Terraform reads Git URLs directly:

module "vpc" {
  source = "github.com/acme/tf-modules//vpc?ref=v1.4.0"
}

Three pieces of punctuation carry the whole address:

  • github.com/ (also gitlab.com/, bitbucket.org/) is shorthand; anything else needs the explicit form, git::https://example.com/team/tf-modules.git//vpc?ref=v1.4.0.
  • //vpc selects a subdirectory of the repository — one repo can hold a dozen modules, and the package Terraform installs is the content under that subdirectory.
  • ?ref= pins the revision. Omit it and Terraform clones the default branch at HEAD, which means the version is whatever the branch happens to point at when init runs.
ref value Example Reproducible? What terraform init -upgrade does
Tag ?ref=v1.4.0 Yes, semver-shaped Stays on 1.4.0 unless you edit the ref
Commit SHA ?ref=3fa2c1d8e Byte-exact Cannot change — the commit is immutable
Branch ?ref=main No — silently moves Re-resolves to the branch's latest commit

Pin a tag, or a commit SHA when you need a stronger guarantee; never pin a branch. A branch ref makes every teammate's and every pipeline's next terraform init a coin flip on other people's unreviewed commits, and tags are only slightly weaker — anyone with push access can move one, whereas a SHA cannot be rewritten without changing the address itself. There is also a size lever: ?depth=1 asks Git for a shallow clone of just the selected commit, which matters when the module lives in a large monorepo.

Git sources take no version argument — Terraform says so during init:

Error: Invalid version constraint

Cannot apply a version constraint to module "vpc" (at main.tf:3) because it
doesn't come from a module registry.

The ref is the pin, and it is a weaker one: there is no constraint syntax, no version list, no ~> 1.4 — you retype the whole ref to move up a release.

Changing a source or a ref is not picked up by plan. It requires re-initialization, and until you do, Terraform refuses to run:

Error: Module source has changed

The source address was changed since this module was installed. Run
"terraform init" to install all modules required by this configuration.

For module-only work there is a lighter command than init: terraform get downloads and updates the modules declared in the root module into .terraform, with terraform get -update forcing already-installed modules to re-check their source. It handles no backends, no providers, and no lock file — so terraform init remains the command that makes a workspace usable, and terraform init -upgrade the one that re-resolves installed modules to the newest version your constraints allow.

terraform get                 # fetch every module the root declares
terraform get -update         # re-check sources that are already installed
terraform init -upgrade       # full re-init: providers, lock file, modules

Two details round out the picture. First, downloads are cached per label: the Git archive lands in .terraform/modules/vpc, and the manifest maps vpc back to the exact ref you asked for, so a second init with the same ref does nothing. Second, resolution is recursive — when the child module itself declares sources, its local paths resolve against its directory and its remote sources are fetched the same way, depth-first, all during the one init. That is why a single ?ref= on your top-level call can pull a whole tree of code you never see, and why reviewing what a Git source actually contains (the //subdir contents at that ref) is part of reviewing the change.

Whatever the transport, the child code arrives as plain files, and what it exposes back to you is its interface: Designing Module Input and Output Contracts.

Quiz

1. Which source address points at a local directory inside the declaring module?

Question 1 options

2. What does `source = "app.terraform.io/acme-corp/vpc/aws"` mean to `terraform init`?

Question 2 options

3. A teammate changes a module source from `./modules/vpc` to a Git URL and runs `terraform plan` without re-initializing. What happens?

Question 3 options

4. You need `terraform get` for a module-only task. What does it do — and not do?

Question 4 options

Flashcards

Question

What two rules govern local module sources?

Answer

The path must begin with `./` or `../` (otherwise Terraform parses it as a registry address and reports `Module not found` with a "use ./ instead" hint), and it resolves against the directory of the module that declares it — never the shell's working directory.

Question

What does `../` do once a module is installed from a registry or Git repo, and what is the error?

Answer

It points outside the downloaded package. Terraform fails init with `Error: Local module path escapes module package` — local paths must stay within the package the calling module belongs to, so a published module may only reference local children underneath its own directory.

Question

How are the segments of a registry source address laid out, publicly and privately?

Answer

Public: `namespace/name/provider` (e.g. `terraform-aws-modules/vpc/aws`) resolved on registry.terraform.io. Private/custom: `host/namespace/name/provider` (e.g. `app.terraform.io/acme-corp/vpc/aws`). Init discovers the host's registry endpoint, lists versions, and picks the newest matching the constraint.

Question

Which Git `ref` values pin a module, and which one must never be used in production?

Answer

A tag (`?ref=v1.4.0`) or a commit SHA (`?ref=3fa2c1d...`) pins reproducibly; a branch (`?ref=main`) must not be used, because it silently moves — `terraform init -upgrade` would re-resolve it to the branch tip. No ref at all means HEAD of the default branch.

Question

What do `terraform get` and `terraform init -upgrade` each do for modules?

Answer

`terraform get` downloads/updates only the root module's sources into `.terraform` (`-update` forces a re-check) — no backends, providers, or lock file. `terraform init -upgrade` re-resolves already-installed modules (and providers) to the newest versions the constraints allow.

Revision Notes

Key Takeaways

  • 1. The `./` or `../` prefix is what makes a source a filesystem path; without it Terraform parses the string as registry shorthand and reports `Module not found`
  • 2. Local sources resolve against the declaring module's own directory, and published modules may not reach outside their package with `../`
  • 3. Registry addresses are `namespace/name/provider` publicly and `host/namespace/name/provider` privately; init picks the newest version matching your constraint
  • 4. Git sources pin with `?ref=` (tag or SHA — never a branch), select subdirectories with `//`, and take no `version` argument at all
  • 5. Changing a source or ref requires `terraform init` (optionally `-upgrade`); `terraform get` installs modules only and writes nothing but `.terraform`

Interview Tips

  • • "When would you use a local source?" — one repository, one review, one PR; then immediately say when you would not: a second repo needs a registry or tagged Git source, and a module's local children must stay inside its published package
  • • "How does Terraform find a module version?" — service discovery on the host, version list for namespace/name/provider, newest matching the constraint; add that omitting `version` means newest overall and that pre-releases need exact requests
  • • "Your pipeline says Module not installed / Module source has changed — what happened?" — the manifest in `.terraform/modules/modules.json` disagrees with the config: never installed versus edited after install; both fixed by init, only the second by init after a source change
  • • "How do you pin a module from Git?" — `?ref=v1.4.0` tag or a full SHA, subdirectory via `//`, plus `?depth=1` for large repos — and explain that a branch ref makes init non-reproducible

Cheat Sheet

Module Source Addresses

Classify by prefix:

Prefix Kind Version pin Install command
./ or ../ Local path none (the commit is the version) terraform init (records it in .terraform/modules/modules.json)
ns/name/provider Public registry version = "~> 5.5" terraform init
host/ns/name/provider Private registry version = "1.4.0" terraform init (needs host credentials)
github.com/...?ref=, git::https://...?ref= Git ?ref= tag or SHA terraform init / terraform get -update

Rules that bite:

  • Local path must start with ./ → otherwise Terraform parses it as a registry address and fails with Module not found plus the "use ./path instead" hint.
  • Local path resolves relative to the declaring module's directory; ../ may not escape the package (Local module path escapes module package).
  • Registry: 3 segments = public host, 4 = explicit host; init picks the newest version matching the constraint, pre-releases only when requested exactly.
  • Git: //subdir selects a folder, ?ref= pins, ?depth=1 shallow-clones; version on a Git source → Invalid version constraint ... doesn't come from a module registry.

Init errors and their meaning:

Module not installed            → never ran init; run terraform init
Module source has changed       → source edited after install; re-run init
Module version requirements have changed → constraint moved; re-run init (add -upgrade)
Unresolvable module version constraint  → constraint excludes all published versions
Failed to download module       → registry resolved, archive fetch failed

Command roles: terraform get = modules only, into .terraform; terraform init = backends + providers + modules + lock; terraform init -upgrade = re-resolve installed modules/providers to the newest allowed.

Continue Learning