Skip to content

Terraform Modules with Git: Versioning and Releases

Lesson 3 of 10Intermediate13 min readGit for DevOps & Infrastructure · Infrastructure as CodeVerified: Terraform module sources and module registry documentation, September 2026

There is one character of difference between these two module references, and it decides whether your infrastructure estate is something you can reason about.

source = "git::https://github.com/example/tf-modules.git//network?ref=main"
source = "git::https://github.com/example/tf-modules.git//network?ref=v2.3.1"

The first says: give me whatever is on main right now. The second says: give me the exact tree somebody tagged v2.3.1. With the first, a colleague merging a pull request into a module repository changes what production plans, and nobody in production’s repository did anything.

Local modules use relative paths. Shared modules use versioned references.

# In the same repository — no version needed, it moves atomically with the caller
module "network" {
source = "../../modules/network"
}
# In another repository — pin it
module "network" {
source = "git::https://github.com/example/tf-modules.git//network?ref=v2.3.1"
}

Everything else in this lesson is about producing those tags responsibly and consuming them deliberately.

Not a style preference — a specific failure mode with a recognisable shape.

A module repository is referenced by ?ref=main from twelve root modules. Somebody merges an improvement. Nothing happens immediately, because nothing re-plans on its own. Three weeks later an unrelated pull request touches one root module, CI plans it, and the plan contains changes nobody in that pull request wrote — because the module underneath moved.

Three specific harms:

The plan is confusing. A reviewer sees changes unconnected to the diff and has to work out where they came from. The usual response is to approve anyway, which is the wrong lesson to learn.

The change is untraceable. Production changed because of a commit in a different repository, referenced by a name that no longer points where it did. Six months later, git log in the infrastructure repository does not explain it.

Rollback does not work. Reverting the infrastructure commit restores a configuration that still says ?ref=main, which still resolves to the new module. The revert appears to succeed and changes nothing.

A pinned reference converts all three into an ordinary, reviewable pull request: one line changes from v2.3.1 to v2.4.0, CI plans it, a human reads what that upgrade does, and history records it.

Terraform accepts several, and they differ in what guarantees they give you.

SourceImmutable?Notes
../../modules/xMoves with the repositoryLocal path — the right choice in-repo
git::…?ref=mainNoChanges underneath you
git::…?ref=v2.3.1By conventionTag — the practical default
git::…?ref=a1b2c3dYesCommit SHA — unambiguous, unreadable
Registry, version = "~> 2.3"Resolved and lockedRegistry semantics

The //subdirectory syntax selects a directory inside the repository, which is what makes a single module repository practical:

source = "git::https://github.com/example/tf-modules.git//modules/network?ref=v2.3.1"

For SSH-authenticated access the form is git::ssh://git@github.com/example/tf-modules.git//modules/network?ref=v2.3.1. In CI that means the runner needs a deploy key or an app token with read access — covered in Git credentials.

The same monorepo question, at module scale.

All modules in one repository means one tag covers everything. Simple to publish, and it means a consumer upgrading to get a networking fix also receives every unrelated change to every other module since their last upgrade. In practice this is fine when modules change at similar rates and awkward when one module is volatile and another is stable.

A repository per module gives independent versioning, which is what you want when a database module changes monthly and a DNS module changes twice a year. The cost is real: a dozen repositories, a dozen CI configurations, a dozen release processes, and a discovery problem for anybody looking for a module.

The pragmatic middle most teams land on is one repository per domain — tf-modules-network, tf-modules-data, tf-modules-platform. Related modules version together, unrelated ones do not, and there are three repositories rather than twenty.

Start with one repository. Split when a single volatile module is forcing everyone else to upgrade.

Semantic versioning translates to Terraform modules well, once you decide what “breaking” means. The useful definition is what happens to a consumer who upgrades without changing their configuration.

Major — their configuration stops working, or produces destruction. A required variable added with no default. A variable renamed or removed. An output removed. A resource renamed such that Terraform plans destroy-and-create rather than an in-place update.

Minor — new capability, no action required. An optional variable with a default. A new output. A new resource that adds rather than replaces.

Patch — a fix with no interface change.

The category that catches people is the fourth major example. Renaming a resource inside a module — aws_instance.web to aws_instance.server — changes nothing about the module’s inputs or outputs, so it looks like a patch. To Terraform it is a resource that no longer exists and a new one that does, and the plan says destroy and create. That is a major version, and if the resource holds data it is an incident.

The mitigation is moved blocks, which tell Terraform that a resource changed address rather than ceasing to exist:

