In a Terraform module block whose source is a Git repository, how do you pin the module to a specific version, and why can't you use the version argument?
answer
- Where does the pin actually live?
- One mechanism per source type
- version is registry-only
- ?ref= takes tag, SHA, or branch
- A branch ref pins nothing
basics
~20 sTerraform's version argument works only for registry module sources. A Git-sourced module is pinned inside the source string itself, with a ?ref= query parameter naming an immutable tag or commit SHA. A branch name in ?ref= is not a pin.
solid answer
~50 sTerraform resolves `version` by asking a module registry which versions exist, so the argument is only valid when `source` is a registry address like `terraform-aws-modules/vpc/aws`. For a Git source there is no version list to query — the address *is* the version — so you pin with the `?ref=` query parameter: `source = "git::ssh://[email protected]/acme/vpc.git?ref=v2.3.1"`. Putting `version` next to a Git source is a configuration error, not a silent no-op. `?ref=` accepts a branch, a tag, or a commit SHA, and only the last two are real pins: with `?ref=main` your module content changes whenever someone merges, so a re-`init` can change the plan with no diff in your own repo for a reviewer to see. In practice teams tag module releases with semver and pin the tag, reserving commit SHAs for cases where tag mutability is a real threat.
code
hcl · 16 lines# Registry source: version list is queryable, so a constraint is allowed.
module "vpc_registry" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1"
name = "platform"
cidr = "10.0.0.0/16"
}
# Git source: the revision is part of the address, via ?ref=.
module "vpc_git" {
source = "git::ssh://[email protected]/acme/terraform-modules.git//vpc?ref=v2.3.1"
name = "platform"
cidr = "10.0.0.0/16"
}go deeper
Know that a module block always has a source, and that for a Git URL the version goes in the address as ?ref=. Be able to point at the tag in a real source string.
Explain why the two mechanisms differ: a registry can be queried for a version list and resolve a constraint, a Git URL cannot, so the revision is selected by ?ref=. Say plainly that a branch ref is not a pin.
Show the operational consequence: a floating ref makes plans non-reproducible across CI runners and clean clones, and lets anyone with push access change production. Talk about protected tags versus SHA pins as a deliberate call.
Own the policy: which sources are permitted at all, whether production root modules pin exact versions or ranges, how tag immutability is enforced across the estate, and who is accountable when a module change reaches production without a reviewable diff.
## `source` is an address, not a URL Every Terraform `module` block requires a `source` argument. It is not an ordinary URL: it is a *module source address* that Terraform's module installer parses to decide which fetcher to use — local filesystem, module registry, Git, Mercurial, plain HTTP archive, or an object store such as S3 or GCS. Which fetcher is chosen determines which versioning mechanism is available to you, and that is the whole of this question. ## Registry sources: the `version` argument A registry address has the shape `<NAMESPACE>/<NAME>/<PROVIDER>`, optionally prefixed with a registry hostname for a private registry: ```hcl module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.1" } ``` Only here is the separate `version` argument accepted, because only a registry exposes a queryable list of published versions. Terraform asks the registry what exists, evaluates your constraint against that list, and downloads the newest version that satisfies it. The constraint operators are the usual ones — `=`, `!=`, `>`, `>=`, `<`, `<=` and the pessimistic operator `~>`, where `~> 5.1` means ">= 5.1, < 6.0" and `~> 5.1.2` means ">= 5.1.2, < 5.2.0". A detail worth knowing: the dependency lock file records *provider* selections, not module versions. Modules are resolved at `init` time against your constraint and recorded in `.terraform/modules/modules.json` inside the working directory, which is not committed. So a loose module constraint is genuinely loose — the only durable record of what you intended is the constraint in the code. ## Git sources: the `?ref=` parameter Git addresses are written either with the explicit `git::` prefix over any transport, or with the GitHub/Bitbucket shorthand that Terraform detects automatically: ```hcl module "vpc" { source = "git::ssh://[email protected]/acme/terraform-modules.git//vpc?ref=v2.3.1" } ``` There is no registry behind this address, so there is no list of versions to resolve a constraint against. Instead the *address itself* selects the revision, through the `?ref=` query parameter, which is passed through to Git and therefore accepts anything Git can check out: a branch name, a tag, or a full commit SHA. The `//` marks a subdirectory inside the repository. Writing `version` alongside a Git source is not ignored — Terraform fails the configuration, telling you that a version constraint is only meaningful for registry modules. That is a good error to have met once, because it is exactly the confusion this question probes. ## Why a branch ref is not a pin `?ref=main` looks like pinning and behaves like nothing at all. Terraform caches the fetched module under `.terraform/modules`, so an existing working directory keeps the commit it already downloaded; but a fresh CI job, a new clone, or a re-`init` fetches whatever `main` points at now. The consequences: - Your plan can change with no diff in your repository. A reviewer looking at the pull request sees no reason for the resources that are about to change. - Two engineers on the same commit of your repo can produce different plans, because they initialised on different days. - It is a supply-chain hole: anyone who can push to that branch can change your infrastructure without touching your code. Omitting `?ref=` entirely is worse in the same way — you get the repository's default branch. ## Tag versus commit SHA A tag is the readable, upgradeable choice: `?ref=v2.3.1` tells a reviewer what changed and makes the upgrade a one-line, greppable diff. Its weakness is that Git tags are mutable — a force-pushed tag silently changes the module's content. Mitigate that with protected tags in your Git host, or pin the 40-character commit SHA when you cannot rely on that, accepting that the pin is now unreadable and that upgrades need a lookup. Many teams use tags everywhere and SHAs only for modules that touch security-sensitive resources. ## Upgrading Because Git pins live in the source string, an upgrade is a text edit — change the `ref`, run `terraform init` so the new revision is downloaded into `.terraform/modules`, then read the plan before applying. That the pin is visible in the diff is the point: with registry modules a loose `~>` constraint can pull a new minor version on someone else's machine, whereas a Git tag pin only moves when a human edits the file.
- You pinned ?ref=v1.4.0 and someone force-pushed that tag in the module repository. What is your exposure, and how do you close it?Git tags are mutable, so the content behind your pin can change while the code does not. The next `init` in a clean directory fetches the new commit and your plan shifts with no reviewable diff. Close it with protected tags or a release process that never rewrites them, and for high-risk modules pin the commit SHA in `?ref=` instead, which cannot be moved.
- What is the difference between how Terraform stores a Git-sourced module and how it stores a local-path module?A Git source is a module *package*: Terraform clones it at `init` into `.terraform/modules` and records the resolution in `modules.json`. A local path starting with `./` or `../` is not downloaded or copied at all — it is read in place, shares your repository's version control, and therefore takes no `version` argument and no `ref`.
- If Git pins are edited by hand, how do you keep a plan from changing between the pull request and the merge?An immutable `?ref=` tag or SHA already guarantees the module content is identical in both runs; the remaining sources of skew are your own repository's other commits and real-world changes to the infrastructure. Registry modules with a range constraint do not have that guarantee, which is one argument for exact `version = "1.4.0"` pins in production root modules.
saying these in an interview costs you the question
- Thinks version works with any module source
- Says ?ref=main is a valid version pin
- Believes the lock file records module versions
- Assumes a Git tag can never move
- Confuses the module version argument with required_version