Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 015 / 100

Versioning Terraform modules: what developers should know

Change modules without breaking stacks.

Expona Admin7 min read

Versioning Terraform modules: what developers should know

When your infrastructure team grows beyond a handful of engineers, managing shared Terraform modules becomes a coordination problem. You'll face the question: how do we evolve modules without breaking the stacks that depend on them? The answer lies in treating modules like any software dependency—with careful versioning, clear contracts, and a disciplined upgrade strategy. This article covers the practices that keep your infrastructure stable while modules continue to improve.

Why module versioning matters

Imagine this scenario: your platform team maintains a VPC module used by ten different product teams. You add a new required variable for enhanced security controls. Without versioning, you either break all ten stacks immediately or maintain two incompatible module codebases. Versioning prevents this chaos.

Module versioning serves several purposes. It creates a stable contract between module publishers and consumers. It allows breaking changes to be introduced in a new major version while keeping the old version available. It makes rollbacks possible when something goes wrong. And it enables teams to upgrade on their own schedule rather than forced synchronization across the entire organization.

Tag releases with semantic versions

Semantic versioning follows the pattern MAJOR.MINOR.PATCH. A major version bump (1.0.0 to 2.0.0) indicates breaking changes. A minor version bump (1.0.0 to 1.1.0) indicates new features that are backward compatible. A patch version bump (1.0.0 to 1.0.1) indicates bug fixes. This convention lets consumers understand at a glance whether they can safely upgrade.

In practice, you'll tag releases in your Git repository where the module lives. If your module is in a monorepo alongside other modules, tag releases with the module name as a prefix.

hcl

# Example tag workflow for a Git repository
git tag v1.0.0
git push origin v1.0.0

# For monorepo with multiple modules
git tag aws-vpc-v1.0.0
git push origin aws-vpc-v1.0.0

Consider this real scenario: your RDS module currently creates a single database instance. In version 1.0.0, it outputs the endpoint and port. In 1.1.0, you add support for read replicas—new functionality, but existing code continues to work. In 2.0.0, you rename the output from endpoint to primary_endpoint for clarity—this breaks existing code, so it must be a major version bump.

Each version gets associated with a specific commit hash, making deployments reproducible. When a consumer references v1.0.0, they get the exact code from that commit, no surprises.

Reference modules with pinned versions

Terraform's module source syntax supports Git URLs with version constraints. Instead of referencing the main branch or a commit hash, reference a specific semantic version tag.

hcl

# Good: pinned to exact version
module "vpc" {
  source = "git::https://github.com/company/terraform-modules.git//modules/aws-vpc?ref=v1.2.0"

  vpc_cidr = "10.0.0.0/16"
  name     = "production"
}

# Also acceptable: allow patch updates
module "rds" {
  source = "git::https://github.com/company/terraform-modules.git//modules/aws-rds?ref=v1.2"

  engine         = "postgres"
  engine_version = "15.2"
}

# Avoid: references main branch, unpredictable
module "s3_bucket" {
  source = "git::https://github.com/company/terraform-modules.git//modules/aws-s3"
  # No ?ref specified—pulls latest from main
}

The ?ref= query parameter tells Terraform which Git ref to check out. This can be a tag (recommended), a branch name (less recommended for production), or a commit hash (rarely useful). Pinning to exact versions like v1.2.0 is the safest approach for production stacks. This way, terraform init always pulls the same module code.

Some teams use looser constraints like v1.2 to automatically pick up patch releases, accepting the trade-off that v1.2.1 might differ from v1.2.0. If you do this, ensure your tests catch any surprises.

Maintain clear changelogs for breaking changes

Every module repository should have a CHANGELOG that documents what changed in each version. This becomes especially critical before major version bumps. The changelog lets consumers understand the impact before they decide to upgrade.

A good changelog includes the version number, release date, additions, improvements, bug fixes, and deprecations or breaking changes. Format it in a way that's easy to scan—many teams use the Keep a Changelog format.

