Terraform Fundamentals

Providers & Versioning

18 min Lesson 3 of 30

Providers & Versioning

A Terraform provider is a plugin that translates Terraform resource declarations into real API calls against a cloud platform, SaaS service, or on-premises system. Every resource and data block you write belongs to exactly one provider. Understanding how providers are discovered, downloaded, pinned, and locked is not optional knowledge — it is the single most common source of "works on my laptop, breaks in CI" failures on production Terraform codebases.

How Providers Work

Terraform itself has no built-in understanding of AWS, GCP, Azure, or Kubernetes. It is a generic engine that understands the HashiCorp Configuration Language (HCL) and a state file. Providers are independently versioned Go binaries distributed through the Terraform Registry at registry.terraform.io. When you run terraform init, Terraform reads your required_providers block, resolves version constraints, downloads matching binaries into the .terraform/providers/ directory, and writes a lock file.

Providers have a three-part address: <hostname>/<namespace>/<type>. The default hostname is registry.terraform.io, so hashicorp/aws is shorthand for registry.terraform.io/hashicorp/aws. Third-party providers (e.g. grafana/grafana, cloudflare/cloudflare) follow the same pattern but with their own namespace.

The required_providers Block

Provider requirements live in a terraform block inside any .tf file — conventionally versions.tf. Declaring providers explicitly here (rather than relying on implicit detection) is mandatory for reproducible builds:

# versions.tf — explicit provider requirements terraform { required_version = ">= 1.9, < 2.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.50" } kubernetes = { source = "hashicorp/kubernetes" version = ">= 2.30, < 3.0" } datadog = { source = "DataDog/datadog" version = "~> 3.39" } } }
Key idea: The required_providers block is the contract between your configuration and the outside world. It tells Terraform where to find each provider binary and which versions are acceptable. Omitting it means Terraform guesses — and on a different machine with a different Terraform cache it may resolve a different version, silently producing different infrastructure.

Version Constraint Syntax

Terraform uses a constraint syntax borrowed from semantic versioning. Choosing the right operator matters enormously in practice:

  • = 5.50.0 — exact pin. Maximum reproducibility, zero flexibility. Rarely used; makes routine upgrades painful.
  • != 5.48.0 — exclude a specific version. Used to blacklist a known-bad release while still accepting future patches.
  • >= 5.0, < 6.0 — range. Accepts any 5.x release. Useful when you know a major version boundary introduces breaking changes.
  • ~> 5.50 — pessimistic constraint operator (tilde-arrow). Allows rightmost version component to increment freely. ~> 5.50 means >= 5.50, < 6.0. ~> 5.50.0 means >= 5.50.0, < 5.51.0. This is the operator used at top-tier companies for most providers.
Pro practice — the two-level pin: For the Terraform binary itself use a range like >= 1.9, < 2.0 to allow patch upgrades without accidentally crossing a major version boundary. For providers, use the tilde-arrow at the minor level (~> 5.50) to accept patch fixes automatically while staying on a tested minor line. Commit the lock file so CI always runs the exact same binary regardless of what the registry returns.

Configuring a Provider

Declaring a provider in required_providers tells Terraform which binary to download. Configuring it tells the provider how to authenticate and what defaults to apply. Provider configuration lives in a provider block:

# main.tf — provider configuration provider "aws" { region = var.aws_region # Credentials are NOT hardcoded here. # In CI, set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment vars. # On a local workstation with aws-vault or aws sso login, the SDK picks # up the standard credentials chain automatically. default_tags { tags = { ManagedBy = "terraform" Environment = var.environment Team = "platform" CostCenter = var.cost_center } } } provider "kubernetes" { host = module.eks.cluster_endpoint cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data) exec { api_version = "client.authentication.k8s.io/v1beta1" command = "aws" args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name] } }
Production pitfall — never put credentials in provider blocks. Hardcoding access_key and secret_key directly in HCL means those secrets end up in your git history, in your state file, and in any plan output uploaded to CI logs. Always use environment variables (AWS_ACCESS_KEY_ID), instance profiles (on EC2/ECS), IRSA (on EKS), or a secrets manager integration. Treat the provider block as config-only, never as a credentials store.

Provider Aliases — Multiple Configurations of the Same Provider

Sometimes you need to deploy resources into multiple AWS regions or multiple accounts in a single configuration. Provider aliases allow this. You declare a primary provider and one or more aliased providers, then reference them explicitly on resources that need the non-default configuration:

