Terraform Guide

Terraform module versions and dependencies: draw what your repo actually pins

By the engineer who builds Datadef, from client work on data platforms · Reviewed August 21, 2026

Two different things get called a module dependency, and confusing them causes real incidents. One is supply chain: which version of terraform-aws-modules/vpc/aws this repo consumes. The other is composition: which of your modules needs an output from which. Terraform records both, in different places, and neither is in the dependency lock file.

7 min readFor maintainers upgrading modules across several stacks without breaking one

See it as a diagram

Everything below, as a diagram you can edit. Describe yours and see it in seconds.

153/20003 credits left
Try:

No account needed · Editable canvas, not a picture

The lock file does not cover modules

The .terraform.lock.hcl file tracks provider dependencies with checksums, and the Terraform documentation states the gap outright: the dependency lock file tracks only provider dependencies, Terraform does not remember version selections for remote modules, and it will always select the newest available module version that meets the specified constraints. A registry module declared with ~> 5.0 therefore resolves afresh at every init, which means two engineers running init a month apart can be building different module code from an identical repository.

Git-sourced modules pin differently, in the source string itself, with a ?ref= pointing at a tag or a commit. A source without a ref tracks a branch, and a branch is a moving target. tflint ships a rule for exactly this, terraform_module_pinned_source, which flags a git or mercurial source with no version pin and, in its flexible style, also flags a ref pointing at a default branch such as main or master.

Transitive versions are the sharper edge. If a module you pin exactly itself calls a submodule with a range, your pin buys you less than it looks like. Checking that the modules you depend on pin their own children is part of the review, not a paranoid extra.

# floats: init picks the newest match, and the lock file will not stop it
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}

# pinned: reproducible, upgraded on purpose
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"
}

# git source: the pin lives in the ref, and a missing ref means a branch
module "platform" {
  source = "git::https://example.com/infra.git//modules/platform?ref=v2.3.0"
}

Put the version on the box

A registry or git module cannot be expanded from your repository alone, because its contents are not there. Rather than pretending otherwise, Datadef draws each such call as one component labelled from the call itself, in the form Module terraform-aws-modules/vpc/[email protected], and treats it as a major node that never gets rolled into a group. Its input references still resolve, so the box wires to the rest of the diagram through what it consumes.

The version on the box is read from the literal version argument. A pin assembled from a variable or a local shows as a module with no declared version, which is the honest reading, and a call with no version argument at all shows the same way.

That single choice makes an upgrade review visual. A repository that composes five registry modules shows five versioned components, and the ones with no version declared stand out as the finding they are. Local modules, which the parser can expand, render instead as zones containing their actual resources.

The generated architecture doc carries the inventory side in table form: one row per module with its source and its resource count, where a registry module reads external because its contents are not in the repository. The versions live on the diagram nodes, the counts live in the table, and both come from a single parse, so they cannot disagree.

Composition edges come from outputs, not depends_on

The honest way to find which module depends on which is to follow the references. When module B is given module.a.storage_account_id as an input, that reference resolves through module A's output to the declared resource on the other end, which gives you a true edge and a label: the attribute that carried it.

Datadef resolves references that way rather than reading depends_on, following variables through call sites and locals to what they are wired to. An explicit depends_on is still recorded, but it is a coarse signal, because it states an ordering without saying what is actually consumed.

The practical payoff is upgrade blast radius. Before bumping a shared local module, the diagram shows every stack and module that consumes its outputs, which is the list of things to test. Version pins tell you what you are changing; reference edges tell you who feels it. Related reading: how modules render as zones.

FAQ

Does the Terraform lock file pin module versions?

No. The dependency lock file records provider versions and checksums only. Module version selections are not remembered between runs, so a range constraint resolves to the newest matching version each time init runs unless the constraint is an exact version.

How do I see which module versions my repository uses?

Read the version argument on each registry module call and the ref parameter on each git source. A parse of the repository can list them all with their sources, which is faster than grepping when modules are declared across several stacks.

Should registry modules be pinned to an exact version?

In production, yes, then upgraded deliberately after testing. A range makes the build non-reproducible, because init selects the newest matching version every time and the dependency lock file records providers only. Check that the modules you pin also pin their own children: an exact pin on a module whose submodules float buys less reproducibility than it appears to.

How are dependencies between modules determined without running Terraform?

By resolving references in the source. An input bound to module.a.something follows module A's output to the declared resource it reaches, producing a real edge labelled by the attribute that carried it. Variables and locals are followed rather than evaluated.

What does a diagram show for a module it cannot expand?

One component standing for everything that module provisions, labelled with its source and declared version in the form Module terraform-aws-modules/vpc/[email protected], and wired to the rest of the diagram through the references in its inputs. It is always treated as a major node, so it is never merged into a counted group. Local modules are expanded into their real resources instead.