hcl

# Example CHANGELOG.md structure

## [2.0.0] - 2024-01-15

### Breaking Changes
- Removed `instance_type` variable. Use `instance_types` (list) instead.
- Output `dns_name` renamed to `load_balancer_dns`.
- Minimum required Terraform version bumped to 1.5.

### Added
- Support for multiple availability zones via new `availability_zones` variable.
- New output `security_group_id` for downstream reference.

### Fixed
- Resolved race condition in security group rule attachment.

## [1.5.2] - 2024-01-10

### Fixed
- Corrected default value for `enable_monitoring` variable.

## [1.5.1] - 2024-01-05

### Added
- New optional variable `log_retention_days` for CloudWatch logs.

When you're about to introduce a breaking change, announce it in advance in a minor version. For example, in v1.4.0 you might add a deprecation warning in your module's output or add a new preferred variable alongside the old one. This gives consumers time to plan their upgrades before the breaking change lands in v2.0.0.

Store the changelog in the module repository root, not buried in documentation. Make it a first-class artifact that's easy to find and reference.

Upgrade stacks one at a time

Even with perfect versioning, upgrading every stack that uses a module simultaneously is risky. The safer pattern is rolling upgrades: test in a non-production environment first, then gradually move through staging and production stacks.

Start by upgrading one stack that uses the module. Run terraform plan to see what will change. If it's a minor version bump, you expect minimal changes. If it's a major version bump, expect more significant changes—this is where the changelog helps you understand what's needed.

hcl

# Stack 1: development environment
# Update the source reference
module "vpc" {
  source = "git::https://github.com/company/terraform-modules.git//modules/aws-vpc?ref=v2.0.0"
  # Update any variables if required by v2.0.0 breaking changes
  vpc_cidr_blocks = ["10.0.0.0/16"]  # Changed from vpc_cidr
}

# Run terraform plan to inspect changes
# terraform plan > /tmp/plan.txt
# Review the plan for surprises

# terraform apply only after review

This rolling approach gives you early warning if something went wrong with the new module version before it affects your critical infrastructure. If an issue appears, you can either fix your stack's configuration or report a bug back to the module maintainers.

In larger organizations, you might maintain a spreadsheet tracking which stacks use which module versions. This visibility helps with coordinating upgrades and understanding blast radius if you need to issue a patch.

Consider automating this process with infrastructure-as-code pipelines. Some teams create pull requests automatically that update module versions, then run plan-only validations before humans approve the merge.

Common mistakes

  • Forgetting to commit the tag: You create a release locally but forget to push the Git tag to the remote repository. Consumers can't find the version. Always push tags immediately after creating them.
  • Mixing breaking changes into minor version bumps: You add a required variable in what you label v1.5.0, but it breaks every stack. Semantic versioning relies on trust—if you violate it, nobody knows when it's safe to upgrade. Always major-version-bump for breaking changes.
  • Not testing module upgrades before production: You update the version constraint in your production stack without testing in development first. The new module version has a subtle bug that only manifests under production load. Always test in lower environments first.
  • Deleting old versions: You force-delete v1.0.0 because you think nobody uses it anymore. Then discover a legacy stack still pinned to it. Treat published versions as immutable—once released, they stay available.
  • Vague changelogs: Your changelog says "improved vpc module" with no detail. Consumers don't know if it's safe to upgrade. Always be specific about what changed and what impact it has.

Key takeaways

  • Tag module releases with semantic versions (MAJOR.MINOR.PATCH) to signal breaking changes, features, and fixes clearly.
  • Pin stacks to exact module versions using ?ref=v1.2.0 syntax so deployments remain reproducible.
  • Maintain a changelog that highlights breaking changes, new features, and deprecations before major version bumps.
  • Roll out module upgrades gradually across stacks, testing in lower environments before touching production.
  • Treat published versions as immutable—never delete or re-tag released versions.
  • Consider deprecation warnings in minor versions before breaking changes land in major versions.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles