TerraformXIV · Modules: Reusable Building BlocksProduction Terraform
Module Sources and Versioning
What you'll learn
- Choose the right module source for the organisation
- Pin the module version with the ref argument
- Recognise the cost of an unpinned module source
- Distinguish the public Registry from internal Git sources
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
The source argument of a module block tells Terraform
where to fetch the module. The source is the most
under-reviewed argument in any production Terraform
configuration. A bad source produces a flat, unpredictable
apply. A good source produces one that can be reproduced
six months later by a different engineer.
This lesson teaches the source types, the discipline of versioning, and the cost of an unpinned source.
The source types
Terraform 1.9 supports nine source types. The four you will actually use in production:
| Source | Form | When to use |
|---|---|---|
| Local path | "./modules/network" | Inside the same repository, for tightly coupled modules |
| Git | "git::https://github.com/acme/modules.git//network?ref=v3.4.0" | Internal modules shared across repositories |
| Terraform Registry | "terraform-aws-modules/vpc/aws" | Public modules from the community |
| Internal Registry | "app.terraform.io/acme/network/vpc" | Internal modules with the same UX as the public Registry |
The local path is the simplest. The module is a directory in the same repository. The source is a filesystem path. The version is the commit hash of the consumer’s repository.
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
The local path is appropriate when the module is tightly coupled to the consumer. If the module is in the same repository as the consumer, the version is the same Git commit. Refactoring the module and the consumer together is straightforward.
The Git source is the right choice when the module is shared across repositories. The module lives in its own repository. The consumer references the repository and pins a tag:
module "network" {
source = "git::https://github.com/acme/modules.git//network?ref=v3.4.0"
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
The ref argument is the tag, branch, or commit hash. The
ref argument is what makes the source versioned.
The Terraform Registry is the right choice for public
modules from the community. The terraform-aws-modules
organisation and the Azure and GoogleCloud namespaces
publish modules that are widely used. The Registry is a
recommended channel for vendors.
The internal Registry is the right choice for organisations that need to manage their own modules with the same UX as the public Registry. The internal Registry is a service that runs in your infrastructure. The consumer references the module the same way as a public Registry module. The publisher pushes a version. The consumer gets a versioned interface.
Pinning the version
The ref argument pins the version. Without the ref
argument, the source is the default branch of the
repository. The default branch changes. The next terraform init may fetch a different commit.
# Bad: tracks the default branch
module "network" {
source = "git::https://github.com/acme/modules.git//network"
vpc_cidr = "10.0.0.0/16"
}
The consumer’s terraform init fetches the current
main branch. Tomorrow, the module author pushes a
breaking change. The consumer’s next terraform plan
shows the diff. The diff is not the consumer’s
intention. The diff is the module author’s unannounced
refactor.
The fix is to pin. The ref argument accepts a tag, a
branch name, or a commit hash. Tags are the right choice.
Tags are human-readable. Tags are immutable. Tags survive
repository housekeeping.
# Good: pinned to a tag
module "network" {
source = "git::https://github.com/acme/modules.git//network?ref=v3.4.0"
vpc_cidr = "10.0.0.0/16"
}
The consumer pins v3.4.0. The module author has a
release process. The consumer upgrades by changing the
pin. The upgrade is acknowledged.
The cost of an unpinned source
A team runs a production estate with thirty stacks. Each
stack references an internal network module. The source is
unpinned. The module author pushes a breaking change to
the default branch on a Friday afternoon. The Friday
evening on-call engineer runs terraform plan for an
unrelated change. The plan shows the network module
replacing fifteen resources. The engineer rejects the
plan. The team loses four hours investigating.
The cost of the unpinned source is real. The cost is commissioned by the module author’s unannounced change, but the cost is paid by the consumer’s on-call engineer. The fix is upstream: pin the source. The cost is also prevented at the module repository: protect the default branch, require pull request reviews, and tag releases.
The same problem applies to the public Registry. The
terraform-aws-modules/vpc/aws source without a version
argument tracks the latest version. The latest version
may have breaking changes between minor versions (for
modules that do not follow semver) or may not (for modules
that do). The Registry requires the version argument for
production use.
# Good: Registry source with version
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "production"
cidr = "10.0.0.0/16"
}
The version argument accepts a constraint expression,
the same syntax as provider versions:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = ">= 5.0.0, < 6.0.0"
name = "production"
cidr = "10.0.0.0/16"
}
The constraint is the upper bound. The constraint is the lower bound. The constraint is the contract.
The version lock file
The Terraform lock file (.terraform.lock.hcl) records
the exact provider version and hash. The module source is
not in the lock file. The module version is in the
source argument, not the lock file.
This is the right answer. The lock file is a tamper-proof record of the provider version. The module version is the consumer’s deliberate choice. The consumer pins. The consumer changes the pin. The consumer acknowledges the upgrade.
The lock file does not lock the module. The consumer operates the module lock.
Private modules and the internal Registry
The internal Registry is the right choice for organisations that need to:
- Publish modules with a private visibility (the public Registry is public).
- Manage the module’s lifecycle with the same UX as the public Registry.
- Integrate the module publication with the organisation’s CI pipeline.
The internal Registry is implemented as a service. The most common implementations are Terraform Cloud’s private registry and the self-hosted alternatives. The consumer references the module:
module "network" {
source = "app.terraform.io/acme/network/vpc"
version = "3.4.0"
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
The internal Registry records the version. The consumer sees the version. The module author publishes the version. The lifecycle is the same as the public Registry.
Validation commands
The reader validates the source after editing:
# Severity: READ-ONLY
terraform init
Initializing the backend...
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Installing hashicorp/aws v5.55.0...
Initializing modules...
- network in git::https://github.com/acme/modules.git//network?ref=v3.4.0
Terraform has been successfully initialized!
The output shows the source, the ref, and the resolved commit hash. The consumer can verify the pin is what was intended.
# Severity: READ-ONLY
terraform init -upgrade
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v5.60.0...
Initializing modules...
Terraform has been successfully initialized!
The -upgrade flag fetches the latest providers matching
the constraint. The module source is not upgraded by
-upgrade. The module source is upgraded by changing the
ref argument.
# Severity: READ-ONLY
terraform get -update
The following modules were reloaded:
- network (from git::https://github.com/acme/modules.git//network?ref=v3.4.0)
The terraform get -update command refreshes the module
tree. The output shows the pinned ref. The output is the
audit trail.
Production failure modes
-
Default branch as the source. The consumer tracks the latest commit. A breaking change to the default branch produces an unexpected plan. The fix is to pin a tag.
-
Branch name as a ref. The branch name is the latest commit on the branch. The branch is mutable. The fix is to pin a tag or a commit hash.
-
Commit hash as a ref (the wrong choice). The commit hash is correct but useless. The consumer cannot read the version. The fix is to tag the commit and pin the tag.
-
Unregistered module in the public Registry. The source is a typo. The error is “module not found”. The fix is to verify the source against the Registry.
-
Auth failure on a private Git source. The consumer’s CI cannot fetch the module. The fix is to configure the SSH key or access token in the CI environment.
-
Version constraint that allows a major upgrade.
version = ">= 3.0.0"includes the next major. The fix is to set the upper bound:version = ">= 3.0.0, < 4.0.0".
Security implications
- The source is a trust boundary. The consumer trusts the module author’s code. Public Registry modules are reviewed by HashiCorp; internal modules are reviewed by the team. Both are trust assumptions.
- The pinned ref is the audit trail. A consumer who needs to investigate a fault can fetch the pinned commit and reproduce the configuration.
- The HTTPS source is preferred over the SSH source in CI environments where the SSH key is not configured. The GitHub token (PAT or GitHub App) is the recommended approach.
- The internal Registry has the same trust model as the public Registry, but the visibility is private. The module is not exposed to the public.
Performance implications
- A
terraform initagainst a Git source is a network round trip. The cost is acceptable for a single configuration. The cost accumulates in a monorepo with many modules. - The Terraform Registry caches the module content. The
consumer’s
terraform initcosts the Registry a bandwidth charge. The cost is paid by HashiCorp (for public) or by the organisation (for internal). - The lock file does not affect the module source. The provider versions are cached. The modules are not.
What comes next
The next lesson is testing modules: the terraform test
framework, the *.tftest.hcl files, and the discipline
of test coverage.
Verification
-
terraform initdisplays the pinnedreffor every Git source. - Every module source has a
refargument or aversionargument. - The version constraint has an upper bound.
-
terraform get -updatereloads the modules without changing the pinned ref. - The CI pipeline can fetch the module source. The authentication is documented.
Knowledge check · 6 questions
Q1. What is the right form of a ref argument to pin a module to a specific version?
Q2. An unpinned module source tracks the default branch of the module repository.
Q3. A consumer's terraform plan shows unexpected replacements after a module default branch changed. What is the root cause?
Q4. Which is the right version constraint for a production module pinned to version 3.x?
Q5. Which of the following are valid module sources in Terraform 1.9? (Select all that apply.)
Q6. A consumer's CI pipeline fails to fetch a Git source module. The error is 'authentication required'. What is the right fix?
Passing score: 75%. Answers are checked in this browser.