moved {
from = aws_instance.web
to = aws_instance.server
}

Shipping a rename with a moved block converts a destructive major change into a safe one. Leave the block in place for at least one major version so consumers upgrading late still get it.

  1. Change the module on a branch. One concern, as with any change.

  2. Update the module’s README and examples. The interface documentation is part of the interface.

  3. Open a pull request. Module changes affect every consumer, which makes them higher-stakes than they look — a module repository deserves stricter review than the root modules that consume it.

  4. Validate in CI. fmt -check, validate, lint, and terraform test against the module’s examples.

  5. Decide the version. Apply the definition above. When uncertain between minor and major, choose major — a consumer who upgrades cautiously loses nothing, and one who upgrades into an unexpected destroy loses a resource.

  6. Merge, then tag the merge commit. git tag -a v2.4.0 -m "Add optional flow logs" and push the tag. Tag the commit that is actually on the default branch.

  7. Create a release with notes describing what changed for a consumer — not the internal diff. “Adds optional enable_flow_logs, default false” is what somebody deciding whether to upgrade needs.

  8. Announce breaking changes explicitly, with the migration step. A major version with no upgrade instructions guarantees a support conversation.

The step teams skip is the seventh, and it is the one that determines whether consumers upgrade. A tag with no notes forces every consumer to read a diff to find out whether the upgrade matters to them, and most will decide not to bother.

An upgrade is an ordinary pull request that happens to change one line.

module "network" {
source = "git::https://github.com/example/tf-modules.git//network?ref=v2.3.1"
source = "git::https://github.com/example/tf-modules.git//network?ref=v2.4.0"
...
}

That diff is one line and the plan may be enormous, which is precisely the situation the Git workflow exists to handle: read the plan, not the diff.

Upgrade lower environments first. Dev, then staging, then production — as separate pull requests, with time between them. The whole reason to have lower environments is to discover a module’s surprises somewhere cheap.

One module upgrade per pull request. Bundling three makes an unattributable plan.

terraform init -upgrade is needed when the reference changes, or Terraform reuses the cached module.

Read the destroy count. A module upgrade that destroys anything deserves a sentence explaining why before anybody approves.

Pinned versions have one real cost: nothing forces you to move. Consumers pinned to v1.2.0 two years ago are running unmaintained code, and the eventual upgrade is a large one nobody wants to start.

Automate the pull request, not the merge. A scheduled job that notices a newer tag and opens a pull request keeps the option visible. The pull request still goes through plan and review.

Track who consumes what. In a monorepo this is grep. Across repositories it needs an inventory — and the answer to “which environments are on the old networking module” should take seconds, not an afternoon.

Upgrade on a cadence, not on a crisis. A team that upgrades modules quarterly does small upgrades that are easy to review and easy to attribute when something breaks. A team that upgrades only when a security advisory forces it does one large migration under time pressure, across several major versions, with no way to tell which of the forty changes caused the plan it is now looking at.

Deprecate before removing. A variable marked deprecated in the release notes for one major version, then removed in the next, gives consumers a window. Removing it without warning is how a shared module loses its users.

Versioning is only worth doing if the thing being versioned has a stable interface. Most module upgrade pain traces back to interface decisions made when the module had one consumer.

Expose intent, not implementation. A module taking instance_type, ami_id, subnet_ids and security_group_ids has exposed AWS. A module taking size and environment has exposed a decision. The second survives a provider change; the first is a thin wrapper that has added a version number to somebody else’s API.

Prefer optional inputs with sensible defaults. Every required variable is a breaking change waiting to happen and a burden on every consumer. If a value has a reasonable default, give it one.

Do not accept arbitrary passthrough. A tags map is fine. A variable that takes a block of raw provider configuration and splices it in makes every consumer’s usage unique and the module impossible to change.

Output what consumers actually need, and nothing else. Every output is part of the contract. An output added because it was easy is an output you cannot remove without a major version.

Keep the module’s scope to one thing. A module that creates a network, a database and a Kubernetes cluster cannot be versioned meaningfully, because a change to any of the three forces a version bump for consumers who use only the others.

The test for a good interface: can you change how the module works internally without changing its inputs or outputs? If every implementation change is also an interface change, the module is a template rather than an abstraction, and it will generate a major version every month.

Most estates consume third-party modules as well as their own, and the same reasoning applies with an added supply-chain dimension.

Pin the version. The registry syntax uses a version argument rather than a ref:

module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "<pinned>" # An exact version, not a range
}

Prefer an exact version over a range for anything production depends on. ~> 5.21 will silently take 5.22.0 on the next init -upgrade, which is convenient right up until a minor release changes a default.

