Skip to main content
RunBook Academy

TerraformXXIV · Upgrading Terraform, Providers, and ModulesProduction Terraform

Upgrading Internal Modules Safely

Intermediate⏱ ~12 minbash

What you'll learn

  • Bump the `?ref=` of an internal Git module deliberately
  • Pin with the mechanism the module source actually supports
  • Re-install the module and review the plan the bump produces
  • Recognise the production cost of a breaking module change
  • Distinguish a tag-based module ref from a branch-based one

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.

Internal modules are the modules the team owns. The team controls the version, the tag, the CHANGELOG, and the breaking decisions. That ownership makes the upgrade more disciplined than a third-party provider: the team can read the diff before applying, can decide the semver bump themselves, and can hold the release until the consumers are ready.

The lesson teaches the upgrade as a four-step procedure and the version-pinning policy that makes module upgrades safe in production.

The shape of a module reference

Where the module lives decides how it is pinned, and the two mechanisms do not mix.

# Internal module on a Git host. The pin is INSIDE the source.
module "vpc" {
  source = "git@github.com:acme/terraform-modules.git//vpc?ref=v3.4.0"

  # ...inputs...
}

# The same module published to a registry instead. The pin is
# a SEPARATE version constraint.
module "vpc" {
  source  = "app.terraform.io/acme/vpc/aws"
  version = "~> 3.4"

  # ...inputs...
}

source is where the module lives. For internal modules on a Git host, the git:: form or the GitHub SSH shorthand above is the canonical pattern; //vpc selects a subdirectory of the repository. For modules in a registry, the source is the registry address.

ref is the Git ref — a tag, a branch, or a commit SHA, anything git checkout accepts. Tags are the production default. Branches are dangerous because they can move. Commit SHAs are immutable but lose their human meaning in a year.

version is a registry-only argument. HashiCorp’s module block reference is explicit: you can only use version when source points to a module listed in a registry. There is no constraint for Terraform to solve on a Git source, because a Git URL with a ref already names one revision - so the two arguments together are an error, not a belt-and-braces pin:

Error: Invalid version constraint

Cannot apply a version constraint to module "vpc"
(at main.tf:2) because it has a non-Registry URL.

Inputs are the values the module accepts. Renaming an input is a breaking change.

For an internal module on Git, then, there is exactly one pin and it lives inside the source string. That single fact drives the rest of this lesson: everything the registry expresses with a constraint, a Git consumer expresses by choosing which ref to write down.

The version-pinning policy

Four policies, each with a different operational story. The first three are what a Git source offers; the fourth exists only once the module is published to a registry.

Policy A: tag-based ref. The module repository tags every release. The consumer pins ?ref= to a tag. This is the production default. Note what it does not do: it does not admit later patches on its own. A Git pin is exact, so v3.4.1 arrives when a human writes it down, not before.

source = "git@github.com:acme/terraform-modules.git//vpc?ref=v3.4.0"

Policy B: commit SHA only. The consumer pins to a commit SHA. No constraint. The team bumps the SHA deliberately.

source = "git@github.com:acme/terraform-modules.git//vpc?ref=8a3f9c2"

Policy C: branch-based. The consumer pins to a branch that the module repository treats as a release line. This is the most dangerous because the branch can move without the consumer knowing.

source = "git@github.com:acme/terraform-modules.git//vpc?ref=main"

Policy D: registry with a constraint. Only available once the module is published to a registry, and the only place version is legal. ~> 3.4 then admits 3.4.0 through the last 3.x release below 4.0, and Terraform resolves it at init time.

source  = "app.terraform.io/acme/vpc/aws"
version = "~> 3.4"

The default for production is policy A. The team tags releases on the module repository, the consumer pins ?ref= to a tag, and the upgrade is a deliberate PR that moves that one string.

The four-step upgrade

1. Read the module CHANGELOG
2. Bump the ref in the consumer configuration
3. Re-install the module
4. Read the plan and decide

Step 1: read the CHANGELOG

The module repository keeps a CHANGELOG.md. The entries follow semver:

## [3.4.0] - 2026-07-15
### Added
- new optional input `flow_log_retention_days`

## [3.3.0] - 2026-06-01
### Added
- new optional output `vpc_arn`
### Changed
- default for `enable_flow_logs` is now true

