StackPractices
intermediate By Mathias Paulenko

Terraform Module Versioning Policy Template

A ready-to-adapt policy for versioning and publishing Terraform modules: semver rules, breaking-change and deprecation workflows, changelog format, registry publishing, and CI/CD release pipelines.

Topics: devops

Overview

A policy your team can copy and adapt for versioning Terraform modules: semantic versioning rules, breaking-change and deprecation workflows, changelog requirements, registry publishing, and CI/CD release pipelines. Use it as-is or strip out the sections your setup doesn’t need — it works for the public Terraform Registry, private registries on Terraform Cloud/Enterprise, and plain git-based sources.


1. Semantic Versioning Rules

1.1 Version Format

Format: MAJOR.MINOR.PATCH (e.g., 2.3.1)

MAJOR (X.0.0) — Breaking changes
  - Variable renamed or removed
  - Resource type changed or removed
  - Output renamed or removed
  - Minimum Terraform version increased
  - Provider version constraint changed to incompatible

MINOR (0.X.0) — New features, backward compatible
  - New variable added (with default)
  - New resource added
  - New output added
  - New feature flag added

PATCH (0.0.X) — Bug fixes, backward compatible
  - Bug fix in resource configuration
  - Documentation update
  - Dependency version bump (compatible)
  - Example update

1.2 Pre-release Versions

1.0.0-alpha.1   — Early preview, expect breaking changes
1.0.0-beta.1    — Feature complete, testing in progress
1.0.0-rc.1      — Release candidate, final testing
1.0.0           — Stable release

1.3 Choosing the Bump

When you’re not sure which number to move, walk the public API of the module: inputs, outputs, and version constraints.

Decision tree to pick the semver bump — changes that break the public API get a major version, backward-compatible additions get a minor, and internal fixes get a patch.

2. Module Structure

2.1 Required Files

modules/
└── my-module/
    ├── main.tf          # Resource definitions
    ├── variables.tf     # Input variables
    ├── outputs.tf       # Output values
    ├── versions.tf      # Terraform and provider version constraints
    ├── README.md        # Module documentation
    ├── CHANGELOG.md     # Version history
    ├── examples/        # Usage examples
    │   └── basic/
    │       ├── main.tf
    │       └── README.md
    └── tests/
        └── basic_test.go  # Terratest or kitchen-terraform

2.2 versions.tf — Version Constraints

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 6.0"
    }
  }
}

2.3 README.md Template

# my-module

Short description of what this module does.

## Usage

    module "my_module" {
      source  = "myorg/my-module/aws"
      version = "2.3.1"

      name = "my-app"
      environment = "production"
    }

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| name | Resource name | string | n/a | yes |
| environment | Environment name | string | "production" | no |

## Outputs

| Name | Description |
|------|-------------|
| id | Resource ID |
| arn | Resource ARN |

3. Breaking Change Management

Breaking changes are inevitable in shared modules — what matters is that consumers see them coming. This section defines what has to ship alongside every breaking release.

3.1 Breaking Change Checklist

- [ ] Bump MAJOR version
- [ ] Document breaking change in CHANGELOG.md
- [ ] Provide migration guide in CHANGELOG.md
- [ ] Add deprecation notice in previous MINOR version
- [ ] Update all examples to use new API
- [ ] Run tests against new version
- [ ] Notify consuming teams via Slack/email
- [ ] Set deprecation date for old version (minimum 90 days)
- [ ] Create issue tracker for migration progress
- [ ] Update module documentation

3.2 Deprecation Pattern

variable "instance_type" {
  description = "(Deprecated) Use `instance_types` instead. Will be removed in v3.0.0."
  type        = string
  default     = null
  deprecated  = true
}

variable "instance_types" {
  description = "List of instance types for the auto-scaling group"
  type        = list(string)
  default     = ["t3.micro"]
}

3.3 Migration Guide Template

## Migration Guide: v1.x to v2.x

### Breaking Changes

1. `instance_type` variable removed → use `instance_types` (list)
2. `tags` output changed from `map(string)` to `map(any)`
3. Minimum Terraform version raised from 1.0 to 1.5

### Migration Steps

1. Update module version in your configuration:

       module "my_module" {
         source  = "myorg/my-module/aws"
         version = "2.0.0"
       }

2. Replace `instance_type` with `instance_types`:

       # Before (v1.x)
       module "my_module" {
         instance_type = "t3.micro"
       }

       # After (v2.x)
       module "my_module" {
         instance_types = ["t3.micro"]
       }

3. Run `terraform init -upgrade` to download the new version
4. Run `terraform plan` and review changes
5. Run `terraform apply` when ready

4. CHANGELOG Requirements

4.1 CHANGELOG Format

# Changelog

## [2.3.1] - 2026-07-04

### Fixed
- Fixed IAM policy when `enable_logging` is false
- Fixed panic when `tags` is empty map

## [2.3.0] - 2026-06-15

### Added
- New variable `enable_monitoring` (default: false)
- New output `monitoring_dashboard_url`

### Changed
- Updated AWS provider constraint to >= 5.0, < 6.0

## [2.2.0] - 2026-05-01

### Added
- Support for `kms_key_id` in S3 bucket module

### Deprecated
- `instance_type` variable deprecated, use `instance_types`

## [2.0.0] - 2026-03-01

### Breaking
- `instance_type` removed, replaced with `instance_types` (list)
- `tags` output type changed from `map(string)` to `map(any)`
- Minimum Terraform version raised to 1.5

5. Module Registry Publishing

Versioning only becomes real when consumers can fetch what you tagged. This section covers the three publishing channels — pick one per module and stay consistent.

5.1 Terraform Registry (Public)

# Tag the release in git
git tag v2.3.1
git push origin v2.3.1