# Multi-region example: primary us-east-1 + DR region eu-west-1 provider "aws" { region = "us-east-1" } provider "aws" { alias = "eu" region = "eu-west-1" } # This bucket lands in eu-west-1: resource "aws_s3_bucket" "dr_bucket" { provider = aws.eu bucket = "acme-dr-bucket-eu-west-1" } # This bucket lands in us-east-1 (default provider): resource "aws_s3_bucket" "primary_bucket" { bucket = "acme-primary-bucket-us-east-1" }

The Dependency Lock File

When terraform init resolves providers for the first time it creates (or updates) .terraform.lock.hcl. This file records the exact provider version selected and its cryptographic hashes for every supported platform. It is the Terraform equivalent of package-lock.json or Pipfile.lock.

Terraform Provider Resolution Flow terraform init reads *.tf files versions.tf required_providers reads .terraform.lock.hcl exact version + hashes writes Terraform Registry registry.terraform.io resolves version .terraform/providers/ local binary cache downloads verifies hash CI Runner uses lock → reproducible same binary
Provider resolution flow: terraform init reads version constraints, queries the Registry, downloads the binary, and writes hashes to the lock file — which CI uses to guarantee the exact same provider runs in every environment.

A lock file entry looks like this:

# .terraform.lock.hcl — commit this file into git provider "registry.terraform.io/hashicorp/aws" { version = "5.54.1" constraints = "~> 5.50" hashes = [ "h1:AbCdEf1234...==", # zh: hash for the current platform (darwin_arm64, linux_amd64, etc.) "zh:1234abcd...", # Additional platform hashes pre-populated with -platform flags "zh:5678efgh...", ] }

The h1: prefix is a hash of the zip archive the binary lives in; zh: hashes cover individual binaries inside. When Terraform downloads a provider, it recomputes these hashes and refuses to proceed if they do not match — protecting you from supply-chain tampering.

Pro practice — pre-populate platform hashes for CI: By default the lock file only contains hashes for the platform where terraform init was run (e.g. darwin_arm64 on an Apple Silicon laptop). CI typically runs on linux_amd64. Run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 once and commit the result so CI does not need network access to the registry just to verify hashes.

Upgrading Providers Safely

Routine provider upgrades are a non-negotiable operational discipline. AWS alone ships provider releases every week; patch versions fix bugs and add resource types that mirror new AWS service features. The safe upgrade workflow is:

  • Run terraform init -upgrade to resolve the latest version permitted by your constraints and rewrite the lock file.
  • Run terraform plan and review the diff carefully — provider upgrades occasionally change defaults or deprecate arguments, producing unexpected resource changes.
  • Apply in a non-production environment first, then promote through your environment pipeline.
  • Commit the updated lock file as a dedicated commit so reviewers can see exactly which provider changed.
Production pitfall — terraform init -upgrade without the lock file in git. If your repository does not commit .terraform.lock.hcl, every engineer and every CI run resolves providers independently. On any given day the registry may return a new patch release. Two engineers working on the same PR may be running different provider versions. A silent API behavior difference between 5.50.0 and 5.54.1 will produce a plan diff that has nothing to do with their code change — and may silently recreate resources. Always commit the lock file.

Private and Mirror Registries

Large enterprises often cannot allow Terraform runners to reach the public registry. The solutions are:

  • Terraform Cloud / HCP Terraform: Has a built-in private registry for internal modules and a provider mirror capability.
  • Artifactory / Nexus provider mirror: Configure ~/.terraformrc (or a TF_CLI_CONFIG_FILE) with a provider_installation block to redirect resolution to your internal mirror before falling back to the public registry.
  • filesystem_mirror: For air-gapped environments, pre-download provider zips into a directory structure that Terraform understands and point to it with a filesystem_mirror block.

This lesson focuses on the public registry workflow — the details of enterprise mirror configuration are covered in the Advanced Terraform tutorial.

Anatomy of a Real versions.tf

At a company running a multi-account AWS setup with Kubernetes, Datadog, and Vault, a production versions.tf is a carefully managed artifact, not an afterthought:

# versions.tf — production-grade, multi-provider configuration terraform { required_version = ">= 1.9.0, < 2.0.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.50" } kubernetes = { source = "hashicorp/kubernetes" version = ">= 2.30, < 3.0" } helm = { source = "hashicorp/helm" version = "~> 2.13" } vault = { source = "hashicorp/vault" version = "~> 4.3" } datadog = { source = "DataDog/datadog" version = "~> 3.39" } } } # After editing this file, run: # terraform init -upgrade # terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 # Then commit both versions.tf AND .terraform.lock.hcl.

Keeping versions.tf as a standalone file (rather than burying required_providers inside main.tf) makes code review faster — a one-line version bump is immediately visible as its own change with a clear purpose.