## [3.2.1] - 2026-05-10
### Fixed
- tag propagation for subnets

## [3.2.0] - 2026-04-22
### BREAKING
- input `cidr` renamed to `vpc_cidr`

The breaking changes section is the critical read. A registry consumer on ~> 3.1 picks up 3.2.0 the next time the constraint is resolved, because a two-component ~> lets the minor move, and is then surprised by a minor release that renamed an input. A Git consumer pinned to ?ref=v3.1.0 is never surprised that way - but only because it is never upgraded either, including for the security fix in 3.2.1. Neither pin removes the need to read the CHANGELOG. The team’s semver discipline is the contract behind both.

Step 2: bump the ref

One change, in one string:

# before
source = "git@github.com:acme/terraform-modules.git//vpc?ref=v3.3.0"

# after
source = "git@github.com:acme/terraform-modules.git//vpc?ref=v3.4.0"

There is no second line to keep in step, because on a Git source the ref is the pin. The mirror image holds for a registry module: the source stays fixed and version is the line that moves. Writing both is the error above, and it is a common one - the shape looks like a provider block, where source and version really do sit side by side.

Step 3: re-install the module

# CONFIGURATION: re-install the module at the new ref
terraform init

Plain init is enough here, and knowing why is the point. Terraform records what it installed in .terraform/modules/modules.json, keyed by module call, with the source address it was installed from. On the next init it compares that recorded address with the one in the configuration; the ?ref= moved, so the address moved, so the module is re-cloned.

{
  "Modules": [
    { "Key": "", "Source": "", "Dir": "." },
    {
      "Key": "vpc",
      "Source": "git@github.com:acme/terraform-modules.git//vpc?ref=v3.4.0",
      "Dir": ".terraform/modules/vpc/vpc"
    }
  ]
}

The first record is the root module. Dir on the second is where the clone landed plus the //vpc subdirectory inside it, and Source is the string that gets compared on the next init.

-upgrade is for the case where the source string does not change: a branch ref, or a registry version constraint that could now resolve to something newer. The documented rule for plain init is that it installs modules added since the last run “but will not change any already-installed modules”; a changed source address counts as a new install, a moved branch does not.

Two things are worth being blunt about, because the shape of the file above invites the opposite assumption:

  • This is not a lock file. .terraform/ is generated and gitignored. Nothing in it is committed or reviewed.
  • The committed lock file does not cover modules. HashiCorp: “At present, the dependency lock file tracks only provider dependencies. Terraform does not remember version selections for remote modules.” .terraform.lock.hcl has no module entry to change.

So the audit trail for a module bump is the pull request itself - one line of main.tf, reviewed by a human. That is also why the ref you write there needs to be immutable: it is the only record.

Step 4: read the plan

The plan is the diagnostic. The patterns are the same as for providers:

  • Plan is empty. The upgrade is a no-op.
  • Plan shows new outputs or new attributes. Backwards compatible. Safe to apply.
  • Plan shows replacements. The new module version changed a default or an attribute the consumer relies on. Stop.
  • Plan fails with “Unsupported argument”. The new module version removed or renamed an input. Update the consumer configuration or delay the upgrade.

A consumer of an internal module has a closer relationship with the author than a consumer of a third-party provider. The author is on the same chat channel. The author can be asked. The author should be told about a plan that shows unintended changes before the PR is merged.

The cost of a breaking module change

The cost is the same shape as a breaking provider change, but with one important difference: the team controls both ends. The consumer and the author are on the same team. The breaking change is internal; the recovery is internal.

A. Configuration rewrite. Every consumer has to update. For a widely-used module, this can be dozens of working directories.

B. State migration. Module-level state changes are uncommon; module inputs that change in a way that affects state are a project. The author has to design the migration.

C. Consumer churn. Internal module consumers often have backlogs. A breaking module bump competes with feature work and loses. The author has to support both the new module and the old module for a deprecation window.

D. Time. A breaking module bump is a multi-week project. Tag the old major as deprecated; document the migration; review the consumer PRs; cut a release. The work is not optional.

Production failure modes

Five failure modes recur.

1. Branch-based ref in production. Symptom: a module author merges to main; the next terraform init pulls the change without the consumer knowing. Recovery: pin to tags; back-fill the consumer repositories with explicit tag refs.

