Module Versioning

Terraform module versioning is a mechanism for managing changes and ensuring consistency of infrastructure deployments by assigning unique version numbers to modules.

Modules have three different dependencies that could/should be version controlled to ensure that the code you run today will run in six months from now regardless as to if any of the three dependencies have since changed. Upgrading a dependency should be a deliberate and tested process and not just because time as elapsed and new version have been released. By not versioning your dependencies you crate brittle code which will break and cause bigger issues.

The three dependencies are:

Terraform binary

Provider

Module

Terraform binary

You can pin the version of what Terraform binary (the Terraform file on the machine running the TF code) needs to be used for the code as per the example below. This pins the version to anything with a major release value of 1 but nothing else. As Terraform major releases are not backward compatible it’s important to block your code from being allowed to run a different major release other than the one you know works.

terraform {
  required_version = ">= 1.0.0, < 2.0.0"
}

If you want to be really specific then you can pin a major.minor.patch version number as below:

terraform {
  required_version = "1.2.3"
}

Provider

Terraform uses providers to interact with various services such as AWS, Google Cloud, Azure, etc. Each provider is versioned and it’s essential to control these versions in your Terraform code. This is done through the use of provider version constraints in your configuration.

In Terraform, you declare providers in your configuration files along with their versions. The version argument is optional, but it’s a best practice to declare it in your configuration. This allows you to lock your configuration to a specific version or range of versions of the provider.

This is an example of declaring a provider with a version:

