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:#fff

Why 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 &quot;vpc&quot;\nversion = &quot;~> 3.0&quot;"] --> 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:#fff

Safe 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:#2e7d32

Version 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.0 in the source URL.
  • version constraints 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 init could get a different version.
  • Upgrade major versions gradually — test in dev/staging first, read the plan output carefully, use moved blocks if internal resources are renamed.

← Previous: Root vs Child Module   Next: Interface Design →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact