Terraform Module Sources and Version Constraints

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
cloudposse/label/null 0.24.1 / 0.25.0
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user
Scope Terraform module source types — local paths, public Registry addresses, Git and GitHub with ref pinning, subdirectory syntax, brief S3 and HTTP archive recognition, Registry version constraints, terraform init and init -upgrade, terraform modules, provider vs module versioning, lock file distinction, and common source errors. Does not cover private Registry setup, Git authentication, S3 infrastructure setup, module publishing, provider constraint tutorials, or Registry discovery depth.
Related guides Terraform modules
Terraform Registry modules
terraform init command
Provider version constraints and lock file
Terraform Associate certification course

The source argument on a module block tells Terraform where to load child-module configuration. How you pin a revision depends entirely on that source type — local paths, Registry addresses, and Git URLs each use different mechanisms.

Three patterns cover most Associate-level work:

hcl
module "application" {
  source = "./modules/application"
}
hcl
module "app_label" {
  source  = "cloudposse/label/null"
  version = "~> 0.25.0"
}
hcl
module "app_label" {
  source = "git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.25.0"
}

This guide explains each source category, how version selection differs, and how to install or upgrade modules with terraform init. Labs use cloudposse/label/null and cloudposse/terraform-null-label on GitHub — null-provider naming modules with no billable cloud resources.

Each exercise uses its own subdirectory under ~/terraform-labs/terraform-module-source-version/.

NOTE
Use the Terraform lab environment on Ubuntu. Public module versions and Git tags change over time; this article pins releases verified on the lab host. If init fails with a version or ref error, check the Registry page or repository tags and update the constraint before retrying.

Terraform module source types

The source string selects where Terraform reads .tf files for a child module:

Category Example shape Version pinning
Local filesystem ./modules/application None — use the files on disk
Terraform Registry namespace/name/system version argument
Git / GitHub git::https://github.com/org/repo.git ?ref= tag, branch, or commit SHA
HTTP archive https://example.com/module.zip URL path or tag in filename
S3 / object storage s3::https://bucket.s3.region.amazonaws.com/key.zip Object key version you upload

Terraform downloads remote sources during init and caches them under .terraform/modules/. Local sources are referenced in place — not packaged like Registry releases.

This lesson focuses on local, Registry, and Git sources. S3 and HTTP appear briefly so you recognize the syntax; setting up buckets or hosting archives is out of scope.


Local module sources

Local modules live in your repository. Terraform reads the directory you point at — no download step.

hcl
module "application" {
  source = "./modules/application"
}

Sibling directories use relative paths:

hcl
module "shared" {
  source = "../modules/shared"
}

Local modules do not support the version argument. The configuration on disk is always what Terraform loads. Change the files, then run plan — no Registry release to fetch.

Write the child module files — change into the lab directory first:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/local/modules/application && cd ~/terraform-labs/terraform-module-source-version/local

Create modules/application/main.tf:

bash
cat > modules/application/main.tf <<'EOF'
variable "name" { type = string }

resource "terraform_data" "app" {
  input = var.name
}

output "name" {
  value = terraform_data.app.input
}
EOF

Add the root module that calls the child:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

module "application" {
  source = "./modules/application"
  name   = "local-demo"
}

output "app_name" {
  value = module.application.name
}
EOF

Initialize and validate:

bash
terraform init -input=false -no-color

With init complete, validate the module wiring:

bash
terraform validate -no-color

Sample output:

output
Success! The configuration is valid.

modules.json records the local path without a remote download:

bash
cat .terraform/modules/modules.json

Sample output:

output
{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"application","Source":"./modules/application","Dir":"modules/application"}]}

The Source field shows ./modules/application — a filesystem path, not a Registry address.


Terraform Registry module sources

Public Registry modules use the three-part address documented in Terraform Registry modules:

hcl
module "app_label" {
  source  = "cloudposse/label/null"
  version = "~> 0.25.0"

  namespace = "golinuxcloud"
  name      = "registry-src"
}

Only Registry sources accept the version argument on the module block. Terraform resolves the constraint during init and downloads the selected release.

Create the Registry lab directory and configuration:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/registry && cd ~/terraform-labs/terraform-module-source-version/registry

Write the root module configuration:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

module "app_label" {
  source  = "cloudposse/label/null"
  version = "~> 0.25.0"

  namespace = "golinuxcloud"
  name      = "registry-src"
}

output "label_id" {
  value = module.app_label.id
}
EOF

Download the module during init:

bash
terraform init -input=false -no-color

Sample output:

output
Downloading registry.terraform.io/cloudposse/label/null 0.25.0 for app_label...
- app_label in .terraform/modules/app_label

Terraform has been successfully initialized!

Confirm the resolved version:

bash
terraform modules -no-color

Sample output:

output
Modules declared by configuration:
.
└── "app_label"[registry.terraform.io/cloudposse/label/null] 0.25.0 (~> 0.25.0)

The trailing (~> 0.25.0) is your constraint; 0.25.0 is the release Terraform selected.


Module version constraints

Registry version arguments use the same constraint syntax as provider versions. Common operators:

Operator Meaning Example constraint Typical matches
= Exact version = 0.25.0 Only 0.25.0
>= Minimum inclusive >= 0.24.0 0.24.0 and newer
<= Maximum inclusive <= 0.25.0 Up to 0.25.0
~> Pessimistic (patch within minor) ~> 0.25.0 0.25.x but not 0.26.0
~> Pessimistic (within major) ~> 2.1 2.1.0 through any 2.x release, but not 3.0.0

The constraint defines acceptable releases. During init, Terraform picks the newest release that satisfies it.

~> 0.25.0 allows patch updates within the 0.25 line — useful when you want bugfix releases without jumping to 0.26.0. ~> 2.1 allows releases from 2.1.0 up to but not including 3.0.0. Use ~> 2.1.0 when you want to stay within the 2.1.x patch line.

Omitting version on a Registry module is legal — Terraform selects the latest available release. Pinning or constraining the version is strongly recommended so init does not silently pick a newer release you have not tested.


Git and GitHub module sources

Git sources clone a repository (or fetch an archive) during init. Pin the revision in the URL — not with version:

hcl
module "app_label" {
  source = "git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.25.0"

  namespace = "golinuxcloud"
  name      = "git-demo"
}

The git:: prefix tells Terraform to use Git protocol handling. ref can name a tag, branch, or commit SHA depending on what the repository exposes.

For reproducibility, prefer a release tag or, when you need the strongest immutable reference, a full commit SHA. Avoid moving branches such as main when you need deterministic module source code.

Create the Git lab:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/git-ref && cd ~/terraform-labs/terraform-module-source-version/git-ref

Write the Git-sourced module block:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

module "app_label" {
  source = "git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.25.0"

  namespace = "golinuxcloud"
  name      = "git-demo"
}

output "label_id" {
  value = module.app_label.id
}
EOF

Clone the repository during init:

bash
terraform init -input=false -no-color

Init completes without a Downloading registry.terraform.io line — Terraform fetches Git content instead.

Verify the Git source address:

bash
terraform modules -no-color

Sample output:

output
Modules declared by configuration:
.
└── "app_label"[git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.25.0]

Do not add version = "..." to a Git module block — Terraform treats version as Registry-only. Pin Git revisions with ref in the source URL instead.


Modules from a subdirectory

Monorepos often store multiple modules under one repository. Append //subdirectory after the repository URL:

hcl
module "example" {
  source = "git::https://github.com/cloudposse/terraform-null-label.git//examples/complete?ref=tags/0.25.0"
}

Terraform clones the repo, then uses the examples/complete folder as the module root. The same // syntax works with Registry and other remote sources when the publisher documents a nested path.

Create the subdirectory lab:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/git-subdir && cd ~/terraform-labs/terraform-module-source-version/git-subdir

Point source at the examples/complete subdirectory inside the repository:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

module "example" {
  source = "git::https://github.com/cloudposse/terraform-null-label.git//examples/complete?ref=tags/0.25.0"
}
EOF

Initialize to clone the repo and select the nested path:

bash
terraform init -input=false -no-color

Init succeeds. terraform modules lists nested child modules inside the example — a large fixture module. Use subdirectory syntax when the README points at a path inside the repo, not only at the repository root.


Other remote sources

Terraform also supports module archives over HTTP and object storage. You will see these in enterprise environments even when this course does not set up the hosting infrastructure.

HTTP archive:

hcl
module "network" {
  source = "https://example.com/terraform-modules/network-v1.zip"
}

S3 object (HTTPS endpoint form):

hcl
module "network" {
  source = "s3::https://s3.amazonaws.com/example-bucket/modules/network.zip"
}

Init attempts to download the archive. Without a real hosted object, init fails with a download error — expected on a lab host with no bucket. Version pinning for these sources is whatever naming convention your team uses for archive keys or URLs; there is no Registry-style version argument.


Install and upgrade modules

Every source type flows through init. After you add a module, change source, change a Registry version constraint, or change a Git ref, re-run init in that working directory.

Upgrade a Registry module constraint

Start with an older constraint in ~/terraform-labs/terraform-module-source-version/registry-upgrade/:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/registry-upgrade && cd ~/terraform-labs/terraform-module-source-version/registry-upgrade

Start with the older ~> 0.24.0 constraint:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

module "app_label" {
  source  = "cloudposse/label/null"
  version = "~> 0.24.0"

  namespace = "golinuxcloud"
  name      = "upgrade-demo"
}
EOF

Install the module release that satisfies ~> 0.24.0:

bash
terraform init -input=false -no-color

Sample output:

output
Downloading registry.terraform.io/cloudposse/label/null 0.24.1 for app_label...

Check the selected release:

bash
terraform modules -no-color

Sample output:

output
└── "app_label"[registry.terraform.io/cloudposse/label/null] 0.24.1 (~> 0.24.0)

Edit main.tf and change the constraint to ~> 0.25.0, then ask init to re-resolve module packages:

bash
sed -i 's/~> 0.24.0/~> 0.25.0/' main.tf

Re-run init with -upgrade so Terraform can fetch the newer allowed release:

bash
terraform init -upgrade -input=false -no-color

Sample output:

output
Upgrading modules...
Downloading registry.terraform.io/cloudposse/label/null 0.25.0 for app_label...

Confirm the upgrade:

bash
terraform modules -no-color

Sample output:

output
└── "app_label"[registry.terraform.io/cloudposse/label/null] 0.25.0 (~> 0.25.0)

Init downloaded 0.25.0 after the constraint change. -upgrade also refreshes already-installed remote modules. For Registry modules it re-resolves releases within the allowed constraint; for a Git source that tracks a moving branch, it can fetch newer source code for that same branch reference.

Inspect the module cache when debugging:

bash
ls .terraform/modules/

Do not edit files under .terraform/modules/ by hand. Change source, version, or ref in configuration and re-run init.


Provider versions vs module versions

Provider and module versioning use different fields and different persistence:

text
Provider plugin:
  required_providers { version = "~> 2.5" }
  locked in .terraform.lock.hcl

Registry module:
  module { version = "~> 0.25.0" }
  selected during init — not locked in .terraform.lock.hcl

Git module:
  source = "git::...?ref=tags/0.25.0"
  revision pinned in the source URL

The provider version constraints and lock file lesson covers required_providers and .terraform.lock.hcl. That lock file records provider plugin checksums for reproducible installs across machines.

Module selections are not locked the same way today. Registry module versions come from your version constraint at init time. Git module revisions come from ref in the source string. Commit your module version constraints and Git ref values in version control so teammates resolve the same releases.


Common source and version errors

Symptom Likely cause Fix
Unreadable module directory Local source path does not exist Create the directory or fix the relative path
Unresolvable module version constraint No Registry release matches version Open the module Registry page; pick an available release
Failed to download module / invalid ref Git tag, branch, or commit not found Verify ref against repository tags; use a release tag or full commit SHA
Invalid registry module source address with version on Git source version used with non-Registry source Remove version; pin with ?ref= instead
Init succeeds but plan fails on providers Module needs providers you have not configured Add required_providers and provider blocks
Stale module after editing source Init not re-run Run terraform init or terraform init -upgrade

Reproduce a missing local path:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-bad-source && cd ~/terraform-labs/terraform-module-source-version/errors-bad-source

Reference a directory that does not exist:

bash
cat > main.tf <<'EOF'
module "x" {
  source = "./does-not-exist"
}
EOF

Init should fail while resolving the local path:

bash
terraform init -input=false -no-color

Sample output:

output
Error: Unreadable module directory

Unable to evaluate directory symlink: lstat does-not-exist: no such file or directory

