InsightsInfrastructure as Code
Versioning Terraform modules without breaking every consumer
How to publish shared Terraform modules with semantic version tags, consume them through a registry or pinned git refs, and roll upgrades out across many stacks.
Published January 2, 20245 min read
Shared Terraform modules start as a convenience. One team writes a good VPC module, another copies the path, and within a year thirty stacks depend on it. Then someone renames a variable on the main branch, and the next terraform init in an unrelated repository pulls the change and fails, or worse, plans to replace a load balancer.
The fix is to treat modules like any other library: release them with versions, consume them with explicit constraints, and upgrade on purpose.
Semantic versioning for infrastructure
Semantic versioning works for modules, but "breaking" needs a stricter definition than it does for application code. A change is breaking if any consumer would have to change their code, or if a plan would destroy or replace existing resources.
| Change | Version bump |
|---|---|
| Removing or renaming a variable or output | Major |
| Adding a required variable | Major |
| Changing a default that alters existing resources | Major |
Renaming a resource address without a moved block | Major |
| Raising the minimum Terraform or provider version | Major |
| Adding an optional variable or a new output | Minor |
| New optional resource behind a variable that defaults off | Minor |
| Bug fix with no change to plans for existing users | Patch |
Most accidental breakage comes from resource addresses. Renaming aws_s3_bucket.this to aws_s3_bucket.main, or moving a resource into a for_each, destroys and recreates it unless the module tells Terraform about the move:
moved {
from = aws_s3_bucket.this
to = aws_s3_bucket.main
}With a moved block in place, the refactor becomes a minor release instead of a major one. Keep these blocks for at least one major version so consumers who skip releases still upgrade cleanly.
Where modules live
There are two common ways to distribute modules, and both work with versions.
A private registry. Terraform Cloud and Terraform Enterprise include one, as do several other platforms such as Spacelift, env0 and GitLab. The registry publishes a module version for each semver tag in its repository, and consumers get real version constraints:
module "orders_db" {
source = "app.terraform.io/platform/postgres/aws"
version = "~> 3.2"
name = "orders"
instance_class = "db.r6g.large"
vpc_id = module.network.vpc_id
}Git references. No registry needed, just tags in the repository. The version argument does not apply to git sources, so the version goes in the ref query parameter:
module "orders_db" {
source = "git::https://github.com/platform/terraform-aws-postgres.git?ref=v3.2.1"
name = "orders"
instance_class = "db.r6g.large"
vpc_id = module.network.vpc_id
}Git refs pin an exact version, which is safe but means every patch release needs a change in every consumer. A registry lets consumers accept patches and minors automatically within a constraint. Never point a git source at a branch such as main. That is the original problem in a different form.
If you have many modules in one repository, the registry needs one repository per module, named terraform-PROVIDER-NAME. Git sources can address a subdirectory with a double slash, such as //modules/postgres?ref=v3.2.1, but then all modules in the repository share one version line. Separate repositories are easier to version independently.
Choosing constraints
Terraform's pessimistic constraint operator is the one to know. ~> 3.2 allows 3.2 and any later 3.x, and ~> 3.2.1 allows only 3.2.x patches from 3.2.1 upward.
- Production stacks:
~> 3.2.0or an exact version. Changes arrive when you choose them. - Lower environments:
~> 3.2, to pick up minor releases early. - Inside other modules: use the widest range that works, such as
>= 3.2, < 4.0. Tight constraints in nested modules create conflicts that consumers cannot resolve.
The same rules apply to the required_providers block in each module. A module should declare the minimum provider version it needs and leave the upper bound to the root stack.
The .terraform.lock.hcl file records provider versions and hashes, not module versions. Commit it in root stacks so provider upgrades are deliberate. For modules, the version constraint is the only control.
Releasing a module
A release should be boring and repeatable:
- Validate on every pull request:
terraform fmt -check,terraform validate, andtflintwith the provider's ruleset. - Test real behavior. Terraform 1.6 added
terraform testwith.tftest.hclfiles, which can runplanorapplyagainst a sandbox account and assert on outputs. Terratest remains an option for teams already using Go. - Generate docs with
terraform-docsso the README always lists current inputs and outputs. - Tag from main with the next version. Conventional commits with release-please or semantic-release can choose the bump and write the changelog from commit messages.
variables {
name = "orders"
vpc_id = "vpc-0a1b2c3d4e5f67890"
}
run "encrypted_by_default" {
command = plan
assert {
condition = aws_db_instance.main.storage_encrypted == true
error_message = "Storage must be encrypted unless explicitly disabled."
}
}Changelogs people read
A changelog entry for a major version should answer three questions: what changed, what the consumer must do, and whether the plan will show replacements. A short upgrade section is worth more than a long list of commits:
## 4.0.0
### Breaking
- `instance_type` renamed to `instance_class`. Rename the argument.
- Minimum AWS provider version is now 5.0.
### Upgrade notes
- `moved` blocks cover the parameter group rename. Expect no replacements.
- Run `terraform plan` and confirm zero destroys before applying.The upgrade workflow
Versioned modules only help if upgrades actually happen. Otherwise stacks drift several majors behind and the eventual upgrade is the same big-bang change you were trying to avoid.
- Automate the pull requests. Renovate understands Terraform module sources, both registry and git tags, and opens a pull request per stack when a new version is released. Dependabot supports registry modules as well.
- Let CI post the plan on each upgrade pull request, so reviewers see the actual effect instead of the version diff.
- Upgrade in environment order. Development first, then staging, then production, with the same version.
- Track versions in use. A simple search across repositories for each module source, run on a schedule, shows which stacks are behind and by how much.
- Support the previous major for a fixed period, then stop. Backporting fixes indefinitely to old majors is how module teams burn out.
For more on promoting the same configuration through environments, see structuring Terraform for multiple environments.
Where to start
- Pick the three most widely used modules and tag their current state as
v1.0.0. - Change every consumer from a branch reference to that tag or a registry constraint.
- Add
fmt,validate,tflintand oneterraform testfile to each module's CI. - Adopt a changelog with an upgrade-notes section for every major release.
- Turn on Renovate for the stacks, starting with non-production.
- Write down the support window for old majors and announce it.