Pinning your Terraform providers is crucial for reproducible infrastructure deployments.
Let’s see how Terraform uses providers. Imagine this simple Terraform configuration:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "4.12.0" # Example version
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0" # Example AMI
instance_type = "t2.micro"
}
When you run terraform init, Terraform downloads the hashicorp/aws provider with the exact version specified (4.12.0 in this case) into a local .terraform directory. This ensures that subsequent terraform plan and terraform apply commands use the same provider logic, preventing unexpected changes due to provider updates.
The required_providers block in your Terraform configuration is where you declare the providers your configuration needs and, critically, their versions.
terraform {
required_providers {
# Provider alias (e.g., "aws")
# source: The registry path (usually "namespace/name")
# version: The specific version or version constraint
google = {
source = "hashicorp/google"
version = "~> 4.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "3.20.0"
}
}
}
In this example, we’re requiring two providers: hashicorp/google and hashicorp/azurerm. For google, we’ve specified a version constraint ~> 4.0, which means any version from 4.0.0 up to, but not including, 5.0.0. For azurerm, we’ve pinned it to the exact version 3.20.0.
The "source" attribute tells Terraform where to find the provider. hashicorp/aws means the provider named "aws" published by the "hashicorp" organization in the official Terraform Registry. If you’re using a private registry, the source would look different, for example, my-corp.com/my-team/my-provider.
The most surprising truth about provider versioning is that not pinning a version at all is the default and the most dangerous practice. If you omit the version attribute, Terraform will always try to download the latest available version of the provider during terraform init. This can lead to your infrastructure breaking unexpectedly because a new provider version might introduce breaking changes or deprecate resources you’re using, even if you haven’t changed your .tf files.
Here’s a breakdown of version constraints:
= 3.20.0: Pin to exactly this version.!= 3.20.0: Exclude this specific version.> 3.20.0: Greater than this version.>= 3.20.0: Greater than or equal to this version.< 3.20.0: Less than this version.<= 3.20.0: Less than or equal to this version.~> 4.0.0: Pessimistic constraint, allows patch versions. Equivalent to>= 4.0.0and< 4.1.0.~> 4.0: Allows minor versions. Equivalent to>= 4.0.0and< 5.0.0.~> 4.12: Allows patch versions. Equivalent to>= 4.12.0and< 4.13.0.
For maximum reproducibility, pinning to an exact version (= 3.20.0) is generally recommended for production environments. For development or when you want to allow minor updates, a pessimistic constraint (~> 4.12.0) is a good balance.
When you run terraform init, Terraform checks your required_providers block. If the specified providers (and versions) are not already downloaded into your .terraform.lock.hcl file (or the .terraform directory), it will download them from their respective sources. The .terraform.lock.hcl file is crucial; it records the exact versions of providers that were downloaded, ensuring that future terraform init runs will use those locked versions, even if newer ones are available. This is the core mechanism that makes your deployments reproducible.
The most common mistake is not updating the required_providers block when a provider is first introduced or when you start using a new provider. If you have a configuration that was created before you explicitly declared required_providers, Terraform might still download a provider, but it won’t be locked. You must explicitly define it in required_providers to manage its lifecycle.
The terraform providers command is your friend here. Running terraform providers after terraform init will show you the providers that Terraform has discovered and is using for your configuration, including their versions. This is a quick way to verify that the correct providers are being loaded.
You might also encounter situations where a provider has a transitive dependency on another provider (e.g., a custom provider that wraps the AWS provider). In such cases, you’ll need to declare both the custom provider and the underlying provider (like aws) in your required_providers block.
The next concept you’ll want to master is managing provider configurations for different environments, such as development, staging, and production, often involving different regions or credentials.