Skip to content
Infrastructure as Code iac modules 4 min read

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 with version constraints (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.

ConstraintMeaningAllows
= 5.8.1 or 5.8.1Exact version5.8.1 only
>= 5.8.0Minimum version5.8.0 and any later
~> 5.8Pessimistic, minor-flexible>= 5.8.0, < 6.0.0
~> 5.8.1Pessimistic, patch-flexible>= 5.8.1, < 5.9.0
>= 5.0, < 6.0Explicit rangeany 5.x
!= 5.8.2Exclude a versionanything 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=main or omitting ref entirely in anything beyond a quick experiment. An unpinned Git source re-resolves the branch head on every init -upgrade and 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:

  1. Read the module’s changelog and migration notes for the target major version.
  2. Update the constraint in code, e.g. ~> 5.8 becomes ~> 6.0.
  3. Run terraform init -upgrade to pull the new version.
  4. Run terraform plan and review every change — major bumps may move resources, requiring moved blocks or terraform state mv.
  5. 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 version constraint (or ref for 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.
Last updated June 14, 2026
Was this helpful?