Terraform

Terraform Module Versioning and Publishing: A Complete Guide

AGAnurag Gupta05 Apr 2025 · Updated 04 Oct 2026 · 8 min read
Terraform Module Versioning and Publishing: A Complete Guide

Quick answer: Version a Terraform module by tagging its Git repository with semantic versions (v1.4.2), and publish it either to the public Terraform Registry (free, for public GitHub repositories named terraform-<provider>-<name>) or to a private registry such as HCP Terraform. Consumers then pin a version constraint like ~> 1.4 so they receive bug fixes automatically but never a breaking change by surprise.

An unversioned module is a time bomb: one push to main and every environment that references it gets a different plan tomorrow. This guide covers semantic versioning as it applies to infrastructure, the exact version-constraint operators Terraform understands, how to publish to the public and private registries, how to automate releases, and the versioning mistakes that break consumers.

Why module versioning matters more in infrastructure

In application code, a bad library upgrade fails a unit test. In Terraform, a bad module upgrade can plan to destroy a production database. Because a module’s variables and outputs are an API, and because resource renames inside a module cause replacement, module authors must signal clearly which releases are safe to adopt without reading every diff.

Versioning also enables staged rollouts: upgrade dev to v3.0.0, watch it for a week, then bump staging and prod. Without versions, every environment moves at once. For the module layout the Registry expects, read Terraform Module Structure and Best Practices first; the Registry depends on that structure.

Semantic versioning for Terraform modules

Modules follow MAJOR.MINOR.PATCH, with a v prefix on Git tags (the Registry requires it). The rule of thumb for what each number means in Terraform terms:

Bump When to use it Terraform examples Consumer impact
MAJOR (2.x.x to 3.0.0) Breaking changes Removing or renaming a variable or output, changing a resource address so it would be recreated, raising the minimum Terraform or provider version Must read the upgrade guide and probably edit code
MINOR (2.3.x to 2.4.0) Backwards-compatible features Adding an optional variable with a default, adding an output, supporting a new resource behind a feature flag Safe to adopt; plan should show no changes unless the new feature is enabled
PATCH (2.3.1 to 2.3.2) Bug fixes, docs, refactors with no plan diff Fixing a wrong tag, correcting a validation message, tightening an IAM policy that was accidentally too open Safe to adopt

One subtlety: a security fix that tightens an IAM policy will produce a plan diff, yet it is still a PATCH because the module’s interface did not change. Document it clearly in the changelog so consumers understand why the plan is not empty.

Version constraints consumers can use

When calling a Registry module, the version argument accepts the same operators as required_providers:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.8"      # >= 5.8.0 and < 6.0.0: patches and minors, no majors

  name = "tkh-prod"
  cidr = "10.30.0.0/16"
}

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = ">= 20.0, < 20.20"   # explicit range

  cluster_name    = "tkh-prod"
  cluster_version = "1.30"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnets
}

module "internal_bucket" {
  # Git sources do not support `version`; pin with ?ref= instead
  source = "git::https://github.com/tkh-labs/terraform-aws-secure-bucket.git?ref=v1.4.2"

  bucket_name = "tkh-prod-artifacts"
}

These are the same operators used for provider constraints, which Terraform Core Concepts and Syntax introduces. Operator reference:

  • = 1.4.2 or 1.4.2 – exactly this version.
  • ~> 1.4 – pessimistic constraint: at least 1.4.0, below 2.0.0. The most common choice for production.
  • ~> 1.4.2 – at least 1.4.2, below 1.5.0. Allows only patches.
  • >= 1.4, < 2.0 – an explicit range; comma means AND.

Terraform resolves module versions during terraform init. Changing a constraint has no effect until you run terraform init -upgrade. Unlike providers, module versions are not recorded in .terraform.lock.hcl, which is why a tight constraint matters so much.

Publishing to the public Terraform Registry

The public Registry at registry.terraform.io is free and integrates directly with GitHub. Requirements:

  1. A public GitHub repository named terraform-<PROVIDER>-<NAME>, for example terraform-aws-secure-bucket. The provider segment must be a real provider; the name may contain hyphens.
  2. The standard module structure: main.tf, variables.tf, outputs.tf, and a README.md at the root. Submodules go under modules/, examples under examples/.
  3. At least one semantic version tag such as v1.0.0. Tags without the v prefix are not recognised.
  4. Sign in to the Registry with GitHub, choose Publish > Module, select the repository, and confirm. The Registry installs a webhook so every new tag becomes a new published version within minutes.

