Why version modules
Without a version pin: you call module "vpc" { source = "..." }. Today it
downloads code in one shape. Tomorrow the module author releases a breaking
change, renames an input. Your next terraform init pulls the new code, the
plan shows a destroy on the VPC. In production.
Versioning fixes this: you pin a specific version, and updates happen only when you decide, after reviewing the plan.
Semantic versioning
The semver standard: MAJOR.MINOR.PATCH.
| Component | Changes when | Example |
|---|---|---|
| PATCH | Bug fixes, no contract change | 1.2.3 -> 1.2.4 |
| MINOR | New inputs/outputs or resources, backward-compatible | 1.2.3 -> 1.3.0 |
| MAJOR | Breaking: removed or renamed inputs, changed resource type | 1.2.3 -> 2.0.0 |
The Terraform Registry and most module communities follow semver. It is not
enforced, and theoretically someone could introduce a breaking change in a
minor release. In practice, terraform-aws-modules/* follows semver strictly.
Version constraints
In a module block:
module "vpc" {source = "terraform-aws-modules/vpc/aws"
version = "5.7.0" # exactly this version
# version = "~> 5.7" # 5.7.x, but not 5.8 and not 6.0
# version = ">= 5.0, < 6.0" # any 5.x
# version = "~> 5.7.0" # 5.7.x (excludes 5.8)
}
Operators
| Operator | Meaning |
|---|---|
5.7.0 | Exactly this version |
>= 5.7.0 | This version or newer |
~> 5.7.0 | 5.7.x (last component is free) |
~> 5.7 | 5.x where x >= 7 (second-to-last component is free) |
>= 5.0, < 6.0 | A range, explicit major boundary |
!= 5.8.3 | Any version except this one (for example, a known-bad release) |
Pessimistic constraint ~>
The most common choice in production. It means "the last component may grow; everything above it is fixed".
~> 5.7.0 is equivalent to >= 5.7.0, < 5.8.0: picks up patches, ignores
minor bumps.
~> 5.7 is equivalent to >= 5.7, < 6.0: picks up minor releases, blocks
major bumps.
The tradeoff: get bug fixes automatically, stay protected against breaking changes.
When to pin exactly
- Production: use an exact version.
version = "5.7.0". Upgrade deliberately, through a PR with code review. - Services with independent release cycles: use
~> 5.7.0. Patches come in automatically; minor bumps go through a PR. - Experimental environments or playgrounds:
~> 5.0is fine. Pinning tightly adds no value here.
Where version constraints apply
The version constraint works only for:
- Modules from the Terraform Registry (
terraform-aws-modules/vpc/aws). - Providers through
required_providers.version. See tf-version-constraints.
For git, HTTP, and S3 modules, the version argument does not work. Use
?ref=v5.7.0 in the URL instead:
module "billing" {source = "git::https://github.com/myorg/modules.git//billing?ref=v5.7.0"
}
The same constraint applies as for any git source: ref takes a static
value, not an expression.
How to upgrade versions
Step 1: update the HCL
module "vpc" {source = "terraform-aws-modules/vpc/aws"
- version = "5.7.0"
+ version = "5.8.0"
Step 2: terraform init -upgrade
Without -upgrade, Terraform checks the cache and concludes it already has
what it needs.
terraform init -upgrade
Step 3: terraform plan
Read the plan carefully:
- Any destroy in a plan coming from a module is a red flag. It likely means a breaking change.
- Changes to default tags, ARNs, and IDs may be fine (in-place replace) or problematic (destroy + create). Context determines which.
- When the plan is hard to interpret, open the module's CHANGELOG. Good modules have one.
Step 4: apply
Apply in a test environment first. Never upgrade a module directly in production.
Lockfile and modules
.terraform.lock.hcl records only provider versions. Module versions are
not stored in the lockfile. The single source of truth for a module
version is the version value in the HCL itself.
This means two developers with identical HCL and the same
version = "~> 5.7" can end up with different minor versions of a module
if a new release came out between their init runs. The fix is an exact
version pin in production.
Pitfalls
-
~> 5.0is not the same as~> 5.0.0.~> 5.0allows anything up to 5.99.99.~> 5.0.0allows only 5.0.x. Know the difference or you will pick up minor updates you did not expect. -
No version specified means "give me the latest". A
module "x" { source = "..." }block without aversionin the Registry downloads the newest release. Fine on a brand-new project. On an existing one, you get the classic "it was working and I changed nothing." -
Semver does not mean no plan. Even a minor upgrade like
5.7.0 -> 5.8.0(new resources, backward-compatible) adds resources to state. That is a plan, just one without any destroys. Read it. -
CHANGELOG and UPGRADE_GUIDE are required on large modules. Before a major upgrade of
terraform-aws-modules/vpc/awsfrom 4 to 5, open the UPGRADE.md in the module repository. It lists which inputs were renamed. Skipping that step means debugging afterward. -
Pre-release versions (
5.8.0-rc.1) match~> 5.8. This can be surprising. Terraform treats a pre-release as greater than5.8.0. To exclude pre-releases, add< 5.9.0-0to the constraint. -
You removed a
moduleblock, but the module is still downloaded. That is harmless.terraform init -upgradewill clean it up. The leftover code in.terraform/modules/is clutter, not a bug.