OnCallReady

Lesson 12.3 · Terraform: Language & Workflow · 32 min read

Providers, versions and the lock file

In plain words

Terraform is like a universal remote control that does not know any TV brand by itself. For each brand you plug in a little adapter: one for Azure, one for GitHub, one that just rolls dice. Each adapter speaks its own brand's language. The lock file is a sticker saying exactly which adapter model you used, so your friend's remote behaves the same way.

The adapters are providers (hashicorp/azurerm, hashicorp/random), declared in required_providers with a version constraint like ~> 4.14. terraform init downloads the newest version that fits and records it, with checksums, in .terraform.lock.hcl. After that, everyone gets that exact version until someone runs terraform init -upgrade on purpose.

Why this matters

What you need to know already: the block types and the workflow (12.1), environment variables (export, 6.1 and 2.21), and that an HTTP API is a web address programs send requests to (9.21).

Two engineers run the same Terraform code a week apart and get different plans. Nothing in the code changed - but a new version of the plugin that talks to the cloud came out in between, and one of them downloaded it. This lesson is about controlling exactly which plugin, which version, and with which credentials.

What a provider is

A provider is a plugin (a separate program Terraform starts) that knows one API. hashicorp/azurerm turns azurerm_subnet blocks into calls to Azure's management API (Azure Resource Manager, "ARM"). hashicorp/random generates values locally and calls nothing at all.

Terraform core downloads the provider binary during init, starts it as a child process (3.1) for every plan/apply, and talks to it over gRPC (a standard way for two programs to call each other; you never touch it).

Every provider has a source address, like a package name in npm:

registry.terraform.io / hashicorp / azurerm
      hostname           namespace    type

Providers come in three tiers on the registry:

official    published by HashiCorp          hashicorp/azurerm, hashicorp/random
partner     published by the vendor         datadog/datadog, cloudflare/cloudflare
community   anyone                           read the source before you trust it

Declaring providers: required_providers

This is the provider's entry in the terraform { } block - the equivalent of dependencies in a package.json.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.14"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

If you use a resource without declaring its provider, Terraform assumes hashicorp/<prefix>: an azurerm_resource_group with no required_providers still works because hashicorp/azurerm exists. That fallback is a convenience, not a practice - it gives you no version constraint at all, and it breaks for any provider outside the hashicorp namespace.

Configuring a provider

The provider block sets how the provider behaves and which account it works in.

provider "azurerm" {
  features {}
  subscription_id = "00000000-1111-2222-3333-444444444444"
}

Credentials never go in the provider block. The block ends up in git, and anything in git is readable by everyone with the repo. Instead, azurerm finds credentials itself, from environment variables. The options, from best to worst for automation:

OIDC / workload identity federation   CI jobs: short-lived token, no secret at all
managed identity                      code running inside Azure (a VM): Azure vouches for it
service principal + certificate       a robot account that proves itself with a certificate
service principal + client secret     a robot account with a password (ARM_CLIENT_SECRET)
Azure CLI (az login)                  your laptop only: uses your own login

In plain words:

Every one of those is configured through ARM_* environment variables or use_oidc = true / use_msi = true - never by writing a secret into a .tf file.

More than one configuration of the same provider: alias

One provider block per provider is the default. When you need two - two regions, two subscriptions - give the extra ones an alias (a second name):

provider "azurerm" {
  features {}
}

provider "azurerm" {
  alias = "dr"
  features {}
  subscription_id = var.dr_subscription_id
}

resource "azurerm_resource_group" "primary" {
  name     = "rg-orders-weu"
  location = "westeurope"
}

resource "azurerm_resource_group" "dr" {
  provider = azurerm.dr
  name     = "rg-orders-neu"
  location = "northeurope"
}

(dr = disaster recovery: a second copy in another region, for when the first region is down. var.dr_subscription_id is an input variable - 12.5.)

Later (Ch 13): reusable groups of resources (modules) receive providers from their caller with providers = { azurerm = azurerm.dr }, and never contain a provider block themselves.

Many providers in one configuration

This is what "multi-cloud / service-agnostic workflow" means in practice (exam objective 1c and 2c): one plan that spans several APIs, wired together by references.

resource "random_password" "db" {
  length  = 24
  special = true
}

resource "azurerm_key_vault_secret" "db" {
  name         = "db-password"
  value        = random_password.db.result
  key_vault_id = azurerm_key_vault.main.id
}

