10.1 How Terraform Sources Modules

Key Takeaways

  • A module block's required source argument is the address Terraform uses during terraform init to locate the child's .tf files
  • Relative local paths must start with ./ or ../ and are used in place; absolute paths are copied into the module cache and are not recommended
  • Public registry modules use NAMESPACE/NAME/PROVIDER (hashicorp/consul/aws); HCP private modules prepend app.terraform.io/
  • Git modules use git:: plus a Git URL and pin a revision with ?ref=; HTTP, s3::, and gcs:: install from archives or terraform-get redirects
  • terraform init (or terraform get) installs remote modules into .terraform/modules; changing source or adding a module requires a new init
Last updated: August 2026

10.1 How Terraform Sources Modules

Quick Answer: The required source argument is the address Terraform uses to find a child module. Relative local paths start with ./ or ../ and are used in place. The public registry uses NAMESPACE/NAME/PROVIDER (hashicorp/consul/aws). Git uses git::https://... and pins a revision with ?ref=. HTTP, s3::, gcs::, and the HCP private registry (app.terraform.io/...) are the other sources 004 tests. terraform init installs remote modules into .terraform/modules.

Objective 5a on Terraform Associate (004) is: explain how Terraform sources modules. The product version on the exam is Terraform 1.12. Official pages: Module sources, the module block reference, and Use modules in your configuration.

Why source is a separate idea from version

A module is a directory of Terraform configuration. The root module is the working directory where you run Terraform. A child module is any directory you call with a module block. source answers one question: where are that child's files? version (section 10.4) answers a different question: which published edition of those files? Mixing the two is the most common 5a miss.

On Terraform 1.12, write source as a static address in the module block. After you add a module, change source, or change a registry version, you must run terraform init again so Terraform can refresh the local install.

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

module "consul" {
  source  = "hashicorp/consul/aws"
  version = "0.11.0"
}

module "shared_net" {
  source = "git::https://example.com/network.git//modules/vpc?ref=v1.2.0"
}

You may point two module blocks at the same source if their labels differ. That is how one VPC module becomes module.network_prod and module.network_lab with different inputs.

Source types 004 expects you to recognize

SourceAddress shapeHow Terraform installs itversion argument?
Relative local./modules/vpc, ../sharedUsed in place; not copied as a packageNo
Absolute local/opt/modules/vpc or a Windows drive pathCopied into the module cache as a packageNo
Public registryhashicorp/consul/aws (NAMESPACE/NAME/PROVIDER)Registry protocol from registry.terraform.ioYes
HCP private registryapp.terraform.io/<ORG>/<NAME>/<PROVIDER>Same protocol against HCP TerraformYes
Terraform Enterprise<HOSTNAME>/<ORG>/<NAME>/<PROVIDER>Same protocol against your TFE hostYes
Generic Gitgit::https://... or git::ssh://...git cloneNo — pin with ?ref=
GitHub shorthandgithub.com/<ORG>/<REPO>git clone over HTTPSNo — ?ref=
HTTP / HTTPSArchive URL or a vanity URLGET, possibly following terraform-getNo
S3 archives3::https://s3-eu-west-1.amazonaws.com/bucket/vpc.zipDownload and extract the archiveNo
GCS archivegcs::https://www.googleapis.com/storage/v1/BUCKET/path/module.zipDownload and extract the archiveNo

Local paths

A local source must begin with ./ or ../. That prefix is how Terraform distinguishes a disk path from a registry address. source = "modules/vpc" is not a local path — Terraform treats it as a registry-style address and init fails.

Relative local modules are special. Terraform records them in .terraform/modules (look at modules.json) but does not copy their files into a package. The child is the directory on disk. Edit ./modules/vpc/main.tf and the next terraform plan sees the change without another init. That is the opposite of a registry or Git module, whose files live as a downloaded snapshot until you re-init or upgrade.

Absolute paths (/usr/local/modules/vpc) are recognized as local, but HashiCorp does not recommend them. Terraform copies an absolute path into the module cache as a package, which couples the configuration to one machine's filesystem layout.

../shared is legal and common when several root modules in a monorepo share a sibling directory. The path is resolved relative to the calling module, not relative to wherever your shell happens to sit.

