There is a never-ending discussion in the DevOps community around how to structure your Terraform configurations, how many root modules you should have, and what is the best way to split your infrastructure into smaller, reusable modules.
No matter which camp you stand in, Terraform modules have their place and can turn infrastructure chaos into a manageable codebase. An important aspect of working with Terraform modules is module versioning.
In this blog post, you will learn about what Terraform module versioning is, how it works, and best practices for managing your modules with proper versioning.
What we’ll cover:
TL;DR
Terraform module versioning makes infrastructure upgrades safer, more predictable, and easier to manage at scale.
For external modules published to a Terraform registry, follow semantic versioning: use major for breaking or rollback-unsafe changes, minor for backward-compatible features, and patch for bug fixes. Keep a changelog and document state-affecting refactors clearly.
What are Terraform modules?
Before we can understand what it means to version a Terraform module, we must understand what a Terraform module is.
Every Terraform configuration is a module. Terraform configurations where you run terraform apply are called root modules. From your root modules, you can call other modules (called child modules).
Terraform modules can be local and external. A local module is a directory located close to the root module, often in a subdirectory of the root module named modules. Local modules do not support versioning.
External modules are published to a Terraform registry. There is a public Terraform registry where anyone can publish modules, but you can also publish modules to your own private registries. External modules support versioning.
The main purpose of Terraform modules is to group together related infrastructure and allow creating multiple copies of the same infrastructure in a convenient manner.
A module producer is someone (e.g., a platform team) who builds the module code and publishes it for others to use. A module consumer is someone who uses modules in their Terraform root modules to create infrastructure. A module producer and consumer could be the same entity.
How does Terraform module versioning work?
As a module producer, you need to think about module versioning when publishing your modules. The purpose of versioning a module is similar to versioning other types of dependencies used in software development and DevOps contexts.
Module versioning should follow semantic versioning. With semantic versioning, each module version number consists of three components:
- A major version (e.g. 1.x.x)
- A minor version (e.g. x.1.x)
- A patch number (e.g. x.x.1)
There are strict rules around how each component in the version number should be modified for a given change to the module source code:
- Breaking, or backward-incompatible, changes increase the major version number. A backward-incompatible change simply means that you can’t roll back to a version with a lower major version number without encountering difficulties.
- Backward-compatible changes increase the minor version number.
- Bug fixes increase the patch number.
It is your responsibility as a module producer to follow these rules so that your module consumers can use your modules without any surprises.
As a module consumer, you should also care about module versioning. When you start consuming a new module, you will often use the latest available version at that time. As time passes and new versions are made available, you should have a deliberate plan for how you perform module upgrades. It is your responsibility to review the changes included in new module versions and decide how to proceed.
Example module versioning workflow in Terraform
To see a concrete example of module versioning, let’s create a simple Terraform module. The example module uses the Microsoft Azure provider and creates a storage account for storing blobs and files. In this scenario, we will be the module producers.
This walkthrough will not include details on how to set up your own private Terraform registry or publish modules to it. These details are out of scope for this blog post.
For details on this, you can read: Private Registry: Setup, Publishing & Best Practices.
Initial module version
We start with a blank slate: an empty repository.
Add a file named providers.tf containing the required providers for this module:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 4.0"
}
}
}Create another file named variables.tf that defines the input that this module expects.
variable "resource_group_name" {
type = string
}
variable "location" {
type = string
}
variable "suffix" {
type = string
}Finally, add a file named main.tf that contains the storage account resource:
resource "azurerm_storage_account" "default" {
name = "st${var.suffix}"
resource_group_name = var.resource_group_name
location = var.location
account_tier = "standard"
account_replication_type = "LRS"
}This is a simple module, but it already contains enough code that could be updated in different ways to force us to publish new major, minor, and patch releases.
To document the changes we make to our module, we should create a changelog. This is often created as a markdown file named CHANGELOG.md. Create this file and add the initial content:
# Changelog
## [1.0.0] - 2026-03-02
- Initial module codeA common way to publish a new version from your git repository is to tag the commit you wish to publish. Let’s create an initial v1.0.0 tag for the initial version of our module:
$ git tag -a v1.0.0 -m "Initial version"
$ git push origin v1.0.0If you use GitHub as your version control system, you can set up a GitHub Actions workflow that runs when a tag is pushed to your repository. This workflow could create a repository release and publish the module metadata to your private registry (again, the details of how to do this are outside the scope of this blog post).
A minimal example workflow that creates a release when a new tag on the format vX.Y.Z is pushed to the repository is shown below:
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
create-a-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- id: version
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
gh release create "$TAG" --title "$VERSION" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
With this workflow, we can publish a new version as a release in our GitHub repository by first implementing the change, committing it, tagging the commit, and pushing the tag to our repository.
Note that for a real module, we will likely add additional files to the repository. This includes files for testing the module (e.g., using the Terraform test framework) and documentation files (e.g., a README.md file).
Fixing a bug (increasing the patch number)
Soon after publishing the initial version, we discover that we didn’t properly test our module’s source code. It turns out we have supplied an invalid value to one of the resource arguments.
We have set the account_tier argument of the storage account to the value "standard". This is a valid value, but using an invalid casing. The correct value of this argument should be "Standard".
We implement the fix to the resource:
resource "azurerm_storage_account" "default" {
name = "st${var.suffix}"
resource_group_name = var.resource_group_name
location = var.location
account_tier = "Standard"
account_replication_type = var.account_replication_type
}We can now publish an updated module version. This change does not introduce any new features; it is simply a bug fix. Therefore, we increase the patch number by 1 so that our latest module version is now 1.0.1.
Add an entry to CHANGELOG.md describing this new version:
# Changelog
## [1.0.1] - 2026-03-02
### Fixed
- Corrected the account tier argument value to "Standard"Commit the change, and then tag the commit and push it to the repository:
$ git tag -a v1.0.1 -m "Fix account tier value"
$ git push origin v1.0.1We implement this change as a new version; we do not modify v1.0.0. However, since we know that v1.0.0 will never work (due to the bug we introduced), we should likely flag this as a bad version in our registry.
See what options you have to do this in the specific registry implementation you are using. For the Spacelift module registry, you can mark a version as bad.
Adding new features (increasing the minor version)
We now have a working module. We would now like to add new features to the module.
We will add a new resource: a storage container for storing blobs, specifically for temporary (non-critical) Azure platform log files.
Add the new resource to main.tf:
# previous code omitted for brevity
resource "azurerm_storage_container" "logs" {
name = "platform-logs"
storage_account_id = azurerm_storage_account.default.id
}This change is a new feature: a new resource. The change is backward compatible. After applying the update, we could go back to version 1.0.1 without any problems. Terraform would destroy the non-critical storage container, but the main infrastructure (the storage account) remains untouched.
Therefore, we increase the minor version of the module: 1.1.0. Note that the patch number is reset to zero when we increase the minor version.
Add an entry to CHANGELOG.md to describe the change:
# Changelog
## [1.1.0] - 2026-03-03
### Added
- A storage container named platform-logs for non-critical platform log file storageThen we commit the change, tag the commit, and push it to the repository:
$ git tag -a v1.1.0 -m "Add storage container for platform logs"
$ git push origin v1.1.0Introduce a breaking change (increasing the major version)
After some time, we want to refactor our module slightly to comply with the Terraform design guidelines our organization has put together.
The design guidelines state that the main resources should use the resource name <"this" in the source code. Currently, we have named the storage account resource to "default".
We implement the change by changing the resource name for the storage account:
resource "azurerm_storage_account" "this" { # <-- update this
# the rest of the resource remains unchanged
}We also add a moved block to the code to inform Terraform that the resource that was previously at the address azurerm_storage_account.default can now be found at azurerm_storage_account.this:
moved {
from = azurerm_storage_account.default
to = azurerm_storage_account.this
}This is a backwards-incompatible change. We can upgrade our module to the latest version without any issues.
However, if we try to revert to version 1.1.0, Terraform will attempt to replace the storage account and container with new resources, potentially deleting hundreds of gigabytes of data in the process.
The module consumer could perform a safe rollback to version 1.1.0 if they also include a moved block in their root module to revert the previous moved block. This moved block would look similar to the following example:
moved {
from = module.storage.azurerm_storage_account.this
to = module.storage.azurerm_storage_account.default
}However, requiring the module consumer to add this block is not a fair responsibility to put on the consumer. Instead, treat this as a major version upgrade.
Therefore, we will increase the module version to 2.0.0. Note that both the minor version number and the patch number are reset to zero when we increase the major version.
Add an entry to CHANGELOG.md describing the change:
# Changelog
## [2.0.0] - 2026-03-04
### Changed
- Refactoring resource names and update the state to match And then:
$ git tag -a v2.0.0 -m "Change ..."
$ git push origin v2.0.0Best practices for Terraform module versioning
Keep the following best practices in mind when working with module versioning, as a module producer and a module consumer.
1. Follow semantic versioning
When you publish modules to a Terraform registry, you must follow semantic versioning. Make sure you understand each change you make to your modules and bump the appropriate version number (major, minor, or patch).
Following semantic versioning as intended means there is less surprising behavior for your module’s consumers.
Your module consumers could use a version constraint when consuming your modules, e.g., version = "~> 1.0.0" and be confident that any patch version updates allowed by this version constraint only introduce bug fixes and nothing else.
Note that some changes you make might be completely backwards compatible (e.g., adding a new resource), but should still be treated as a major version change. This is because rolling back to an older module version would cause Terraform to destroy the new resource. This will often be a destructive change. However, this depends on the nature of the resource you are adding. If the resource is non-critical, this change could be treated as a minor upgrade.
2. Keep a changelog
It is a good practice to keep a changelog document for any changes you make to your module source code. This is not a requirement, but it simplifies things for your module consumers.
The changelog should document the changes for each version. It should be a human-readable list of descriptions of what the corresponding code changes do.
The changelog is typically written in markdown in a file named CHANGELOG.md, and it often includes the following headers:
- Added to the document new features (e.g., adding a new resource or a new output)
- Changed to document significant changes (e.g., changing some attributes of an existing resource)
- Deprecated to document deprecations (e.g., deprecating a variable or output)
- Removed to document removals (e.g., removing a previously deprecated variable or output, or removing a resource or data source)
- Fixed to document bug fixes (e.g., correcting the input type for a variable or fixing the logic in how some local value is computed)
In the walkthrough earlier in this blog post, each new version included only one of these headers, but it is common for a new version to use multiple headers (e.g., you add a few new features, change some old behavior, and fix a few bugs).
3. Make breaking changes rare
Breaking changes are inevitable. However, try to minimize the number of breaking changes you make. Increasing the major version number of your modules can often make it hard to keep up with changes.
Teams handle major version upgrades at different paces, where some teams may prefer to introduce new versions slowly in a controlled fashion. If they are constantly three or four major versions behind the latest version, this would put unnecessary stress on these teams.
Also, constantly bumping the major version makes it appear as if your module is chaotic and unstable, and might make your module consumers search for alternative modules.
4. Use version constraints as a module consumer
As a module consumer, you can use version constraints when consuming modules. An example of using a version constraint is shown in the following module block:
module "storage" {
source = "myregistry.myorganization.com/storage/azurerm"
version = "~> 2.0.0"
}The version constraint "~> 2.0.0" tells Terraform to use the latest version available starting with 2.0.x (e.g. 2.0.1, 2.0.3, or 2.0.42). If a new patch version is released, you will automatically consume this version.
If you want to be deliberate about when you consume module upgrades, you should pin the module version to an exact version number. An example of this is shown below:
module "storage" {
source = "myregistry.myorganization.com/storage/azurerm"
version = "= 2.0.0"
}This version constraint tells Terraform to use version 2.0.0 and only that version. You could also write this version constraint simply as version = "2.0.0" (i.e., without the explicit equal sign).
How to manage Terraform resources with Spacelift
Terraform is powerful, but a secure, end-to-end GitOps workflow needs more than a CLI. You need a platform that can orchestrate your infrastructure workflows with the right guardrails, visibility, and control.
Spacelift takes Terraform further with purpose-built infrastructure orchestration, including:
- Policy as code with Open Policy Agent (OPA) — Control approvals, restrict the resources teams can create, validate configuration parameters, and define how runs behave when pull requests are opened or merged.
- Multi-IaC workflows — Orchestrate Terraform alongside Kubernetes, Ansible, OpenTofu, Pulumi, CloudFormation, and other tools. Model dependencies between workflows and share outputs across them.
- Governed self-service infrastructure — Use Blueprints to create Golden Paths for self-service. Teams fill out a form to provision infrastructure through standardized, policy-backed workflows.
- Integrations with third-party tools — Connect Spacelift to the tools your teams already use, and extend governance across them. For example, you can integrate security tools into your workflows using Custom Inputs.
Spacelift also lets you run private workers inside your own infrastructure, so you can execute workflows within your security perimeter. Read the documentation to learn more about configuring private workers.
You can try Spacelift by creating a trial account or booking a demo. The platform supports teams that want developer self-service without giving up governance, auditability, or control.
Key takeaways
Terraform module versioning provides a predictable experience when upgrading modules for your module consumers.
Module publishers follow semantic versioning when publishing modules to a Terraform registry. Semantic versioning uses a version number consisting of three parts: major, minor, and patch.
The major version is bumped for breaking or backward-incompatible changes. The minor version is bumped for new backward-compatible changes and features. The patch number is bumped for bug fixes.
Child modules can be either local or external. Local modules do not support versioning. External modules published to a Terraform registry (public or private) do support versioning.
As a module publisher, follow semantic versioning, keep a changelog describing the version changes, and minimize breaking changes to make the lives of your module consumers easier.
Manage Terraform better and faster
If you are struggling with Terraform automation and management, check out Spacelift. It helps you manage Terraform state, build more complex workflows, and adds several capabilities for end-to-end infrastructure management.
Frequently asked questions
How does versioning differs by module source?
In Terraform, versioning depends on the module source. Registry modules use a separate version argument and support version constraints like ~> or >=. Git and other VCS sources do not use version, you pin them in source with ?ref= to a tag, branch, or commit. Local paths, HTTP archives, S3, and GCS sources also do not support version, so any versioning must be handled by the path, URL, or object name itself.
How do you handle module versioning in Terraform?
Use semantic versioning and set a version constraint in the module block for registry modules, usually ~> for controlled patch or minor updates. Terraform then selects the newest installed or downloadable version that matches that constraint, and constraining versions explicitly is recommended to avoid unexpected changes. For Git-sourced modules, pin with a tag or commit using ref, because the version argument is for registry modules.
How to check Terraform module version?
Check the module block in your Terraform code first. Registry modules usually declare it with version = “x.y.z”, while Git modules pin it in source, for example ?ref=v1.2.3.
If you want the resolved installed version after terraform init, inspect .terraform/modules/modules.json. Terraform does not store module versions in terraform.lock.hcl, that file is only for providers.
What are the best practices for semantic versioning Terraform modules?
Treat a module’s public API as its inputs, outputs, expected behavior, and resource addresses: bump major for incompatible changes, minor for backward-compatible features, and patch for backward-compatible fixes.
Publish releases with valid x.y.z or vX.Y.Z Git tags, encourage consumers to use version constraints such as ~>, and prefer deprecating or revoking bad versions instead of deleting them unless there is a critical flaw.
Should I pin Terraform module versions?
You should pin Terraform module versions. It makes builds reproducible, prevents unexpected changes from upstream releases, and reduces the risk of a plan changing between runs or environments.
A good practice is to pin to an exact tag or commit in production, then upgrade intentionally after testing, rather than tracking a moving branch or latest release.