Reproduce an unavailable Registry version:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-bad-version && cd ~/terraform-labs/terraform-module-source-version/errors-bad-version

Request a Registry release that does not exist:

bash
cat > main.tf <<'EOF'
module "x" {
  source  = "cloudposse/label/null"
  version = "99.99.99"
}
EOF

Init reports that no release matches the constraint:

bash
terraform init -input=false -no-color

Sample output:

output
Error: Unresolvable module version constraint

There is no available version of module
"registry.terraform.io/cloudposse/label/null" (main.tf:1) which matches the
given version constraint. The newest available version is 0.25.0.

Reproduce a bad Git ref:

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-bad-git-ref && cd ~/terraform-labs/terraform-module-source-version/errors-bad-git-ref

Point ref at a tag name the repository does not publish:

bash
cat > main.tf <<'EOF'
module "x" {
  source = "git::https://github.com/cloudposse/terraform-null-label.git?ref=not-a-real-tag"
}
EOF

Git download fails during init:

bash
terraform init -input=false -no-color

Sample output:

output
Error: Failed to download module

Could not download module "x" (main.tf:1) source code from
"git::https://github.com/cloudposse/terraform-null-label.git?ref=not-a-real-tag":
error downloading
'https://github.com/cloudposse/terraform-null-label.git?ref=not-a-real-tag':
invalid ref: "not-a-real-tag"

Reproduce version on a Git source (Registry-only argument):

bash
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-git-version && cd ~/terraform-labs/terraform-module-source-version/errors-git-version

Combine a Git source with a Registry-only version argument:

bash
cat > main.tf <<'EOF'
module "x" {
  source  = "git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.25.0"
  version = "0.25.0"
}
EOF

Terraform rejects the mixed addressing during init:

bash
terraform init -input=false -no-color

Sample output:

output
Error: Invalid registry module source address

  on main.tf line 2, in module "x":
   2:   source  = "git::https://github.com/cloudposse/terraform-null-label.git?ref=tags/0.25.0"

References


Summary

You compared the three module source families that matter most on the Associate path. Local ./modules/... paths read files from disk with no version argument. Registry namespace/name/system addresses use version constraints resolved during init. Git git::https://...?ref=... URLs pin revisions in the source string — never with version.

The upgrade lab showed ~> 0.24.0 selecting 0.24.1, then terraform init -upgrade fetching 0.25.0 after the constraint moved to ~> 0.25.0. terraform modules and .terraform/modules/modules.json confirm what Terraform installed without editing cached code by hand.

Keep provider versioning separate from module versioning. required_providers and .terraform.lock.hcl lock provider plugins; Registry module releases and Git refs are controlled through version and ref in your configuration. Pin both deliberately in version control before teammates run init on shared stacks.

When you finish, remove lab directories under ~/terraform-labs/terraform-module-source-version/ or run destroy in any directory that created resources.


Frequently Asked Questions

1. Can I use the version argument with a Git module source?

No. The version argument applies to Terraform Registry module sources only. For Git modules, pin the revision with ref in the source URL, for example git::https://github.com/org/repo.git?ref=tags/1.0.0.

2. What does ~> mean in a Terraform module version constraint?

The pessimistic operator ~> allows the right-most specified component to increment. For example, ~> 2.1.0 permits 2.1.x but not 2.2.0, while ~> 2.1 permits any 2.x release from 2.1.0 up to but not including 3.0.0. Terraform selects the newest release that satisfies the constraint during init.

3. When should I run terraform init -upgrade for modules?

Run terraform init -upgrade after you change a Registry module version constraint or when you want Terraform to re-resolve releases within the allowed range. For a pinned Git tag or commit, change ref and re-run terraform init when you want another revision. If ref points to a moving branch such as main, terraform init -upgrade can refresh the installed module from the newer branch tip.

4. Does terraform lock file lock module versions?

No. The .terraform.lock.hcl file records provider plugin selections for dependency locking. Registry module versions are selected from your version constraint during init; Git module revisions come from the ref in the source address.

5. What is the double slash syntax in a Git module source?

The //subdirectory segment selects a folder inside the repository after the clone. Example: git::https://github.com/org/monorepo.git//modules/network?ref=v1.0.0 loads the modules/network directory instead of the repository root.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)