terraform {
  required_version = ">= 1.0.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

~>: Specifies an approximate version constraint. For example, “~> 3.0” allows any version in the 3.x range, excluding 4.0 and above, and “~> 3.0.0” would include versions in the 3.0.x range, excluding 3.1 and above.

The reason for version control in providers is that different versions of a provider can have different sets of available resources, different resource arguments, and even different behaviors. This means that different versions can cause your Terraform code to behave differently. By specifying a specific version or version range, you can avoid issues caused by these differences.

The current version of each provider can be found on the provider’s page on the Hashicorp website. At time of writing the latest version of the AWS provider is 5.3.0 which can also be viewed on the GitHub repository.

AWS Provider GitHub repo

Azure Provider GitHub repo

Module

If your production and staging resources share the same reusable module code, then it would be helpful if you could treat your module code like software code and attach version numbers to it. That means you can pin your deployed resources at the current latest version known as “good”.

Then later, when that reusable module is changed, the version number increments. You can then update the reusable module’s version number in the staging root module and deploy the changes to ensure all is well. In contrast, the production root module remains linked to the original latest release, which is “good.”

Once you have tested the new reusable module in staging, you can update the production root module to use the latest release and deploy the changes.

Without this mechanism, your production deployment is always at risk of uncontrolled changes if someone changes the reusable module code and someone else runs a terraform init & apply on the production root module, as those changes would be downloaded and run.

module "ec2_instance" {
  source = "git@github.com:grinntec/modules.git//aws/ec2?ref=0.1.2"
}

Define a version strategy

Use your VCS, such as GitHub, to version your modules. You cannot do it in the native Terraform files without this type of system managing the metadata. This would mean you would point your GitHub root module at the VCS target URL instead of using relative paths to locate the reusable module library in your local filesystem.

It would help to consider how to use tags, as they can quickly become complicated and prone to error unless you plan. There are two methods to deploy tags; at the folder level or the repository. Both have advantages and disadvantages, and you should fully appreciate the differences when planning your versioning strategy.

Repository-level tagging offers benefits like easier version control, clear dependencies, and straightforward release management. Folder-level tagging can save on storage and cloning time and keep all modules in a single repository, but it comes with more complex version management and potential difficulties in scaling.

FeatureRepository-Level TaggingFolder-Level Tagging
GranularityCoarse: Tags apply to the entire repository.Fine: Tags can be applied to specific folders within the repository.
Versioning ControlEasier to manage and track version changes across multiple modules.More complex version management when dealing with multiple modules in the same repository.
Repository OrganizationEach module has its own repository, making it easy to manage individual components.All modules are stored in a single repository, which can lead to clutter and difficulty in managing version changes.
Dependency ManagementClear dependencies between modules, as each module version is tied to a specific repository tag.Dependencies between modules can become more complex, as different folders may have different tags.
CollaborationEach module’s repository can have its own access controls, making it easier to manage permissions.Access controls must be managed at the repository level, potentially making collaboration more difficult.
Release ManagementReleases are more straightforward, as each repository represents a single module version.Releases can be more complex, as multiple modules with different versions may be in the same repository.
Rollback and RecoveryEasier to rollback or recover from issues, as each module version is tied to a specific repository tag.More complex rollback or recovery process, as it may involve rolling back changes to multiple folders.
Continuous IntegrationCI pipelines can be set up per repository, allowing for more targeted testing and deployment.CI pipelines may need to be more complex to account for the different folders and versions within a single repository.
Storage and CloningRequires more storage and cloning time, as each module has its own repository.Less storage and cloning time required, as all modules are stored in a single repository.
ScalabilityEasier to scale, as each module can be managed independently in its own repository.May become more difficult to scale, as the number of modules and versions in a single repository increases.

How tags are used in code

If you use tags then you are using a VCS which means you need to use a URL to target the reusable code folder instead of relative paths.

Example showing relative path in a root module configuration

module "ec2-instances" {
  source = "../../../../../../modules/ec2-instance"
  
  # ...
}

Example showing GitHub URL in a root module configuration

module "ec2-instances" {
  source = "git@github.com:<OWNER>/<REPO>.git//<PATH>?ref=<VERSION>
  
  # ...
}

Version numbers

A standard way of versioning code is to use semantic versioning. This uses major, minor, and patch values to show what has changed in the code based on the version number increment.

When you start a new project and adopt semantic versioning, beginning with version 0.1.0 is standard. Once the project reaches a stable state with a well-defined API or functionality, you can release the first stable version as 1.0.0. From that point forward, you would increment the major, minor, or patch version numbers according to semantic versioning rules.

Major significant changes that could break backward-compatibility

Minor adds functionality which are backward-compatible with previous major versions

Patch adds bug fixes which are backward-compatible with previous major releases

Version constraint operators

Version pinning in Terraform allows you to specify the exact version or a range of versions that can be used. This can be very useful to avoid unexpected behaviors or issues when newer versions of Terraform or Terraform providers are released.

The symbols =, <, >, <=, >=, and ~> are used to specify versions:

  • =: This symbol is used to specify an exact version. For example, required_version = "= 0.12.0" would mean that only Terraform version 0.12.0 can be used.

  • <: This symbol is used to specify any version less than the given version. For example, required_version = "< 0.12.0" would mean any Terraform version less than 0.12.0 can be used.

  • >: This symbol is used to specify any version greater than the given version. For example, required_version = "> 0.12.0" would mean any Terraform version greater than 0.12.0 can be used.

  • <=: This symbol is used to specify any version less than or equal to the given version. For example, required_version = "<= 0.12.0" would mean Terraform version 0.12.0 and any version less than 0.12.0 can be used.

  • >=: This symbol is used to specify any version greater than or equal to the given version. For example, required_version = ">= 0.12.0" would mean Terraform version 0.12.0 and any version greater than 0.12.0 can be used.

  • ~>: This is called the “pessimistic version constraint” and is used to specify any version up to the next major release. For example, required_version = "~> 0.12.0" would mean any Terraform version from 0.12.0 to less than 0.13.0 can be used. If you specify ~> 0.12, it would allow versions from 0.12.0 to less than 1.0.0.

Here is an example of how you can specify the required version in your Terraform configuration:

terraform {
  required_version = ">= 0.12.0"
}

In this case, it means that your configuration is compatible with Terraform version 0.12.0 and any version greater than 0.12.0. Always refer to the latest Terraform documentation for the most current and accurate information.

Last modified July 21, 2024: update (e2ae86c)