Module Versioning
Modules evolve: bugs get fixed, inputs change, and new resources appear. Without explicit versioning, every terraform init could silently pull a newer module and rewrite your infrastructure plan out from under you. Pinning module versions makes your configuration reproducible — the same code produces the same plan today and six months from now — while still giving you a deliberate, reviewable path to upgrade. This page covers the version argument, semantic version constraints, Git tag pinning, and a safe upgrade workflow. Everything here applies equally to OpenTofu, which shares Terraform’s module sourcing and version-resolution behavior.
The version argument
The version argument is only valid for modules sourced from a registry (the public Terraform Registry, a private registry, or the Terraform Cloud/Enterprise registry). The version is resolved against the constraint at init time and recorded in the dependency lock file.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}
When you run init, Terraform reports exactly which version satisfied the constraint:
Output:
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.8.1 for vpc...
- vpc in .terraform/modules/vpc
Terraform has been successfully initialized!
Module versions are not stored in
.terraform.lock.hcl— that file only locks provider versions. To lock module versions you must pin them withversionconstraints (or commit hashes for Git sources) in your configuration.
Semantic versioning and constraint operators
Registry modules follow semantic versioning: MAJOR.MINOR.PATCH. A MAJOR bump signals breaking changes, MINOR adds backwards-compatible features, and PATCH is for bug fixes. Constraint operators let you allow safe automatic upgrades while blocking risky ones.
| Constraint | Meaning | Allows |
|---|---|---|
= 5.8.1 or 5.8.1 | Exact version | 5.8.1 only |
>= 5.8.0 | Minimum version | 5.8.0 and any later |
~> 5.8 | Pessimistic, minor-flexible | >= 5.8.0, < 6.0.0 |
~> 5.8.1 | Pessimistic, patch-flexible | >= 5.8.1, < 5.9.0 |
>= 5.0, < 6.0 | Explicit range | any 5.x |
!= 5.8.2 | Exclude a version | anything except 5.8.2 |
The pessimistic constraint ~> is the most common choice. ~> 5.8 accepts new minor and patch releases but refuses the next major version, so you get bug fixes automatically while breaking changes stay opt-in.
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = "~> 6.10" # any 6.x >= 6.10, never 7.0
identifier = "app-prod"
engine = "postgres"
engine_version = "16.4"
instance_class = "db.t3.medium"
allocated_storage = 50
}
Pinning Git-sourced modules
Modules sourced directly from Git do not support the version argument. Instead you pin to a tag, branch, or commit with the ref query parameter. Always pin to an immutable reference — a tag or a full commit SHA — for reproducibility. Pinning to a branch like main means your build can change without your code changing.
# Pin to a release tag (recommended for shared modules)
module "network" {
source = "git::https://github.com/acme/tf-modules.git//network?ref=v2.3.0"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
}
# Pin to an exact commit (maximum reproducibility)
module "iam" {
source = "git::https://github.com/acme/tf-modules.git//iam?ref=8c4e2f1a9b7d3e6f0a1c2b3d4e5f6a7b8c9d0e1f"
role_name = "deploy"
}
Avoid
?ref=mainor omittingrefentirely in anything beyond a quick experiment. An unpinned Git source re-resolves the branch head on everyinit -upgradeand can introduce untested changes into production with no diff in your own repository.
Upgrading versions safely
Version constraints only take effect when Terraform is allowed to re-resolve them. A normal init reuses what is already downloaded; you must pass -upgrade to pick up newer versions that satisfy your constraints.
terraform init -upgrade
terraform plan -out=tfplan
Output:
Upgrading modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.13.0 for vpc...
- vpc in .terraform/modules/vpc
Plan: 2 to add, 1 to change, 0 to destroy.
A disciplined upgrade flow for a major bump looks like this:
- Read the module’s changelog and migration notes for the target major version.
- Update the constraint in code, e.g.
~> 5.8becomes~> 6.0. - Run
terraform init -upgradeto pull the new version. - Run
terraform planand review every change — major bumps may move resources, requiringmovedblocks orterraform state mv. - Apply in a non-production workspace first, then promote.
To see which versions you currently have resolved, use:
terraform version
terraform providers # also lists modules and their resolved versions
Best Practices
- Always set an explicit
versionconstraint (orreffor Git sources) on every module — never rely on “latest”. - Prefer
~>so you receive bug fixes and features automatically but require an explicit code change to cross a major version. - Pin Git modules to release tags or full commit SHAs; never to a mutable branch in production.
- Treat module upgrades like dependency upgrades: read the changelog, bump in a PR, and review the resulting plan before applying.
- Upgrade only one major dependency at a time so a misbehaving plan is easy to attribute.
- Run upgrades in a staging or sandbox workspace before promoting the new version to production.
- Commit the upgraded constraint and the reviewed plan together so the change is auditable.