| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1cloudposse/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:
module "application" {
source = "./modules/application"
}module "app_label" {
source = "cloudposse/label/null"
version = "~> 0.25.0"
}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/.
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.
module "application" {
source = "./modules/application"
}Sibling directories use relative paths:
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:
mkdir -p ~/terraform-labs/terraform-module-source-version/local/modules/application && cd ~/terraform-labs/terraform-module-source-version/localCreate modules/application/main.tf:
cat > modules/application/main.tf <<'EOF'
variable "name" { type = string }
resource "terraform_data" "app" {
input = var.name
}
output "name" {
value = terraform_data.app.input
}
EOFAdd the root module that calls the child:
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
}
EOFInitialize and validate:
terraform init -input=false -no-colorWith init complete, validate the module wiring:
terraform validate -no-colorSample output:
Success! The configuration is valid.modules.json records the local path without a remote download:
cat .terraform/modules/modules.jsonSample 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:
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:
mkdir -p ~/terraform-labs/terraform-module-source-version/registry && cd ~/terraform-labs/terraform-module-source-version/registryWrite the root module configuration:
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
}
EOFDownload the module during init:
terraform init -input=false -no-colorSample 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:
terraform modules -no-colorSample 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:
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:
mkdir -p ~/terraform-labs/terraform-module-source-version/git-ref && cd ~/terraform-labs/terraform-module-source-version/git-refWrite the Git-sourced module block:
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
}
EOFClone the repository during init:
terraform init -input=false -no-colorInit completes without a Downloading registry.terraform.io line — Terraform fetches Git content instead.
Verify the Git source address:
terraform modules -no-colorSample 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:
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:
mkdir -p ~/terraform-labs/terraform-module-source-version/git-subdir && cd ~/terraform-labs/terraform-module-source-version/git-subdirPoint source at the examples/complete subdirectory inside the repository:
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"
}
EOFInitialize to clone the repo and select the nested path:
terraform init -input=false -no-colorInit 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:
module "network" {
source = "https://example.com/terraform-modules/network-v1.zip"
}S3 object (HTTPS endpoint form):
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/:
mkdir -p ~/terraform-labs/terraform-module-source-version/registry-upgrade && cd ~/terraform-labs/terraform-module-source-version/registry-upgradeStart with the older ~> 0.24.0 constraint:
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"
}
EOFInstall the module release that satisfies ~> 0.24.0:
terraform init -input=false -no-colorSample output:
Downloading registry.terraform.io/cloudposse/label/null 0.24.1 for app_label...Check the selected release:
terraform modules -no-colorSample 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:
sed -i 's/~> 0.24.0/~> 0.25.0/' main.tfRe-run init with -upgrade so Terraform can fetch the newer allowed release:
terraform init -upgrade -input=false -no-colorSample output:
Upgrading modules...
Downloading registry.terraform.io/cloudposse/label/null 0.25.0 for app_label...Confirm the upgrade:
terraform modules -no-colorSample 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:
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:
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 URLThe 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:
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-bad-source && cd ~/terraform-labs/terraform-module-source-version/errors-bad-sourceReference a directory that does not exist:
cat > main.tf <<'EOF'
module "x" {
source = "./does-not-exist"
}
EOFInit should fail while resolving the local path:
terraform init -input=false -no-colorSample output:
Error: Unreadable module directory
Unable to evaluate directory symlink: lstat does-not-exist: no such file or directoryReproduce an unavailable Registry version:
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-bad-version && cd ~/terraform-labs/terraform-module-source-version/errors-bad-versionRequest a Registry release that does not exist:
cat > main.tf <<'EOF'
module "x" {
source = "cloudposse/label/null"
version = "99.99.99"
}
EOFInit reports that no release matches the constraint:
terraform init -input=false -no-colorSample 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:
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-bad-git-ref && cd ~/terraform-labs/terraform-module-source-version/errors-bad-git-refPoint ref at a tag name the repository does not publish:
cat > main.tf <<'EOF'
module "x" {
source = "git::https://github.com/cloudposse/terraform-null-label.git?ref=not-a-real-tag"
}
EOFGit download fails during init:
terraform init -input=false -no-colorSample 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):
mkdir -p ~/terraform-labs/terraform-module-source-version/errors-git-version && cd ~/terraform-labs/terraform-module-source-version/errors-git-versionCombine a Git source with a Registry-only version argument:
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"
}
EOFTerraform rejects the mixed addressing during init:
terraform init -input=false -no-colorSample 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
- Module sources — Terraform language documentation
- Module blocks — Terraform language documentation
- terraform init command — Terraform CLI documentation
- terraform modules command — Terraform CLI documentation
- Dependency lock file — Terraform documentation
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.

