Versioning #
A module without versioning is a module that can’t be trusted for production. Imagine two teams using the same VPC module — team A applies today, team B applies next week. If a breaking change lands in the module in between, team B gets a different result than team A with no warning at all. Versioning is the mechanism that ensures every caller gets the same module version they chose, until they decide to upgrade.
flowchart TD
A["Module\nVersioning"] --> B["Semantic\nVersioning"]
A --> C["Version\nConstraints"]
A --> D["Lock\nFile"]
B --> E["MAJOR.MINOR.PATCH\nBreaking.Features.Fixes"]
C --> F["~>, >=, <\nConstraints"]
D --> G[".terraform.lock.hcl\nDependency Lock"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#f59e0b,stroke:#d97706,color:#fff
style D fill:#8b5cf6,stroke:#6d28d9,color:#fffWhy Modules Need Versions #
WITHOUT VERSIONING:
Team A: terraform apply on Monday
→ Uses the "latest" version of the VPC module
→ Succeeds, infrastructure running
Module developer: commits a change to the module (Tuesday)
→ Changes the output from "subnet_id" to "subnet_ids" (breaking change)
Team B: terraform apply on Wednesday
→ Uses the "latest" version of the VPC module (which is now different!)
→ ERROR: "module.vpc.subnet_id" doesn't exist
→ Team B is confused — their configuration hasn't changed
WITH VERSIONING:
module "vpc" {
source = "git::https://github.com/org/tf-module-vpc.git?ref=v1.2.0"
}
Both Team A and Team B use v1.2.0 → the same result.
The module developer releases v2.0.0 with the breaking change.
Team B isn't affected until they decide to upgrade to v2.0.0.
Semantic Versioning for Modules #
Terraform modules follow the semantic versioning convention (semver): MAJOR.MINOR.PATCH.
SEMANTIC VERSIONING:
v1.2.3
│ │ │
│ │ └── PATCH: Bug fixes, no interface changes
│ │ v1.2.3 → v1.2.4: Fix a bug in a security group rule
│ │
│ └──── MINOR: New features, backward compatible
│ v1.2.0 → v1.3.0: Add IPv6 support (optional)
│ Users of v1.2.0 are not affected
│
└────── MAJOR: Breaking change
v1.x.x → v2.0.0: Rename the output "subnet_id" → "subnet_ids"
All v1.x.x users must update their configuration before upgrading
EXAMPLE OF A GOOD CHANGELOG:
v2.0.0 (Breaking Change)
BREAKING: Output "subnet_id" replaced with "subnet_ids" (list)
BREAKING: Variable "az_count" removed, use "subnet_count"
NEW: Support for per-AZ NAT Gateways
v1.3.0
NEW: Added the "enable_ipv6" variable (default: false)
NEW: Output "ipv6_cidr_block" added
FIX: Security group no longer removes old rules during updates
v1.2.4
FIX: Subnet CIDR calculation errors when the VPC CIDR is smaller than /16
Releasing Modules in Git #
For modules stored in a Git repository, the version is determined by a Git tag.
# Module release workflow:
# 1. Make sure all changes are committed and tested
git add -A
git commit -m "feat: add per-AZ NAT Gateway support"
# 2. Create a tag in the v<MAJOR>.<MINOR>.<PATCH> format
git tag v1.3.0
# 3. Push the tag to the remote
git push origin v1.3.0
# 4. Optional: create a GitHub Release with the changelog
# (via the UI or the GitHub CLI)
gh release create v1.3.0 \
--title "v1.3.0 — per-AZ NAT Gateways" \
--notes "NEW: Support per-AZ NAT Gateways via the enable_nat_per_az variable"
# Callers use the tag as the version reference
module "vpc" {
source = "git::https://github.com/org/terraform-module-vpc.git?ref=v1.3.0"
cidr_block = "10.0.0.0/16"
environment = var.environment
}
Versioning in the Terraform Registry #
For modules published to the Terraform Registry (public or private), versioning is more formal and uses the version argument.
# Terraform Registry — version controlled with a version constraint
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1"
# ~> 5.1 = >=5.1.0, <6.0.0
name = "production-vpc"
cidr = "10.0.0.0/16"
}
# A stricter constraint for production
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "= 20.2.1"
# Pin to an exact version — no surprise updates
}
# ANTI-PATTERN: No version constraint on a Registry module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# No version — every terraform init could get a different version!
}
flowchart TD
A["module "vpc"\nversion = "~> 3.0""] --> B["v3.0.0 ✅"]
A --> C["v3.1.0 ✅"]
A --> D["v3.9.0 ✅"]
A --> E["v4.0.0 ❌\n(Major bump)"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#10b981,stroke:#059669,color:#fff
style D fill:#10b981,stroke:#059669,color:#fff
style E fill:#ef4444,stroke:#dc2626,color:#fffSafe Upgrade Strategies #
Upgrading a module version, especially a MAJOR version, needs to be done carefully.
# MODULE UPGRADE WORKFLOW:
# 1. Read the new version's CHANGELOG — look for breaking changes
# 2. Test in dev/staging first
# 3. Run terraform plan after updating the version
# Configuration change:
# From:
# source = "git::...?ref=v1.2.0"
# To:
# source = "git::...?ref=v2.0.0"
# 4. Read the plan output carefully
terraform plan
# Note which resources will be replaced
# Breaking changes in modules often cause resources to be replaced
# 5. If there's an unwanted replacement, consider:
# - Do you need a moved block to rename a resource?
# - Do you need a migration stage before the full upgrade?
# Example: a module renames an internal resource from v1 to v2
# Without a moved block, Terraform will destroy + create
# In the root configuration (not the module):
moved {
from = module.vpc.aws_subnet.public
to = module.vpc.aws_subnet.public_tier # New name in the v2 module
}
Managing Breaking Changes as a Module Author #
# STRATEGY 1: Deprecation with temporary backward compatibility
# Give a transition period before truly removing old variables/outputs
variable "subnet_id" {
description = "DEPRECATED: Use subnet_ids. Will be removed in v3.0."
type = string
default = null
validation {
condition = var.subnet_id == null
error_message = "subnet_id is deprecated, use subnet_ids (list)."
# Or let it run but log a warning via a null_resource
}
}
variable "subnet_ids" {
description = "List of subnet IDs. Replaces the deprecated subnet_id."
type = list(string)
default = []
}
locals {
# Backward compatibility: if subnet_id is set, turn it into a list
effective_subnet_ids = (
length(var.subnet_ids) > 0
? var.subnet_ids
: var.subnet_id != null ? [var.subnet_id] : []
)
}
Semantic Versioning in Practice #
Semantic versioning (MAJOR.MINOR.PATCH) is very important for published modules. Every change must be classified correctly.
# PATCH (x.y.Z): Bug fixes that don't change the interface
# Example: fix a typo in a description, fix an incorrect tag
# No breaking change → consumers don't need to change anything
git tag v1.2.1
# MINOR (x.Y.z): New backward-compatible features
# Example: add a new output, add a new optional variable
# Existing consumers aren't affected → safe to upgrade
git tag v1.3.0
# MAJOR (X.y.z): Breaking changes
# Example: remove a variable, change an output type, remove a resource
# Consumers MUST review and update → the upgrade needs planning
git tag v2.0.0
flowchart TD
A["Change\nin the module"] --> B{"Breaking\nchange?"}
B -->|"Yes"| C["MAJOR\nversion bump\n(X.0.0)"]
B -->|"No"| D{"New\nfeature?"}
D -->|"Yes"| E["MINOR\nversion bump\n(x.Y.0)"]
D -->|"No"| F{"Bug fix?"}
F -->|"Yes"| G["PATCH\nversion bump\n(x.y.Z)"]
F -->|"No"| H["No\nbump needed"]
style A fill:#e3f2fd,stroke:#1565c0
style C fill:#ffebee,stroke:#c62828
style E fill:#fff3e0,stroke:#e65100
style G fill:#e8f5e9,stroke:#2e7d32Version Constraint Patterns #
# Exact — only a specific version (too strict for production)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2" # Will never auto-update
}
# Pessimistic — patch updates only (recommended for stability)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1" # >= 5.1.0, < 5.2.0
}
# Minor updates — including minor and patch
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = ">= 5.1.0, < 6.0.0" # All 5.x but not 6.0
}
Version Constraint Best Practices #
# For internal modules (you control them):
module "app" {
source = "./modules/app"
version = "~> 2.1" # Patch updates allowed
}
# For external modules (you don't control them):
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # Stricter — patches only
}
# For providers (stability is very important):
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Pessimistic ~> for stability
}
}
}
# Check available versions
terraform providers lock -platform=linux_amd64
# Generate a lock file for cross-platform consistency
# Update the provider to the latest version
terraform init -upgrade
# Careful: can change behavior
Pinning Provider Versions #
# Inside a module, DON'T pin the provider version
# Let the root module decide
# Module (modules/networking/main.tf):
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
# DON'T set the version here
# Let the root module decide
}
}
}
# Root module (main.tf):
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # The version is decided at the root
}
}
}
# .terraform.lock.hcl — commit to version control
# This file ensures all developers and CI/CD
# use the same provider versions
# Generate a lock file for multiple platforms
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64
# Update the provider to the latest version
terraform init -upgrade
# Review the lock file changes
git diff .terraform.lock.hcl
Version Constraint Operators #
# OPERATORS:
# = (exact): version = "5.0.0"
# != (exclude): version != "5.0.0"
# > (gt): version > "5.0.0"
# >= (gte): version >= "5.0.0"
# < (lt): version < "6.0.0"
# <= (lte): version <= "5.5.0"
# ~> (pessimistic): version ~> "5.0" (allows 5.x but not 6.0)
# EXAMPLES:
# ~> 5.0 = >= 5.0, < 6.0
# ~> 5.1 = >= 5.1, < 6.0
# >= 5.0, < 6.0 = same as ~> 5.0
# BEST PRACTICE:
# Registry modules: use ~> for minor versions
# Providers: use ~> for major versions
# Internal modules: pin the exact version
Summary #
- Versioning is a need, not a choice — without versions, two teams applying at different times can get different results.
- Semantic versioning: PATCH for bug fixes, MINOR for backward-compatible features, MAJOR for breaking changes.
- Git tags as versions for modules in a Git repo —
?ref=v1.2.0in the source URL.versionconstraints in the Terraform Registry — use~>for patch/minor flexibility,=for strict pins in production.- Never use a Registry module without a version constraint — every
terraform initcould get a different version.- Upgrade major versions gradually — test in dev/staging first, read the plan output carefully, use
movedblocks if internal resources are renamed.