Definition
- Semantic Versioning (MAJOR.MINOR.PATCH) communicates the type of change: MAJOR for breaking, MINOR for backward-compatible features, PATCH for backward-compatible fixes
- Consumers pin to an exact or bounded version so production never automatically takes the newest tag
- A breaking change is anything that forces a consumer to edit their configuration or move state: renamed or removed inputs, changed output names or types, resource address renames, and Terraform/provider version floors
- The moved block lets you rename a resource inside a module and have Terraform automatically migrate state, turning what would be a breaking change into a non-breaking minor
- Deprecation is the polite path: keep the old variable for a major cycle with a validation warning, then remove only in the next major
The National Bank of India has a central team that builds a reusable "vault blueprint" (a module) that every branch uses to build its own vault. If the central team changes the blueprint carelessly, every branch's vault could suddenly need rebuilding while Rajesh and Priya are mid-transaction. So the central team stamps each blueprint with a version number. A small fix gets a tiny bump. A new optional feature gets a medium bump. But if the new blueprint renames the vault door or removes a required measurement, that is a major version: branches must consciously opt in and adjust their plans.
Understanding the Concepts
What is Semantic Versioning for Terraform modules?
Semantic Versioning gives three numbers. The PATCH number increases for backward-compatible bug fixes. The MINOR number increases for backward-compatible new features. The MAJOR number increases for breaking changes. A consumer reading "4.1.2 to 4.3.0" knows it is safe, while "4.3.0 to 5.0.0" means read the upgrade guide carefully.
# SemVer: MAJOR.MINOR.PATCH
# 4.2.1 -> 4.2.2 PATCH: bug fix, safe to take
# 4.2.1 -> 4.3.0 MINOR: new feature, safe to take
# 4.2.1 -> 5.0.0 MAJOR: breaking change, read upgrade guide
# Tag a module release with SemVer
git tag -a v4.3.0 -m "Add optional flow_logs variable (non-breaking)"
git push origin v4.3.0
git tag -a v5.0.0 -m "Rename subnet_id output to private_subnet_id (BREAKING)"
git push origin v5.0.0
How do consumers pin versions?
Pinning protects consumers from automatic, unreviewed upgrades. The critical rule is that production never uses an unpinned or mutable source.
# Exact pin -- safest, consumer takes only this version
module "networking" {
source = "app.terraform.io/nbi/networking/aws"
version = "4.2.1"
}
# Bounded range -- accept patches but not minors
module "networking" {
source = "app.terraform.io/nbi/networking/aws"
version = "~> 4.2" # Accepts 4.2.x only
}
# Range with ceiling -- accept minors but not the next major
module "networking" {
source = "app.terraform.io/nbi/networking/aws"
version = ">= 4.2, < 5.0"
}
# WRONG: Pointing at a mutable branch
module "networking" {
source = "git::https://github.com/nbi/networking.git?ref=main"
# A push to main silently changes your infrastructure!
}
# CORRECT: Pointing at an immutable tag
module "networking" {
source = "git::https://github.com/nbi/networking.git?ref=v4.2.1"
}
What counts as a breaking change?
A breaking change is anything that forces a consumer to edit their configuration or their state. Precision matters in interviews.
| Change Type | Breaking? | Why |
|---|---|---|
| Remove an input variable | Yes | Consumers passing it now error |
| Rename an input variable | Yes | Consumers using the old name error |
| Make an optional variable required | Yes | Consumers not passing it now error |
| Change a variable's type | Yes | Consumers passing the old type error |
| Remove an output | Yes | Downstream code reading it breaks |
| Rename an output | Yes | Downstream code using old name breaks |
| Rename a resource inside the module | Yes (without moved block) | Terraform sees destroy + create |
| Raise minimum Terraform version | Yes | Consumers on older versions fail |
| Raise minimum provider version | Yes | Consumers on older providers fail |
| Change a default value | Possibly | If it alters real infrastructure |
| Add a new optional variable with default | No | Existing consumers unaffected |
| Add a new output | No | No consumer depends on it yet |
# BREAKING: Removing a variable
# Before (v4.x):
variable "subnet_id" { type = string }
# After (v5.0): variable removed -- every consumer breaks
# NON-BREAKING: Adding an optional variable with a default
variable "enable_flow_logs" {
type = bool
default = false # Existing consumers unaffected
}
How does the moved block avoid breaking changes?
Modern Terraform gives you the moved {} block to rename a resource inside the module and have Terraform automatically migrate state, so a rename that used to be breaking becomes a non-breaking minor.
# Inside the module: rename the resource
resource "aws_subnet" "private" { # was: aws_subnet.internal
cidr_block = var.cidr
}
# Add a moved block so Terraform migrates state automatically
moved {
from = aws_subnet.internal
to = aws_subnet.private
}
# Consumer runs terraform plan:
# aws_subnet.internal has moved to aws_subnet.private
# Terraform will update the state to reflect this.
No changes. Your infrastructure matches the configuration.
Without the moved block, Terraform sees the old address as destroyed and the new one as created: a recreation that could cause downtime.
How do you deprecate a variable safely?
Deprecation is the polite path: instead of removing a variable in one release, you keep it for a major cycle with a validation warning, and remove it only in the next major.
# v4.3.0: Deprecate the old variable, add the new one
variable "subnet_id" {
type = string
default = null
description = "DEPRECATED: Use private_subnet_id instead."
validation {
condition = var.subnet_id == null
error_message = "subnet_id is deprecated. Use private_subnet_id."
}
}
variable "private_subnet_id" {
type = string
description = "The subnet ID for NBI private resources"
}
# v5.0.0: Remove subnet_id entirely (breaking, documented in UPGRADING.md)
How do you publish a CHANGELOG and UPGRADING guide?
Every major release needs a CHANGELOG that lists what changed and an UPGRADING guide that tells consumers exactly how to migrate.
# CHANGELOG.md
## 5.0.0 (2024-03-15) -- BREAKING
- BREAKING: Renamed output `subnet_id` to `private_subnet_id`
- BREAKING: Removed deprecated variable `subnet_id`
- BREAKING: Minimum Terraform version raised to 1.5.0
- Added `moved` block for internal resource rename (no consumer action)
## 4.3.0 (2024-01-10)
- Added optional `enable_flow_logs` variable (default: false)
- Deprecated `subnet_id` variable -- use `private_subnet_id`
## 4.2.2 (2023-12-01)
- Fixed CIDR calculation for edge case with /28 subnets
How do you test modules before releasing?
You test the module in isolation with terraform test and example configurations before tagging a release, so consumers never receive an untested version.
# tests/networking.tftest.hcl
run "creates_private_subnet" {
command = plan
variables {
vpc_id = "vpc-test123"
cidr = "10.0.1.0/24"
environment = "test"
}
assert {
condition = aws_subnet.private.cidr_block == "10.0.1.0/24"
error_message = "Subnet CIDR does not match input"
}
}
# Run module tests before tagging
terraform test
# Success! 3 passed, 0 failed.
git tag -a v4.3.0 -m "Add flow logs support"
git push origin v4.3.0
What should a senior engineer know?
A senior engineer publishes immutable, tagged releases through a private registry or Git tags, never through mutable branches. They write a CHANGELOG for every release and an UPGRADING guide for every major. They use the moved block to avoid breaking changes wherever possible. They test with terraform test and example configurations before tagging. And they understand that backward compatibility is a contract, SemVer communicates it, and pinning enforces it.
What are common interview follow-up questions?
- Is changing a default value breaking? It can be, if the new default alters real infrastructure on the next apply.
- How do you communicate breaking changes? CHANGELOG, UPGRADING guide, and SemVer major version bump.
- Can you avoid all breaking changes? Not always, but the
movedblock, deprecation, and optional variables with defaults cover most cases. - Should internal modules use SemVer? Yes, even internal modules benefit from SemVer because it communicates intent across teams.
Visual Overview
Module Release Lifecycle
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Develop │───▶│ Test │───▶│ Tag Release │
│ (branch) │ │ terraform │ │ v4.3.0 │
│ │ │ test │ │ (immutable) │
└───────────────┘ └───────────────┘ └───────┬───────┘
│
▼
┌───────────────────────────────────────────────────┐
│ Private Registry / Git Tags │
│ v4.2.1 v4.2.2 v4.3.0 v5.0.0 │
└───────────────────────────────────────────────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ Team A pins │ │ Team B pins │
│ ~> 4.2 │ │ >= 4.2 < 5 │
└─────────────┘ └─────────────┘
SemVer Decision Tree
┌────────────────────────────────────────────┐
│ Does the change force consumers to edit │
│ their config or move state? │
│ YES ──▶ MAJOR (5.0.0) │
│ NO ──▶ Does it add new capability? │
│ YES ──▶ MINOR (4.3.0) │
│ NO ──▶ PATCH (4.2.2) │
└────────────────────────────────────────────┘
Key Takeaway
Managing versioning for a Terraform module that many teams depend on comes down to Semantic Versioning plus disciplined pinning plus a controlled upgrade process. Every module is published as an immutable, versioned release in the form MAJOR.MINOR.PATCH. A patch is a backward-compatible bug fix, a minor is a backward-compatible new feature like adding an optional variable with a default, and a major is any breaking change. The value of this scheme is that a consumer reading the version jump knows instantly whether an upgrade is safe or needs care.
Pinning is how consumers stay safe. Every consumer pins to an exact tag or a bounded range and crucially never points at a mutable branch like main. That guarantees production never automatically consumes the latest tag, so a module author's push cannot silently change live infrastructure. Upgrades are always deliberate: a team edits its pinned version in a pull request, reviews the plan, and tests before applying.
What counts as a breaking change is the part interviewers probe. Removing or renaming an input variable is breaking because every consumer passing it now errors. Changing a variable's type or making an optional variable required is breaking. Renaming or removing an output is breaking. Renaming a resource inside the module without a moved block is breaking because Terraform treats it as a destroy and a create. In the National Bank of India project, the platform team owned a networking module used by twelve teams. Adding an optional flow-logs variable was a safe minor, renaming an internal subnet resource was kept non-breaking by using a moved block, but renaming the subnet_id output to private_subnet_id broke the payments team's code, so that went out as a major with a changelog and an upgrade guide.
Remember one sentence: backward compatibility is a contract, SemVer communicates it, and pinning enforces it.
---