Skip to content

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.

ChangeVersion bump
Removing or renaming a variable or outputMajor
Adding a required variableMajor
Changing a default that alters existing resourcesMajor
Renaming a resource address without a moved blockMajor
Raising the minimum Terraform or provider versionMajor
Adding an optional variable or a new outputMinor
New optional resource behind a variable that defaults offMinor
Bug fix with no change to plans for existing usersPatch

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:

modules/s3-bucket/moved.tf
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:

stacks/orders/main.tf
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:

stacks/orders/main.tf
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.0 or 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:

  1. Validate on every pull request: terraform fmt -check, terraform validate, and tflint with the provider's ruleset.
  2. Test real behavior. Terraform 1.6 added terraform test with .tftest.hcl files, which can run plan or apply against a sandbox account and assert on outputs. Terratest remains an option for teams already using Go.
  3. Generate docs with terraform-docs so the README always lists current inputs and outputs.
  4. 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.
tests/defaults.tftest.hcl
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:

CHANGELOG.md
## 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

  1. Pick the three most widely used modules and tag their current state as v1.0.0.
  2. Change every consumer from a branch reference to that tag or a registry constraint.
  3. Add fmt, validate, tflint and one terraform test file to each module's CI.
  4. Adopt a changelog with an upgrade-notes section for every major release.
  5. Turn on Renovate for the stacks, starting with non-production.
  6. Write down the support window for old majors and announce it.