Terraform Course
Module Versioning
A module without versioning is a liability. The moment a second team calls your module, any change you make affects them — instantly, without warning. Versioning is what turns a module from a shared codebase into a stable contract. This lesson covers semantic versioning for modules, the Terraform Registry publishing workflow, Git tag pinning for private modules, and how callers upgrade safely.
This lesson covers
Why module versioning matters → Semantic versioning for Terraform modules → The version constraint operators → Publishing to the Terraform Registry → Private module distribution with Git tags → Safe upgrade workflow → Breaking vs non-breaking changes
Why Versioning Matters
When your VPC module lives only in your project and you are the only caller, an unversioned local path is fine. The moment a second team calls it, or you publish it to a shared registry, you have made a contract. Every change you make to the module now affects every caller — unless versioning insulates them.
Without versioning: you rename a variable on a Tuesday and thirty configurations across your organisation break on their next plan. With versioning: callers pin to a version, you release a new one, they upgrade on their own schedule.
The Analogy
Think of a module version like a published book edition. When a publisher releases the second edition of a textbook, all the libraries that bought the first edition still have the first edition on their shelves. They can choose to buy the second edition when ready. They are not forced to. Without versioning, it is like the publisher sneaking into every library at night and rewriting the pages — every reader wakes up to a different book and no one understands why things changed.
Semantic Versioning — MAJOR.MINOR.PATCH
Terraform modules use semantic versioning — MAJOR.MINOR.PATCH. The version number communicates intent. When callers pin with version = "~> 2.0", they trust that every 2.x release will not break them. Violating that trust breaks every caller who pinned to that major version.
| Version part | When to increment | Caller impact |
|---|---|---|
| MAJOR (1.0.0 → 2.0.0) | Rename or remove a variable, remove an output, change a type, cause a resource to be destroyed and recreated | Breaking — callers must update their code |
| MINOR (1.0.0 → 1.1.0) | Add a new optional variable with a default, add a new output, add an optional resource | Backward compatible — callers unaffected |
| PATCH (1.0.0 → 1.0.1) | Fix a bug in logic, fix a validation condition, update documentation | Backward compatible — callers unaffected |
New terms:
- ~> (pessimistic constraint operator) — allows updates within a limited range.
~> 2.0means any version>=2.0, <3.0.~> 2.3means>=2.3, <2.4. The recommended constraint for module version pinning. - >= (minimum version) — allows any version at or above the specified version. Used in module
required_providers— not in the caller'sversionargument, where it would be too permissive. - = (exact version) — pins to one exact version. Maximum stability, but requires a manual update for every bug fix. Use only when an exact version is critical.
# Version constraint options — choose based on how much flexibility you want
# Recommended for most cases — allows minor and patch updates within major 2
module "vpc" {
source = "acme/vpc/aws"
version = "~> 2.0" # Allows 2.1.0, 2.5.3 but NOT 3.0.0
}
# Tighter — allows only patch updates within 2.3.x
module "vpc_tight" {
source = "acme/vpc/aws"
version = "~> 2.3" # Allows 2.3.1, 2.3.9 but NOT 2.4.0
}
# Exact pin — one specific version, no automatic updates
module "vpc_exact" {
source = "acme/vpc/aws"
version = "= 2.3.1" # Only 2.3.1 — must manually change to upgrade
}
# Range — minimum and maximum bounds
module "vpc_range" {
source = "acme/vpc/aws"
version = ">= 2.0.0, < 3.0.0" # Explicit range — same effect as ~> 2.0
# NEVER use an unpinned version for registry modules
# version = "*" or omitting version entirely fetches latest on every terraform init
# A major release can break your configuration without any code change on your part
Publishing to the Terraform Registry
The public Terraform Registry at registry.terraform.io indexes modules from public GitHub repositories. Publishing is just creating a Git tag — the registry monitors connected repositories and indexes new tags automatically.
New terms:
- terraform-PROVIDER-NAME repository convention — the public registry requires this naming format. A module that creates AWS infrastructure must live in a repository named
terraform-aws-NAME. The registry uses the provider segment to determine which provider the module targets. - registry module source format —
NAMESPACE/MODULE/PROVIDER. For example,acme/vpc/awswhereacmeis the GitHub organisation,vpcis the module name, andawsis the provider. - Git annotated tag — a tag that stores a message alongside the commit hash. Created with
git tag -a v1.0.0 -m "message". The Terraform Registry only indexes annotated tags that follow thevMAJOR.MINOR.PATCHformat.
# Publishing a module to the Terraform Registry — step by step
# Step 1: Repository must be named: terraform-PROVIDER-MODULE_NAME
# For an AWS VPC module: terraform-aws-vpc
# For a GCP GKE module: terraform-google-gke
# For an Azure VNet module: terraform-azurerm-vnet
# Step 2: Repository must have the standard module structure
# main.tf, variables.tf, outputs.tf, README.md
# At minimum — examples/ directory is strongly recommended
# Step 3: Connect your GitHub account to registry.terraform.io
# Sign in with GitHub OAuth and authorise the Terraform Registry app
# Step 4: Publish via the registry UI
# registry.terraform.io → Publish → Module → Select repository
# Step 5: Create a version tag — this is what triggers indexing
# The registry monitors connected repos and indexes vX.Y.Z tags automatically
# First release
git tag -a v1.0.0 -m "Initial release of terraform-aws-vpc module"
git push origin v1.0.0
# Patch release — bug fix, no interface changes
git tag -a v1.0.1 -m "Fix: correct AZ index calculation for odd subnet counts"
git push origin v1.0.1
# Minor release — new optional variable, backward compatible
git tag -a v1.1.0 -m "Feature: add enable_nat_gateway variable (default false)"
git push origin v1.1.0
# Major release — breaking change, callers must update their configurations
git tag -a v2.0.0 -m "Breaking: rename variable 'name' to 'vpc_name' — see CHANGELOG"
git push origin v2.0.0
# After pushing the tag — registry indexes within minutes
# Module becomes available at: registry.terraform.io/modules/acme/vpc/aws
# Callers can now pin to it:
module "vpc" {
source = "acme/vpc/aws"
version = "~> 1.0" # Allows 1.0.0, 1.0.1, 1.1.0 — anything in the 1.x family
}
# The registry automatically generates documentation from:
# - variable descriptions → Inputs tab
# - output descriptions → Outputs tab
# - README.md → Module description page
# Every description field you wrote in variables.tf and outputs.tf
# becomes a row in the public documentation. Write them carefully.What just happened?
- Publishing is a Git tag — nothing else. No upload, no packaging, no CLI command. The registry monitors the connected GitHub repository and indexes any new
vX.Y.Zannotated tag automatically. The release process is entirely managed through Git. - Documentation is auto-generated from descriptions. Every
descriptioninvariables.tfandoutputs.tfbecomes a row in the registry's Inputs and Outputs tabs. A variable with no description shows a blank — one of the reasons writing descriptions is not optional.
Private Module Distribution with Git Tags
Most organisations do not publish all their modules publicly. Internal modules — those encoding company-specific naming conventions, account structures, or compliance rules — are distributed privately. The most common approach is a private Git repository with a pinned ?ref= tag.
# Private module distribution options
# Option 1: Git with SSH — uses SSH key configured locally or in CI/CD
module "vpc" {
source = "git::ssh://git@github.com/acme-corp/terraform-modules.git//modules/vpc?ref=v2.1.0"
# The ?ref= pin is critical — always use a tag, never a branch name
# A branch like ?ref=main moves with every commit — equivalent to an unpinned version
# A tag like ?ref=v2.1.0 is permanent — always points to the same commit
}
# Option 2: Git with HTTPS — uses git credentials or GITHUB_TOKEN environment variable
module "vpc" {
source = "git::https://github.com/acme-corp/terraform-modules.git//modules/vpc?ref=v2.1.0"
}
# The // double-slash separates the repository URL from the subdirectory path
# Without it: Terraform looks for modules in the repo root
# With it: Terraform looks in modules/vpc within the repo
# source = "git::...modules.git//modules/vpc" → subdirectory: modules/vpc
# Option 3: HCP Terraform private registry — for teams using HCP Terraform
module "vpc" {
source = "app.terraform.io/acme-corp/vpc/aws" # Private registry address
version = "~> 2.0" # Version works exactly like public registry
}
# Option 4: Local path — for modules used only within one project
module "vpc" {
source = "../modules/vpc" # No versioning — changes take effect immediately
# Appropriate for project-specific modules that will never be shared
}
Which distribution method to choose
- Local paths — one project, one team, no sharing. Zero overhead. Right for project-specific modules.
- Git with ?ref= tags — multiple projects need the module, team controls the repository, no HCP Terraform licence. Tag-based versioning gives callers stability without a full registry setup.
- HCP Terraform private registry — organisation uses HCP Terraform for CI/CD and wants the full versioning and documentation experience scoped to the organisation.
- Public registry — module is generic enough to be useful to other organisations and you want to contribute to the community.
Safe Module Upgrade Workflow
Upgrading a module version without a plan is how production incidents happen. This is the correct sequence every time.
# Safe module upgrade workflow
# Step 1: Read the module's CHANGELOG before touching any code
# Look specifically for:
# - BREAKING CHANGES — anything marked as breaking in the new version
# - Renamed variables — you must update your module call
# - Removed outputs — anything referencing the old output name will break
# - Resource replacements — changes that cause destroy+recreate
# Step 2: Update the version constraint in your module block
module "vpc" {
source = "acme/vpc/aws"
version = "~> 2.0" # Was: "~> 1.0" — update this line
# ...
}
# Step 3: Download the new version
terraform init -upgrade # -upgrade forces re-download even if cached version exists
# Step 4: Run plan and read every line — especially look for:
# - Any ~ (modify) on resources that should not change
# - Any -/+ (destroy and recreate) — this means data loss risk for databases
# - New required variables that you have not supplied yet
terraform plan
# Step 5: Test in a non-production environment first
# Apply in dev. Verify infrastructure is correct. Then apply in staging. Then prod.
# Step 6: Commit the updated .terraform.lock.hcl
# The lock file records the new module version hash
# Everyone on the team and every CI pipeline uses the same version after this commit
git add .terraform.lock.hcl
git commit -m "Upgrade vpc module from ~> 1.0 to ~> 2.0"
Breaking vs Non-Breaking Changes
As a module author, the most important judgement call you make is deciding whether a change is breaking. Getting this wrong — releasing a breaking change as a minor version — is the most disruptive mistake in module authoring.
# Breaking changes — always require a MAJOR version bump
# 1. Renaming a required variable
# Before: variable "name" { type = string }
# After: variable "vpc_name" { type = string }
# → Every caller using name = "..." now gets: Error: Unsupported argument
# → MAJOR version bump required
# 2. Changing a variable's type
# Before: variable "subnet_cidrs" { type = list(string) }
# After: variable "subnet_cidrs" { type = map(string) }
# → Callers passing a list now get a type error
# → MAJOR version bump required
# 3. Removing an output
# Before: output "public_subnet_ids" { ... }
# After: removed
# → Any caller referencing module.vpc.public_subnet_ids gets an error
# → MAJOR version bump required
# 4. A resource is destroyed and recreated in existing deployments
# Even if the interface (variables/outputs) is unchanged —
# if the plan for an existing deployment shows -/+ on any resource,
# that is a breaking change. Callers need to schedule downtime.
# → MAJOR version bump required
# ─────────────────────────────────────────────────────────────────────
# Non-breaking changes — MINOR or PATCH
# MINOR: Add a new optional variable with a default
variable "enable_nat_gateway" {
description = "Create a NAT gateway for private subnet outbound internet access"
type = bool
default = false # false = same behaviour as before — callers are unaffected
}
# PATCH: Fix a bug in validation logic — no interface change
# Before: condition = length(var.name) > 0
# After: condition = can(regex("^[a-z][a-z0-9-]*$", var.name))
# → Callers who were passing valid names see no difference
# → PATCH version bump
Handle breaking changes gracefully with deprecation
When a breaking change is unavoidable, give callers time to migrate. In a MINOR release, add the new variable alongside the old one and mark the old one as deprecated in its description. Use coalesce(var.new_name, var.old_name) internally so both work. In the next MAJOR release, remove the old variable. Callers can migrate gradually within the current major version rather than being forced to update immediately.
Common Mistakes
Releasing a breaking change as a minor or patch version
Callers who pin with ~> 1.0 trust that 1.x releases will not break them. If you rename a variable in 1.2.0, every caller using ~> 1.0 will have the breaking change silently pulled in on their next terraform init -upgrade. Always bump MAJOR for breaking changes — even when the change feels small.
Using a branch name as the ?ref= value
?ref=main points at whatever is on the main branch right now — which changes every time someone merges a pull request. This is equivalent to an unpinned version. Always use a tag — ?ref=v2.1.0. Branches are only appropriate when you are actively developing the module and testing changes locally before creating a release.
Not reading the CHANGELOG before upgrading a major version
Major version upgrades frequently rename outputs, change variable types, and trigger resource replacements. Changing the version constraint and running terraform init -upgrade without reading the CHANGELOG first has caused production database replacements — with data loss. Read the CHANGELOG. Then plan. Then test in dev. Then apply to prod.
Practice Questions
1. You rename a required input variable from "name" to "vpc_name" in your module. Which version component must you increment?
2. What naming format must a GitHub repository use to be eligible for the public Terraform Registry — for an AWS module called "vpc"?
3. When using a Git-based private module with ?ref=, should you use a branch name or a tag? Why?
Quiz
1. You add a new optional variable with a default value to your module. Existing callers who do not pass this variable are completely unaffected. Which version component do you increment?
2. What does version = "~> 2.0" mean for a module constraint?
3. What is the correct first step when upgrading a module from version 1.x to 2.x?
Up Next · Lesson 29
Security Best Practices
Modules versioned. Now secure. Lesson 29 covers the security practices that prevent the most common Terraform infrastructure incidents — secrets management, state file protection, IAM least privilege, and static analysis tools that catch misconfigurations before they reach production.