random generates the value, azurerm stores it. Terraform orders the two because one references the other; it does not care that they are different providers.

What init does with providers

terraform init prepares a directory: it downloads what the code needs. Run it first, and again whenever you add a provider or change a version.

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 4.14"...
- Installing hashicorp/azurerm v4.14.0...
- Installed hashicorp/azurerm v4.14.0 (signed by HashiCorp)
- Finding hashicorp/random versions matching "~> 3.6"...
- Installing hashicorp/random v3.6.3...
- Installed hashicorp/random v3.6.3 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Read it line by line:

  1. It collects every provider requirement - from your required_providers, and from resource types used without a declaration.
  2. It combines the constraints and asks the registry for the newest version that satisfies all of them ("Finding ... matching").
  3. It downloads the binary into .terraform/providers/registry.terraform.io/hashicorp/azurerm/4.14.0/linux_arm64/ and checks it was really published by HashiCorp ("signed by HashiCorp").
  4. It records the choice in .terraform.lock.hcl.
$ ls .terraform/providers/registry.terraform.io/hashicorp/azurerm/
4.14.0

On the next init (a teammate, or CI), the lock file wins:

- Reusing previous version of hashicorp/azurerm from the dependency lock file
- Using previously-installed hashicorp/azurerm v4.14.0

To save downloading the same 200 MB provider into every directory, set a plugin cache: plugin_cache_dir = "$HOME/.terraform.d/plugin-cache" in ~/.terraformrc (Terraform's settings file in your home directory), or the TF_PLUGIN_CACHE_DIR environment variable. Networks with no internet access use terraform providers mirror (copy providers to a folder) and a provider_installation block pointing at that folder. (The lab's provider source is such a mirror, carrying azurerm up to 4.14.0 - simulator.)

Version constraints

Versions look like 4.14.0 = major.minor.patch (semantic versioning, as in npm): patch = bug fixes, minor = new features, major = may break things.

= 4.14.0        exactly this (a bare "4.14.0" means the same)
!= 4.10.0       anything but this one (a known-bad release)
>= 4.0          this or newer - no upper bound, so 5.0 would be accepted
< 5.0           below this
>= 4.0, < 5.0   both: an explicit range
~> 4.14         >= 4.14.0 and < 5.0.0     the rightmost number may grow
~> 4.14.0       >= 4.14.0 and < 4.15.0    patch releases only

The pessimistic operator ~> allows only the rightmost given component to increase. Two components (~> 4.14) means minor and patch bumps; three (~> 4.14.0) means patch only. It is like npm's ^ and ~, but decided by how many numbers you write. That one line is on every Terraform exam.

Where each style belongs:

root configuration     ~> 4.14     you own the upgrade; take minors, review majors
reusable module        >= 4.0      a minimum only - let the ROOT choose the version
terraform itself       ~> 1.9      in required_version

(The root configuration is the directory you run terraform in. A reusable module is shared code other configurations call - Ch 13.) A module that pins = 4.5.0 forces every caller onto 4.5.0; combined with another module pinning = 4.8.0, init fails because nothing satisfies both.

The lock file

.terraform.lock.hcl is like package-lock.json: the exact versions chosen, with checksums.

# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/hashicorp/azurerm" {
  version     = "4.14.0"
  constraints = "~> 4.14"
  hashes = [
    "h1:...",
    "zh:...",
  ]
}

Facts worth having cold:

When the constraint and the lock disagree

Change ~> 4.5.0 to ~> 4.10 in the code, and plan without re-running init:

# the providers mission's config after editing ~> 4.5.0 to ~> 4.10
terraform plan
╷
│ Error: Inconsistent dependency lock file
│
│ The following dependency selections recorded in the lock file are
│ inconsistent with the current configuration:
│   - provider registry.terraform.io/hashicorp/azurerm: locked version selection 4.5.0 doesn't match the updated version constraints "~> 4.10"
│
│ To update the locked dependency selections to match a changed
│ configuration, run:
│   terraform init -upgrade
╵

The lock says 4.5.0, the code now demands 4.10 or newer: they cannot both be true. Plain init refuses too, because the lock is only changed on purpose:

│ Could not retrieve the list of available versions for provider
│ hashicorp/azurerm: locked provider registry.terraform.io/hashicorp/azurerm
│ 4.5.0 does not match configured version constraint ~> 4.10; must use
│ terraform init -upgrade to allow selection of new versions