# Terraform Registry auto-detects tags from GitHub
# Module URL: terraform-aws-modules/s3-bucket/aws
# Version: 2.3.1

5.2 Private Registry (Terraform Cloud/Enterprise)

# Configure Terraform CLI for private registry
terraform login app.terraform.io

# Use module from private registry
module "my_module" {
  source  = "app.terraform.io/myorg/my-module/aws"
  version = "2.3.1"
}

5.3 Git-based Module Source

# Tag-based versioning
module "my_module" {
  source = "git::https://github.com/myorg/terraform-modules.git//my-module?ref=v2.3.1"
}

# Branch-based (not recommended for production)
module "my_module" {
  source = "git::https://github.com/myorg/terraform-modules.git//my-module?ref=main"
}

6. CI/CD Pipeline

6.1 GitHub Actions — Module Release Pipeline

name: Terraform Module Release
on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - name: Terraform format check
        run: terraform fmt -check -recursive

      - name: Terraform validate
        run: |
          terraform init -backend=false
          terraform validate

      - name: Run Terratest
        run: |
          cd tests/
          go test -v -timeout 30m

      - name: Generate docs
        uses: terraform-docs/terraform-docs@v1.0.0
        with:
          output-file: README.md
          output-method: inject

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v1
        with:
          generate_release_notes: true
          body_path: CHANGELOG.md

      - name: Notify teams
        run: |
          curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d "{
            \"text\": \"Terraform module ${{ github.ref_name }} released\"
          }"

6.2 Pre-Release Validation

name: Terraform Module Validation
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        terraform_version: ['1.5.0', '1.6.0', '1.7.0']
    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ matrix.terraform_version }}

      - name: Format check
        run: terraform fmt -check -recursive

      - name: Init
        run: terraform init -backend=false

      - name: Validate
        run: terraform validate

      - name: Security scan (tfsec)
        uses: aquasecurity/tfsec-action@v1.0.0

      - name: Cost estimation (infracost)
        uses: infracost/infracost-action@v3
        with:
          path: examples/basic

7. Version Pinning in Consuming Projects

The publishing rules cover half the contract — the other half is how consumers pin your module. These are the recommended defaults that keep upgrades boring.

7.1 Pinning Strategies

Strategy              | Example              | Risk
──────────────────────┼──────────────────────┼──────────────────────────
Exact pin             | version = "2.3.1"   | No auto-updates, manual
Pessimistic constraint| version = "~> 2.3"  | Auto patch + minor updates
Pessimistic major     | version = "~> 2.0"  | Auto minor updates only
Range                 | version = ">= 2.0"  | Risky, may get breaking
Latest                | (no version)        | Very risky, not recommended
# Production — exact pin
module "production_db" {
  source  = "terraform-aws-modules/rds/aws"
  version = "6.10.0"
}

# Staging — pessimistic constraint (auto patch updates)
module "staging_db" {
  source  = "terraform-aws-modules/rds/aws"
  version = "~> 6.10"
}

# Development — pessimistic major (auto minor + patch)
module "dev_db" {
  source  = "terraform-aws-modules/rds/aws"
  version = "~> 6.0"
}

See Also

External references:

Runnable versions of this policy live in the companion repository.

Frequently Asked Questions

How do I know when to bump the major version?

Bump major whenever consumers have to touch their own configuration to adopt your change. Typical triggers:

  • A variable or output is removed or renamed
  • A variable changes type (string → list, for example)
  • A default changes in a way that produces different resources
  • The minimum Terraform or provider version goes up

When in doubt, bump major — over-communicating a break is far cheaper than silently breaking someone's terraform apply. And soften the landing: mark the old interface deprecated in the current minor version before removing it in the next major.

Should I use the Terraform Registry or git sources for internal modules?

Use the private registry if you've got Terraform Cloud/Enterprise — you get version management, a browsable UI, and workspace integration for free.

If you don't, git sources with tag refs are the fallback: git::https://github.com/myorg/modules.git//my-module?ref=v2.3.1. Two rules for that path:

  • Always pin a tag (?ref=v2.3.1) — branch refs like ?ref=main can change under your feet and aren't reproducible.
  • Once you pass ~5 shared modules or ~10 consuming teams, the registry pays for itself; git-based distribution doesn't scale organizationally.
How do I deprecate a module feature without breaking existing consumers?

Run a deprecation window instead of a hard cut:

  1. Mark the variable or output deprecated and point to the replacement in the description.
  2. Announce it in the README, the CHANGELOG, and a message to consuming teams.
  3. Keep the old behavior working alongside the new one for at least one minor version.
  4. Give a deprecation date — 90 days minimum — and track migrations in your issue tracker.
  5. Ship the removal in the next major release, with a migration guide that shows before/after.
What should I do if a module release has a bug?

It depends on severity. If the bug breaks infrastructure or loses data, act like it's an incident:

  • You can't delete a published registry version, but you can flag it in the README and notify consuming teams immediately.
  • Cut a patch release (e.g., 2.3.2) with the fix and update the CHANGELOG.
  • Non-critical bug? Patch release plus changelog entry is enough.

One rule holds either way: never force-push a tag. Consumers who already pulled it keep the old content while new downloads get the new one — the same version number then means two different things, which is worse than the bug.

How do I test a module before publishing?

Automate it in CI — the pipeline in section 6 is the reference setup:

  • Write Terratest or kitchen-terraform tests that deploy the module in a throwaway cloud account, verify resources, and destroy everything.
  • Run tests on every PR against a matrix of Terraform versions (and provider versions if you support a range).
  • Add terraform fmt -check, terraform validate, tfsec/checkov for security, and infracost for cost visibility.
  • Publish only after the PR is green, reviewed, and merged.