Skip to main content
RunBook Academy

TerraformXIV · Modules: Reusable Building BlocksProduction Terraform

Module Sources and Versioning

Intermediate⏱ ~14 minbashgit

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

Not yet marked complete on this device.

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:

SourceFormWhen 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 init against 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 init costs 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 init displays the pinned ref for every Git source.
  • Every module source has a ref argument or a version argument.
  • The version constraint has an upper bound.
  • terraform get -update reloads the modules without changing the pinned ref.
  • The CI pipeline can fetch the module source. The authentication is documented.

Knowledge check · 6 questions

  1. Q1. What is the right form of a ref argument to pin a module to a specific version?

  2. Q2. An unpinned module source tracks the default branch of the module repository.

  3. Q3. A consumer's terraform plan shows unexpected replacements after a module default branch changed. What is the root cause?

  4. Q4. Which is the right version constraint for a production module pinned to version 3.x?

  5. Q5. Which of the following are valid module sources in Terraform 1.9? (Select all that apply.)

  6. 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.