terraform init -upgrade resolves again, installs 4.14.0 and rewrites the lock:

Terraform has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your
version control system if they represent changes you intended to make.

The lock-file diff then goes into the same PR as the constraint change, and the plan in that PR is what proves the upgrade is harmless.

Seeing what you have

Two commands answer "which providers?":

$ terraform version
Terraform v1.9.8
on linux_arm64
+ provider registry.terraform.io/hashicorp/azurerm v4.14.0
+ provider registry.terraform.io/hashicorp/random v3.6.3

$ terraform providers

Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/azurerm]
├── provider[registry.terraform.io/hashicorp/random]

When they disagree, you have found your problem.

Major upgrades

A major version (3.x to 4.0) is allowed to break things. azurerm 4.0, for example, made subscription_id mandatory and renamed one of its provider settings. The procedure is always the same:

  1. Read the provider's upgrade guide for that major version.
  2. Bump the constraint in a branch, init -upgrade, fix what terraform validate (the offline syntax check) complains about.
  3. Plan against every environment. The goal is no changes - an upgrade that wants to replace things is a bug in your migration, not something to apply.
  4. Merge the constraint, the lock file and the fixes together.

What you can now do:

Why it helps

The day this matters: CI's plan shows changes that yours does not, from identical code. Nine times out of ten, different provider versions, because someone did not commit the lock file or it only has darwin_arm64 hashes and the Linux agent resolved differently. You will also meet "Inconsistent dependency lock file" the first time you bump a constraint, and knowing that init -upgrade is the fix, and that the lock file diff belongs in the PR, saves an afternoon. The ~> operator is on every Terraform Associate exam, and reviewing a module that pins = 4.5.0 (which breaks every caller) is a real review comment you will write.

Commands in this lesson

terraform ls

FAQ

What does ~> 4.14 actually allow, compared with ~> 4.14.0?

The pessimistic operator lets only the rightmost given number grow. ~> 4.14 means >= 4.14.0, < 5.0.0: minor and patch upgrades. ~> 4.14.0 means >= 4.14.0, < 4.15.0: patch releases only. A bare >= 4.0 has no upper bound, so a breaking 5.0 would be accepted. Root configurations typically use ~> on the minor; reusable modules state only a minimum.

Does the lock file pin my modules too?

No. .terraform.lock.hcl records providers only: version, the constraint it satisfied, and hashes. Module versions are controlled by the version argument (registry modules) or a ?ref= on a git source. A module sourced from a branch changes under you on the next init, which is why you pin module sources to tags.

Why does CI fail with a checksum error when it works on my Mac?

The lock file's h1: hashes are per platform. If you created it on your Mac it contains darwin_arm64 hashes only, and a Linux CI agent cannot verify its linux_amd64 binary against them. Run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 once and commit the result, so the lock covers every platform your team and pipelines use.

Where do Azure credentials go if not in the provider block?

In environment variables or in the machine's identity. CI jobs use OIDC, a short-lived token with no stored password (use_oidc = true plus ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID); code running in Azure uses a managed identity (a robot account Azure attaches to the machine); your laptop uses your own az login. A password, if unavoidable, lives in ARM_CLIENT_SECRET from a secret store, never in a .tf file. Since azurerm 4.0 subscription_id is mandatory, which prevents applying to whatever subscription your CLI happened to have selected.

When do I need a provider alias?

When you need two configurations of the same provider in one root module: two regions, two subscriptions, a DR site. The unaliased block is the default; others get alias = "dr" and resources opt in with provider = azurerm.dr (a reference, no quotes). Modules receive aliased providers through providers = { azurerm = azurerm.dr }. Provider blocks themselves belong only in the root module.

In an interview Junior

What is .terraform.lock.hcl, and should it be committed?

It is the dependency lock file, like package-lock.json: for every provider it records the exact version that terraform init chose, the constraints the code allowed, and hashes (checksums) of the download, so a tampered or different binary is refused.

Commit it. Without it, two engineers resolving ~> 4.14 a week apart can get different provider versions and see different plans from identical code.

Things to know:

The version constraint itself lives in required_providers: ~> 4.14 allows 4.x from 4.14 up, but not 5.0.

Also asked: What does the ~> version constraint mean? · How do you use two configurations of the same provider, for two regions? · Where should provider credentials come from?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.