There is no manual review queue for modules; publishing is automatic once the structure and tag are valid. The Registry renders your README, lists inputs and outputs parsed from your code, and shows every version’s source commit.

Internal modules that cannot be public have three homes. The HCP Terraform or Terraform Enterprise private registry connects to your VCS, applies the same naming and tagging rules, and is consumed as source = "app.terraform.io/<org>/<name>/<provider>" with a version argument. Spacelift, env0, Scalr and GitLab’s module registry implement the same protocol. Or skip registries entirely and rely on Git tags with ?ref=v1.4.2: no rendered docs or version argument, but identical pinning guarantees, and where most small teams start.

Automating releases with CI

Manual tagging is error-prone. A simple GitHub Actions workflow can validate the module on every pull request and cut a release when a tag is pushed:

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ["v*.*.*"]

jobs:
  validate-and-release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.*"
      - run: terraform fmt -check -recursive
      - run: terraform init -backend=false && terraform validate
      - run: terraform test
      - uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true

Pair this with a CHANGELOG.md that follows the Keep a Changelog format, or let a tool such as release-please or semantic-release compute the next version from Conventional Commits (feat: bumps MINOR, fix: bumps PATCH, feat!: bumps MAJOR). The less human judgement in the version number, the more consumers can trust it.

Shipping a breaking change responsibly

Sometimes a MAJOR bump is unavoidable. Make the upgrade painless:

# Inside the module, v3.0.0: renamed resource without forcing recreation
moved {
  from = aws_s3_bucket.bucket
  to   = aws_s3_bucket.this
}

# Deprecate instead of delete where you can
variable "enable_logging" {
  type        = bool
  default     = null
  description = "DEPRECATED: use `logging` object instead. Removed in v4."
}

moved blocks inside a module (Terraform 1.1+) let you rename resources without consumers seeing a destroy-and-create. Write an UPGRADE-3.0.md that lists every removed variable and its replacement, and keep the previous major on a maintenance branch for security patches for a few months.

Six versioning and publishing mistakes

  1. Consumers referencing main. Every module source should carry a version or ?ref=. Enforce this with a policy tool such as tflint or OPA in CI.
  2. Moving or deleting a tag. Published versions are immutable. If v1.4.2 is broken, release v1.4.3; never re-point the tag.
  3. Calling a breaking change a MINOR. Renaming an output and shipping it as 2.4.0 breaks everyone on ~> 2.0. When in doubt, bump MAJOR.
  4. Missing the v prefix or the repository naming pattern. The Registry will silently ignore the repository or the tag.
  5. Pinning to an exact version everywhere. = 1.4.2 on fifty modules means fifty pull requests for one security patch. Use ~> and let PATCH releases flow.
  6. Forgetting init -upgrade. Bumping the constraint in code changes nothing until Terraform re-resolves modules. CI should always run terraform init -upgrade.

Frequently asked questions

Can I publish a private GitHub repository to the public Registry?

No. The public Registry requires a public repository. Use a private registry or Git tags for internal modules.

Do module versions get locked in .terraform.lock.hcl?

No. The lock file records provider versions only. The selected module version is written to .terraform/modules/modules.json, which is not committed. Tight constraints are your only protection.

How do I version submodules in a monorepo?

The Registry versions the whole repository, so all submodules under modules/ share one version number. If two modules need independent release cycles, give them separate repositories.

Key takeaways

  • Tag modules with vMAJOR.MINOR.PATCH; treat variables, outputs and resource addresses as the public API that decides the bump.
  • Consumers should pin with ~> on Registry modules and ?ref= on Git sources, then run init -upgrade deliberately.
  • Publishing to the public Registry needs a public GitHub repository, the standard structure and a v-prefixed tag; private registries follow the same rules.
  • Automate validation and releases in CI, and use moved blocks plus an upgrade guide for breaking changes.

If you want to practise the full module lifecycle, from first commit to a versioned release consumed by a CI/CD pipeline, our DevOps course covers Terraform, GitHub Actions and cloud deployment with mentor support and placement assistance. For short video explainers, follow our YouTube channel.

AG
Written byAnurag Gupta

Part of the Techknowledgehub team of industry mentors, writing practical guides to help you build a job-ready tech career.

More articles by Anurag Gupta →
Keep reading

Related articles

Leave a Reply