2. CHANGELOG missing or stale. Symptom: a module release ships without a CHANGELOG entry; the consumer has no way to know what changed; the next plan shows surprises. Recovery: the module release process requires a CHANGELOG entry before the tag is created.

3. Semver violation. Symptom: the module author ships a breaking change in a minor or patch release; consumers on ~> 3.4 are surprised. Recovery: revert the tag, republish as a major, document the rule.

4. Stale clone in a long-lived working directory. Symptom: the consumer is pinned to a branch, so the source string never changes; terraform init sees the module already installed and leaves it alone; that directory keeps planning against a clone from months ago, while CI - a fresh checkout every run - plans against today’s branch head. The two engineers cannot reproduce each other’s plan. Recovery: pin to tags, and treat init -upgrade as mandatory anywhere the pin is a moving one.

5. Author out of date. Symptom: a module author has been away from the codebase for six months; the consumer asks for a fix and the author cannot deliver; the consumer forks. Recovery: the team has a bus factor of one for the module; the bus factor is the team’s problem to solve, not the consumer’s.

Operational guidance

  • Tag every release. Git tags are the production default. Branch-based refs are the exception, and only with a documented justification.
  • Write a CHANGELOG. Every release gets a CHANGELOG entry. The format is up to the team; the existence is not.
  • Honour semver. Breaking changes ship as major bumps. Minor bumps add features. Patches fix bugs.
  • Pin with the mechanism the source supports. ?ref= on a Git source, version on a registry source, never both. Terraform rejects the pair.
  • Re-install after the bump. A changed ?ref= makes plain terraform init re-clone. A pin whose spelling never changes - a branch, or a registry constraint - needs terraform init -upgrade.
  • Support a deprecation window. A breaking module bump leaves the old major supported for a release cycle.

Security and performance

  • Module source authentication. A git:: source uses the SSH key the operator’s ~/.ssh/config provides. The module repository should require signed commits and review; the consumer’s SSH key should be scoped to read-only.
  • No checksum for module source. Terraform records and re-verifies checksums for providers, in .terraform.lock.hcl. It does not do this for modules: there is no module entry in that file and no hash to compare against. The integrity you have on a Git module is Git’s own. A commit SHA is content-addressed and cannot be repointed; a tag is a label that anyone with push access can move. If tag-moving is inside your threat model, pin to the SHA, or protect the tags on the module repository and require signed commits.
  • Performance. Module performance changes are rare. A module that introduces a slow data source or a complex dynamic block can slow plans; profile before and after.

What comes next

The next lesson is on testing upgrades before production: the sandbox, the per-provider integration test, and the sign-off that gates the apply.

Verification

# READ-ONLY: confirm the ref the configuration asks for
grep -A1 'module "vpc"' main.tf
module "vpc" {
  source = "git@github.com:acme/terraform-modules.git//vpc?ref=v3.4.0"
# READ-ONLY: confirm the ref Terraform actually installed.
# There is no module entry in .terraform.lock.hcl to check;
# the install manifest is the only place this is recorded.
jq -r '.Modules[] | select(.Key != "") | "\(.Key) \(.Source)"' \
  .terraform/modules/modules.json
vpc git@github.com:acme/terraform-modules.git//vpc?ref=v3.4.0
# READ-ONLY: confirm the plan is a no-op or shows only safe changes
terraform plan -out=tfplan
terraform show -json tfplan | jq '[.resource_changes[] |
  select(.change.actions | tostring != "[\"no-op\"]")] | length'

A count of zero means the plan is a no-op. Anything above zero is a change that needs review and, for breaking changes, a chat with the module author.

Knowledge check · 7 questions

  1. Q1. Which ref shape is the production default for an internal module on a Git source?

  2. Q2. An internal module author can ship a breaking change in a minor or patch release if the team agrees.

  3. Q3. When a consumer upgrades an internal module and the plan shows unexpected replacements, what is the first action?

  4. Q4. An internal module is sourced from a Git URL ending in ?ref=v3.3.0. What changes to move that consumer to v3.4.0?

  5. Q5. Which of the following are required elements of a disciplined internal module release? (Select all that apply.)

  6. Q6. A team maintains a VPC module used by 30 working directories. The author needs to rename an input from `cidr` to `vpc_cidr`. What is the right release?

  7. Q7. Why is a branch-based module ref dangerous in production?

Passing score: 75%. Answers are checked in this browser.