Terraform Registry and HCP

The public registry address has three slash-separated segments and no https://:

  • Namespace — the publisher (hashicorp, terraform-aws-modules, your org).
  • Name — the module name (consul, vpc).
  • Provider — the primary provider the module targets (aws, azurerm, google).

hashicorp/consul/aws is the official example on HashiCorp's module-source documentation. For a private module on HCP Terraform, prepend the hostname: app.terraform.io/acme/vpc/aws. Terraform Enterprise uses your deployment hostname the same way. The registry protocol is the only source type that fully supports the version argument.

Git, including ref

Prefix a Git URL with git:: so Terraform knows to run git clone. Pin the revision with the ref query parameter. ref accepts anything git checkout accepts: a tag (v1.2.0), a branch (main), or a commit SHA. If you omit ref, Terraform clones the default branch pointed to by HEAD — that is not a production pin.

module "vpc" {
  source = "git::https://example.com/vpc.git?ref=v1.2.0"
}

module "storage" {
  source = "git::https://example.com/storage.git?ref=51d462976d84fdea54b47d80dcabbf680badcdb8"
}

module "private" {
  source = "git::ssh://git@github.com/acme/network.git//modules/vpc?ref=v1.4.0"
}

GitHub also has a shorthand: source = "github.com/hashicorp/example". Terraform still runs git clone. SSH keys are the usual way to clone private repositories from automation and from HCP Terraform. You cannot put version = "1.2.0" on a Git source and expect the registry selector to run — Git is not a registry.

A module can live in a subdirectory of a larger repository. Insert // after the repository (the package root) and before the inner path. Query parameters such as ref go after the subdirectory: git::https://example.com/network.git//modules/vpc?ref=v1.2.0. Terraform clones the whole package, then reads the module from that subdirectory so sibling local source = "../iam" references inside the package still resolve.

HTTP, S3, and GCS

An HTTPS URL can point at an archive (.zip, .tar.gz, .tgz, .tar.bz2, .tar.xz). Terraform downloads and extracts it. A vanity URL that is not an archive is fetched with GET plus terraform-get=1. The server returns the real source either in an X-Terraform-Get header or in an HTML <meta name="terraform-get"> tag.

Object storage uses an explicit prefix so Terraform does not treat the URL as a generic HTTP redirect:

  • S3: s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip. The object must be an archive. Credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, the default shared-credentials profile, or an EC2 instance profile. Buckets in us-east-1 use the s3.amazonaws.com hostname.
  • GCS: gcs::https://www.googleapis.com/storage/v1/BUCKET_NAME/PATH/module.zip. Authentication uses Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_OAUTH_ACCESS_TOKEN, a GCE instance service account, or gcloud auth application-default login).

What terraform init puts on disk

terraform init walks every module block, retrieves remote sources, and records the result under .terraform/modules. terraform get performs the module-install step alone. Do not commit .terraform/. A teammate or an HCP Terraform run performs its own install from the source (and version / ref) written in configuration.

Re-running init after modules are already installed adds newly declared modules but does not refresh already-installed remote modules. Use terraform init -upgrade or terraform get -update when you intend to pull newer matching code. Changing source always requires a new init.

004 traps for module sources

  • ./ and ../ are required for local paths. Bare modules/vpc is not local.
  • Relative local modules are live directories. Registry and Git modules are snapshots.
  • hashicorp/consul/aws is a registry address, not a GitHub URL and not an S3 key.
  • version is registry-only. Git pins with ?ref=.
  • // marks a subdirectory inside a package; ?ref= comes after it.
  • HCP private modules start with app.terraform.io/, not hashicorp/.
  • terraform init is what downloads modules. terraform apply does not clone a new source by itself.
Loading diagram...
How terraform init resolves module.source on Terraform 1.12
Test Your Knowledge

On Terraform 1.12, which source address is a valid public Terraform Registry module?

A
B
C
D
Test Your Knowledge

You edit main.tf inside ./modules/vpc and run terraform plan without terraform init. On Terraform 1.12, what happens?

A
B
C
D
Test Your Knowledge

How do you pin a Git-hosted module to tag v1.2.0 on Terraform 1.12?

A
B
C
D