Treat a third-party module as a dependency. Somebody else’s code runs with your cloud credentials and creates resources in your account. That is the same trust decision as any dependency, and it deserves the same scrutiny: who maintains it, how actively, and what does it actually create.

Read the module before adopting it, not after. A widely used registry module can create sixty resources from a ten-line call. Knowing what those are before the first apply is considerably cheaper than discovering it from a bill.

Consider vendoring critical modules. Forking a third-party module into your own repository costs you upstream fixes and buys you control over when anything changes. For a module that provisions something central, that trade is often correct.

A module is consumed by people who cannot easily verify it, which raises the bar.

terraform validate catches structural errors. Cheap, necessary, insufficient.

Examples that plan. An examples/ directory with minimal and complete usages, planned in CI. This catches the largest class of real defect: a module that does not work as documented, usually because a required variable was added and the README was not updated.

terraform test. Native test files that run plans or applies and assert on outputs and resource attributes. Assert the contract — that the module exposes the outputs it promises — rather than its internals.

Applying against disposable infrastructure. The only way to catch provider-level problems — an argument the provider rejects, a name that exceeds a limit, a combination the API refuses. It needs a throwaway account, a workflow that always tears down, and credentials scoped so that a failed teardown cannot leave anything expensive behind. Worth it for modules that manage anything stateful, and hard to justify for a module that creates a DNS record.

The one thing every module repository should have is the examples directory, planned on every pull request. It is cheap, and it fails exactly when the module’s documented usage stops working.

Shared modules create an ownership question that shared application libraries do not, because the blast radius is infrastructure.

A module with no owner decays predictably. Consumers add the variable they need, nobody removes anything, and after two years the interface has forty inputs of which eleven are used by one caller each. At that point no change is safe, because no one knows what would break.

Put module repositories under CODEOWNERS. The team accountable for the module reviews changes to it. This is more important than for a root module, because a bad module change affects every environment that consumes it rather than one.

Distinguish contributing from owning. Consumers should be able to open pull requests against a module — that is how it improves. The owning team decides what merges and what version it becomes.

Say what the module is for in the README. Not just how to call it: what problem it solves and what it deliberately does not do. That sentence is what lets an owner decline a feature request without it feeling arbitrary.

The failure mode to watch is a platform team that owns every module and becomes a queue. The healthier arrangement is a platform team that owns the modules everybody uses, and lets individual teams keep their own modules in their own repositories until something is genuinely shared.

?ref=main in production. Production changes when somebody merges elsewhere.

Moving a tag. Silently changes what every consumer resolves.

Renaming a resource without a moved block. A patch-looking change that destroys things.

Bundling several module upgrades in one pull request. Unattributable plan.

No release notes. Consumers cannot decide whether to upgrade, so they do not.

Extracting a module repository with one consumer. Version overhead, no benefit.

Forgetting -upgrade on init. The cached old version is used and the plan looks wrong.

Assuming the lock file pins modules. It pins providers only.

Never upgrading. Pinning without a cadence produces one enormous migration later.

A module reference is a dependency declaration. Pinned, it is a decision recorded in history. Floating, it is a change that happens to you.

Every practice here follows: tags exist so consumers can choose, semantic versioning exists so the choice is informed, release notes exist so the decision is cheap, and moved blocks exist so a refactor is not an incident.

  • Local modules use relative paths; cross-repository modules must be pinned
  • ?ref=main means production changes when somebody merges in another repository
  • Tags are immutable by convention only — protect them, or use commit SHAs
  • Renaming a resource inside a module is a major change; moved blocks make it safe
  • The //subdir syntax makes a single module repository practical
  • .terraform.lock.hcl pins providers, not modules
  • Automate the upgrade pull request, never the merge

Use two disposable repositories. No cloud credentialslocal_file is sufficient.

  1. Create a module repository with a network/ module that creates a local_file. Tag it v1.0.0.

  2. Create a consumer repository referencing it with ?ref=v1.0.0. Run init and plan.

  3. Change the module and push to main without tagging. Re-run plan in the consumer. Predict: does anything change?

  4. Change the reference to ?ref=main and run init -upgrade then plan. Predict: what changes now, and how would a reviewer know why?

  5. Rename the resource inside the module and tag v2.0.0. Upgrade the consumer. Predict: update, or destroy and create?

  6. Add a moved block, tag v2.0.1, and upgrade again from v1.0.0. Compare the plans.

  7. Delete both repositories.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The infrastructure-as-code repository template and deployment PR checklist are in the Professional Toolkit.