Background

Providers

36 min read

Terraform knows nothing about AWS. It has no concept of a bucket, a subnet or an IAM policy — those live in providers, which are separate programs, written by other people, versioned on their own schedule, downloaded at init time. Understanding that separation explains provider version constraints, the lock file, and why a configuration that worked in March breaks in April having not been touched.

Prerequisites: HCL & the Type System, Anatomy of a Project


What & Why

A provider is a plugin that translates Terraform's generic operations — create this, read that, update, delete — into API calls against one platform. Terraform core handles configuration, the graph, state and the plan; the provider knows what an aws_s3_bucket is and how to make one.

The bad practice it replaces

A monolithic tool that ships support for everything. That was the alternative design, and it fails in a specific way: the tool's release cycle becomes the bottleneck for every platform it supports. A new cloud service launches and you wait for the next Terraform release. A provider bug fix waits behind unrelated core work.

The plugin architecture is why Terraform covers thousands of platforms — including your DNS host, your identity provider, your CI system and your monitoring vendor — rather than three clouds. The cost, and it's the theme of this page, is that you now have two independent version axes, and most "it used to work" incidents come from the one people don't pin.

Where it sits

This page covers declaring providers, constraining their versions, configuring them, and authenticating them — plus the cross-provider comparison the rest of the article refers back to. It stops before three things: the provider meta-argument that selects an alias per resource is Meta-Arguments; passing providers into modules is Modules; and the backend block that shares the terraform block with required_providers is State & Backends.

Three things they're confused with

A provider is not part of Terraform. Different repository, different version number, different release cadence, usually a different team. terraform version prints both, and they are unrelated numbers.

A provider is not a cloud account. The provider is the plugin; a provider block is one configuration of it — credentials, region, project. One provider can have several configurations via alias.

The registry is not a package manager for your infrastructure. It serves providers and modules, and those are very different things: a provider is a binary Terraform executes, a module is configuration Terraform reads. Both come from registry.terraform.io and the similarity ends there.

When NOT to use them

  • Don't leave version constraints off. init will resolve the newest available and record it, and the next person to run init -upgrade gets something different. Unconstrained providers are the most common cause of "the plan changed and nobody changed anything".
  • Don't pin to an exact version and forget it. version = "5.31.0" is reproducible and it also means never receiving a security fix. Constrain the major version, let patches through, and upgrade deliberately.
  • Don't configure a provider from a resource in the same configuration. The bootstrapping trap from The Dependency Graph: the provider node acquires a dependency and everything using it inherits the problem. Split the configuration instead.
  • Don't reach for a community provider without checking who maintains it. A provider executes with your cloud credentials. That's a supply-chain decision, not a convenience one.
  • Don't use alias to paper over what should be separate configurations. Two regions of the same application, yes. Production and development in one configuration because aliases make it possible, no.

Core Concepts

Providera plugin that speaks one platform's API. A standalone executable implementing Terraform's plugin protocol, communicating with Terraform core over RPC. Versioned independently.

Terraform Registrywhere providers and modules are published. registry.terraform.io by default. Providers are tiered: official (maintained by HashiCorp), partner (by the vendor, verified), and community (anyone).

Source addressa provider's globally unique name. [hostname/]namespace/typehashicorp/aws is registry.terraform.io/hashicorp/aws in full. The namespace matters: hashicorp/aws and someone else's example/aws are different providers with the same local name.

Local namethe short name you use in configuration. The key in required_providers, and the first word of every resource type: aws_s3_bucket uses the provider whose local name is aws. Usually matches the type in the source address, and doesn't have to.

required_providersthe declaration. Inside the terraform block. Maps each local name to a source address and a version constraint. Cannot use variables — it's resolved before values exist.

required_versionthe constraint on Terraform itself. Also in the terraform block. A separate axis from provider versions.

Version constraintthe range of acceptable versions. =, !=, >, >=, <, <= and ~>, comma-separated for multiple conditions.

Pessimistic constraint operator (~>)allow the rightmost specified component to increase. ~> 5.0 permits 5.1 and 5.99 but not 6.0. ~> 5.4.0 permits 5.4.1 but not 5.5.0. Reading it correctly is the single most useful piece of syntax knowledge on this page.

Version resolutionchoosing a concrete version. At init, Terraform picks the newest available version satisfying all constraints, then records it in the lock file. Subsequent init runs use the locked version.

.terraform.lock.hclthe recorded selection. Exact versions plus checksums, committed to git. Covered in Anatomy of a Project; the -upgrade workflow is In Practice below.

provider blockone configuration of a provider. Credentials, region, project, and provider-specific settings. A configuration with no provider block still works if the provider needs no configuration or picks everything up from the environment.

Default provider configurationthe unaliased one. Used by every resource that doesn't say otherwise, and inherited by child modules automatically.

aliasa name for an additional configuration. alias = "us" creates aws.us, selected per resource with the provider meta-argument.

Credential chainthe ordered list of places a provider looks for authentication. Provider-block arguments, then environment variables, then shared config files, then ambient machine identity. Differs per provider, and the differences are the substance of this page's tab sets.

Provider mirroran alternative to the public registry. A filesystem or network mirror, declared in the CLI configuration, for air-gapped or vetted-supply-chain environments.


How It Works

The separation, and what it costs you

Diagram showing Terraform core communicating over RPC with separately versioned provider plugins, each talking to its own cloud API

Terraform core parses configuration, builds the graph, computes the diff and manages state. It asks the provider two kinds of question: what does this resource type look like (the schema, used for type checking and for deciding what forces replacement) and do this thing (the API call). Everything cloud-specific is behind that boundary.

Two consequences follow immediately:

Provider upgrades change plans. A new provider version can add attributes, change defaults, start tracking something it previously ignored, or reclassify an attribute as replacement-forcing. So a provider upgrade is an infrastructure change, and the lock file diff is the only visible sign of it.

One provider version per configuration. You cannot use aws 4.x for one resource and 5.x for another in the same working directory. Multiple configurations via alias, yes; multiple versions, no.

Declaring providers

terraform {
  required_version = ">= 1.5, < 2.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.5"
    }
  }
}

source is not optional in practice. Without it Terraform assumes hashicorp/<local name>, which happens to be right for the major clouds and wrong for everything else — and it's the mechanism by which a typo'd namespace could fetch someone else's provider.

Version constraints, and reading ~> correctly

Constraint Allows Rejects Use when
~> 5.0 5.1, 5.31, 5.99 6.0, 4.9 The default choice. Patches and features, no major upgrades
~> 5.4.0 5.4.1, 5.4.9 5.5.0 Conservative: patches only
>= 5.0 5.0 and anything above, including 6.x below 5.0 Modules — see below
= 5.31.0 exactly that everything else Almost never. Pins away security fixes
>= 5.0, < 5.40 that window outside it Working around a known bad release

Diagram of version resolution showing a constraint admitting a range of candidate versions and the lock file recording the single chosen one

The rule for ~>: the last component you wrote is the one allowed to increase. ~> 5.0 names two components, so the second may increase — any 5.x. ~> 5.4.0 names three, so only the third may — any 5.4.x.

Root modules and shared modules want different constraints. A root module should be reasonably tight, because it's applied against real infrastructure and the lock file gives you exact reproducibility anyway. A shared module should be loose>= 5.0 — because a narrow constraint in a module can make it impossible to satisfy alongside another module, and version resolution has to find one version that satisfies everything. Over-constrained modules are a genuine and common cause of unresolvable dependency conflicts.

Provider configuration and authentication — the real divergence

The mechanism is identical: a provider block naming the local name, with provider-specific arguments. What differs is what must be configured, and where credentials come from. This is the largest practical difference between the three clouds in the whole article.

Vertical credential resolution chain from provider block arguments down to ambient machine identity, with the topmost option flagged as the one to avoid

```hcl
provider "aws" {
  region = "eu-west-1"          # required, and it lives here, not on resources

  # Everything else is normally omitted so the credential chain applies.
  default_tags {
    tags = local.common_tags    # applied to every taggable resource
  }
}
```

**Credential chain, in order:** arguments in the provider block → `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` environment variables → the shared
credentials and config files (`~/.aws/credentials`, `~/.aws/config`, honouring
`AWS_PROFILE`) → container credentials (ECS task role) → EC2 instance metadata
(instance profile). ⚠️ verify exact order and completeness.

**In CI:** OIDC federation. The pipeline presents a signed token, assumes a role
scoped to a specific repository and branch, and receives short-lived credentials.
No stored secrets.

**Cross-account:** an `assume_role` block on the provider, usually with an alias
per account.

`default_tags` is worth knowing — it's an AWS-provider feature with no direct
equivalent elsewhere, and it removes most per-resource tagging boilerplate.
```hcl
provider "azurerm" {
  features {}                          # mandatory, and usually empty
  subscription_id = var.subscription_id # required in azurerm 4.x+

  # No region here — location is a resource attribute in Azure.
}
```

**Credential chain, in order:** provider block arguments → `ARM_CLIENT_ID` /
`ARM_CLIENT_SECRET` / `ARM_TENANT_ID` / `ARM_SUBSCRIPTION_ID` environment
variables → managed identity, if enabled → Azure CLI login (`az login`).
⚠️ verify order.

**In CI:** workload identity federation with a service principal, or a managed
identity on a self-hosted runner. A client secret works and is the thing to
replace.

**Cross-subscription:** aliased providers with different `subscription_id`.

Two Azure-specific oddities. The empty `features {}` block is mandatory and exists
so the provider can gate behavioural options; omitting it is an error, and it
confuses everyone once. And `subscription_id` became required in azurerm 4.x,
which broke a great many working configurations on upgrade — a good illustration of
why the lock file diff deserves review. ⚠️ verify.
```hcl
provider "google" {
  project = var.project_id      # the scope unit
  region  = "europe-west1"      # a default for resources that take one
  zone    = "europe-west1-b"

  # No labels-equivalent of default_tags.
}
```

**Credential chain, in order:** `credentials` argument in the provider block →
`GOOGLE_CREDENTIALS` / `GOOGLE_APPLICATION_CREDENTIALS` environment variables →
gcloud CLI application default credentials → the attached service account on a
GCE instance or Cloud Build worker. ⚠️ verify order and variable names.

**In CI:** Workload Identity Federation, impersonating a service account. Service
account key files work and are the thing to eliminate — they're long-lived
credentials in a JSON file.

**Cross-project:** aliased providers with different `project`. Note that many GCP
resources also take a `project` argument, so cross-project work sometimes needs no
alias at all.

`region` on the provider is a *default* rather than a requirement — most GCP
resources take their own `location`, which is why changing the provider region
doesn't move existing resources.

The cross-provider comparison

This is the canonical version. Every other page in the article links here rather than restating it.

AWS Azure GCP
Scope unit Account + region Subscription → resource group Project
Where region/location lives region on the provider location on each resource location on the resource; region on the provider is a default
Mandatory container None Resource group — every resource needs one None
Metadata tags — free-form map(string), mixed case tags — free-form map(string), mixed case labels — lowercase keys and values, restricted charset
Project-wide metadata default default_tags on the provider None None
Provider config essentials region features {} + subscription_id project (+ region)
CI authentication OIDC → assume_role Workload identity federation, or service principal secret Workload Identity Federation, or service account key
Ambient machine identity Instance profile / task role Managed identity Attached service account
Alias axis Region and account Subscription Project
Multi-region needs an alias? Yes No No
Object storage name uniqueness Bucket names global Storage account names global, 3–24 lowercase alphanumeric Bucket names global
Object storage shape Bucket; features are separate resources Storage account + container Bucket; features are nested blocks

Three rows carry most of the consequences elsewhere in the article. Where region lives determines whether multi-region needs aliased providers (Meta-Arguments) and whether a region change shows up in a plan as a replacement (The Plan/Apply Lifecycle). Metadata determines whether one tagging map can be shared (HCL & the Type System). And object storage shape determines the shape of your dependency graph (The Dependency Graph).

Aliases

provider "aws" {
  region = "eu-west-1"        # default configuration
}

provider "aws" {
  alias  = "us"
  region = "us-east-1"        # additional configuration, named aws.us
}

Selecting one per resource is the provider meta-argument, which belongs to Meta-Arguments:

resource "aws_s3_bucket" "us" {
  provider = aws.us
  bucket   = "tf-providers-demo-us-0725"
}

Two rules worth knowing now. Child modules inherit the default configuration automatically but not aliased ones — those must be passed explicitly, which is Modules. And an aliased provider that no resource uses is not an error, so a stale alias can sit in a configuration indefinitely.


Getting Started

The point is to watch version resolution happen, then change it deliberately.

# main.tf
terraform {
  required_version = ">= 1.5"

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

provider "aws" {
  region = "eu-west-1"
}

provider "aws" {
  alias  = "us"
  region = "us-east-1"
}

resource "aws_s3_bucket" "eu" {
  bucket = "tf-providers-demo-eu-0725"
}

resource "aws_s3_bucket" "us" {
  provider = aws.us
  bucket   = "tf-providers-demo-us-0725"
}

1 · Resolve and lock

terraform init
# UNVERIFIED — confirm against a real run
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.31.0...
- Installed hashicorp/aws v5.31.0 (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.

"Versions matching" then "Installing" is version resolution in two lines: the constraint admitted a range, and one member of it was chosen and written down.

cat .terraform.lock.hcl
# UNVERIFIED — confirm against a real run
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.31.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:...",
    "zh:...",
  ]
}

Both numbers are recorded: what you asked for, and what you got.

2 · Inspect what the configuration requires

terraform providers
# UNVERIFIED — confirm against a real run
Providers required by configuration:
.
└── provider[registry.terraform.io/hashicorp/aws] ~> 5.0

Providers required by state:
    provider[registry.terraform.io/hashicorp/aws]

Useful on an unfamiliar repository, and the "required by state" section is the one that catches a provider you've removed from configuration but which still owns resources.

3 · Upgrade deliberately

terraform init -upgrade
git diff .terraform.lock.hcl

The diff is the whole point: a provider upgrade is a reviewable event. Now plan against it:

terraform plan

If the plan is not empty after only an upgrade, the new provider version has changed how it interprets your configuration — and that's exactly the thing you want to discover in a pull request rather than during an unrelated change.

4 · Break a constraint on purpose

Change the constraint to ~> 4.0 and re-init:

terraform init
# UNVERIFIED — confirm against a real run
Error: Failed to query available provider packages

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

The lock file won this argument, and the error tells you exactly which flag overrides it. That's the reproducibility guarantee working as designed.

5 · Apply and clean up

terraform init -upgrade    # restore ~> 5.0 first
terraform apply
terraform destroy

In Practice

The production versions.tf, which is the file this whole page argues for:

# versions.tf
terraform {
  required_version = ">= 1.5, < 2.0"

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

  # backend "s3" { ... }   ← State & Backends
}

Committed alongside .terraform.lock.hcl. The upper bound on required_version is deliberate: a newer Terraform can upgrade the state format, after which colleagues on older versions cannot read it.

Treat provider upgrades as changes, on a schedule. The workflow that works:

terraform init -upgrade                 # on a branch, on its own
git diff .terraform.lock.hcl            # what moved
terraform plan                          # ...and what that does to infrastructure

An empty plan means a safe upgrade; a non-empty plan is the upgrade's actual content, and it belongs in the pull request description. Doing this monthly on its own branch is far cheaper than discovering a provider's behavioural change inside an unrelated feature PR — which is what happens when nobody owns upgrades.

Record lock hashes for every platform your team uses:

terraform providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64 \
  -platform=windows_amd64

Without this, a lock file generated on a Mac fails on Linux CI with a checksum error that reads like tampering. It's a five-minute fix that people lose an afternoon to.

Never put credentials in a provider block. Not in .tf, not in .tfvars. The provider block should contain the shape of the connection — region, project, subscription — and credentials should arrive from the environment, from ambient machine identity, or from OIDC federation. A provider block with an access key in it is a secret in git, and it's also unnecessary on every one of the three clouds.

Authenticate CI with federation, not stored keys. This is the single highest-value security change available in a Terraform pipeline. The pipeline presents a signed identity token, the cloud exchanges it for short-lived credentials, and the trust policy restricts which repository and branch can do so. No secret exists to leak or rotate. Mechanics in CI/CD & Automation.

Use default_tags on AWS, and accept the asymmetry. It applies tags to every taggable resource from one place, which removes a great deal of boilerplate — and it has no Azure or GCP equivalent, so a multi-cloud repository can't apply the same pattern uniformly. Better to use it where it exists than to avoid it for symmetry's sake. One caveat: interactions between default_tags and per-resource tags have historically produced confusing diffs. ⚠️ verify current behaviour before relying on it.

Constrain shared modules loosely. >= 5.0 in a module, tight constraints in root modules only. Version resolution must satisfy every constraint simultaneously, so two modules with narrow non-overlapping ranges make a configuration unsatisfiable — and the person who hits it is a consumer who can't fix either module.

What a reviewer should look for in a diff touching this topic:

  • A change to .terraform.lock.hcl. The most important item. It means a provider version changed; ask for the plan output that proves what it does. A lock file diff with no plan attached is an unreviewed infrastructure change.
  • A missing or loosened version constraint in a root module.
  • source omitted from a required_providers entry.
  • Anything credential-shaped in a provider block — access keys, secrets, key file paths, tokens.
  • A new aliased provider. Which account, subscription or project does it point at, and should this be a separate configuration instead?
  • A provider block referencing a resource in the same configuration.
  • A community-tier provider being introduced. Who maintains it, how many downloads, when was the last release? It will run with your credentials.
  • A raised required_version floor, which forces everyone including CI to upgrade.

Blast radius and rollback. A provider upgrade's blast radius is every resource that provider manages, and the damage mode is behavioural rather than syntactic — new defaults, newly-tracked attributes, reclassified replacement-forcing arguments. Rollback is unusually clean here: revert the lock file and init again, because provider versions are the one dependency Terraform records precisely. What doesn't roll back is anything the upgraded provider already applied.


Ecosystem

The Terraform Registry. Providers and modules, with a documentation browser that is the actual reference for every resource type — including which attributes force replacement. The tier badge (official / partner / community) is the first thing to check on anything unfamiliar.

terraform providers and terraform providers lock. The first shows what the configuration and state require, including providers still referenced by state after you removed them from configuration. The second records multi-platform hashes. Both are small commands that prevent specific, annoying failures.

Dependabot / Renovate. Automated pull requests for provider version bumps, which turns "nobody owns upgrades" into a queue someone reviews. The glue: a config file naming the Terraform ecosystem. Worth pairing with a CI job that posts the resulting plan, since the value is in seeing what the bump does.

Provider mirrors, and provider_installation in the CLI config. A filesystem or network mirror for air-gapped environments or vetted supply chains, letting you approve provider versions before they're available to engineers. The glue is a CLI configuration file rather than anything in your .tf. ⚠️ verify the current file location and block syntax.

tfenv / tenv. Pin the Terraform version per directory, the other half of the two-axis problem. ⚠️ verify maintenance status.

Provider-defined functions (Terraform 1.8+). Providers can now contribute functions, not just resources and data sources — so provider-specific value manipulation stops requiring string gymnastics. Detail in Extending & the Ecosystem. ⚠️ verify version.


Production

Security

Providers are the largest supply-chain surface in Terraform: a provider is a binary that runs on your machine and in your pipeline, with credentials that can change your entire estate. Three controls, in order of value. Prefer official and partner tiers, and treat adopting a community provider as a review decision with a named owner. Commit the lock file, because its checksums are what make the binary you audited the binary that runs. Consider a mirror where the supply chain must be vetted.

Separately: no credentials in provider blocks, ever, and OIDC federation instead of long-lived keys in CI. That single change removes the most commonly leaked secret in infrastructure repositories.

Blast radius

An upgrade affects everything the provider manages, which for a cloud provider is the whole configuration. The mitigations are procedural rather than technical: upgrade on its own branch, read the plan, and upgrade non-production first — which requires that environments have separate state, or you cannot stage a provider upgrade at all. That's another argument for the layout in Repo & Environment Structure.

Scale

Two costs. Provider binaries are large, so .terraform/ is heavy and CI should cache it — a plugin cache directory shared between working directories saves a great deal on repositories with many root modules. ⚠️ verify the current cache configuration mechanism. And every provider configuration holds its own API client and rate-limit budget, so a configuration with many aliases spreads work across several clients, which occasionally helps and occasionally multiplies your throttling problem.

Team workflow

Two rules, both needing to be written down because neither is enforced by tooling: lock file changes require a plan in the PR, and provider upgrades happen on their own branch on a schedule. The failure mode without them is not dramatic — it's that provider versions drift between developers and CI, plans differ between machines, and nobody can reproduce anyone else's output. Adding a .terraform-version file and multi-platform lock hashes removes most of the remaining variance.

Reliability

The lock file is the reliability mechanism, and it works: with it committed, init is deterministic and reproducible months later. The gap is platform-specific hashes, which turn a fresh CI runner into a checksum failure. Beyond that, the failure worth rehearsing is a provider version being withdrawn or a registry outage — which is precisely what a mirror protects against, and the reason air-gapped environments were an easier sell than they sound.


Interview Questions

Conceptual

What is a provider, and why isn't it part of Terraform?

A plugin that translates Terraform's generic create/read/update/delete operations into API calls against one platform. It's a separate binary, separately versioned, downloaded at init, communicating with Terraform core over RPC.

It's separate so that platform support isn't gated on Terraform's release cycle — which is why Terraform covers thousands of platforms rather than a handful of clouds. The cost is two independent version axes, and the practical consequence is that a configuration can break without being edited, because the provider moved.

What does `~> 5.0` mean, and how does it differ from `~> 5.4.0`?

The pessimistic constraint operator allows the rightmost component you specified to increase. ~> 5.0 specifies two components, so the second may increase: any 5.x, but not 6.0. ~> 5.4.0 specifies three, so only the patch may increase: 5.4.1 but not 5.5.0.

~> 5.0 is the sensible default for a root module — you get patches and features, and major upgrades remain deliberate. Exact pinning is worse than it looks, because it also pins away security fixes; the lock file already gives you reproducibility without needing the constraint to do it.

What's the difference between `required_providers` and a `provider` block?

required_providers, inside the terraform block, declares a dependency: local name, source address, version constraint. It's about which plugin to download. A provider block configures an installed provider: region, project, subscription, credentials. One declaration can have several configurations via alias.

They're also resolved at different times — required_providers can't use variables, because it's needed before values exist.

Why is the lock file committed, and what breaks without it?

Because version constraints admit a range and the lock file records which member of that range you actually got, plus checksums. Committed, it means everyone including CI resolves to the identical binary. Without it, each init independently picks the newest allowed version, so a colleague next month silently gets a newer provider — and provider upgrades change defaults, deprecate arguments and occasionally force replacement. The symptom is a plan that differs between machines for no visible reason.

The subtlety: hashes are platform-specific, so a lock file generated only on macOS can fail on Linux CI. terraform providers lock -platform=... records several at once.

What is a provider alias for?

A second configuration of the same provider, so one configuration can act against more than one region, account, subscription or project. Declare it with alias on an extra provider block, then select it per resource with the provider meta-argument.

Two things worth adding: child modules inherit the default configuration automatically but must be passed aliased ones explicitly; and aliases aren't a substitute for separating environments — using them to manage production and development from one configuration gives you one blast radius and one set of permissions for both.

Technical depth

How does Terraform resolve provider versions, and what happens on `init -upgrade`?

At init, Terraform collects every constraint on a provider — from the root module and from every child module — and selects the newest available version satisfying all of them, then records it with checksums in the lock file. On subsequent init runs, the locked version is used and the constraints are only checked for compatibility; if the lock contradicts the constraints, init fails and tells you to use -upgrade.

-upgrade discards the locked selection, re-resolves, and rewrites the lock file. So the correct upgrade workflow is: init -upgrade, look at the lock file diff, then plan to see what the new version does to your infrastructure.

Why should shared modules use loose version constraints?

Because resolution must find one version satisfying every constraint in the whole configuration simultaneously. A module pinned to ~> 4.2 and another pinned to ~> 5.1 make a configuration unsatisfiable, and the person who hits it is a consumer who can't modify either module.

So modules should declare a floor — >= 5.0, the oldest version whose features they need — and leave the ceiling to the root module, which is where the lock file lives and where reproducibility is actually achieved. Over-constrained modules are one of the more common real-world dependency problems.

Can you use two versions of the same provider in one configuration?

No. One version per provider per working directory, however many provider blocks and aliases you have — aliases give multiple configurations of one version, not multiple versions.

If you genuinely need two versions, that's two configurations with separate state, and it's a real situation during a staged major upgrade of a large estate: migrate one state at a time rather than the whole repository at once.

What are the risks of a community provider?

It's a binary that runs on your machine and in your pipeline with credentials that can modify or destroy your estate, published by someone with no relationship to HashiCorp or your cloud vendor. So the risks are supply-chain compromise, abandonment — a provider that stops working with a new API version and has no maintainer — and simply low quality, since a provider bug can corrupt state or leak values.

Mitigations: prefer official and partner tiers; check release recency, download counts and open issues before adopting; commit the lock file so checksums pin the exact artefact; and use a provider mirror where the supply chain must be vetted, so versions are approved before engineers can use them.

Where do providers get credentials from?

Each provider has an ordered credential chain, and the general shape is the same: explicit provider-block arguments first, then environment variables, then a shared config or CLI login, then ambient machine identity — an instance profile, managed identity or attached service account.

The principle that matters more than the order is that the earliest option is the one you should never use. Credentials in a provider block are a secret in source control, and every one of the three clouds offers an alternative. In CI, the right answer is OIDC or workload identity federation, which exchanges a signed token for short-lived credentials scoped to a specific repository and branch, so no stored secret exists at all.

How does this differ across AWS, Azure and GCP?

This is the topic where the answer is "substantially", and the differences follow each cloud's scoping model.

Scope and where region lives. AWS scopes by account plus region, and region is a provider argument — so any multi-region configuration needs an aliased provider. Azure scopes subscription → resource group → resource, with location on each resource, so multi-region needs no alias; aliases are for crossing subscriptions. GCP scopes by project, with location on resources and region on the provider as a default; aliases are for crossing projects.

Provider configuration essentials. AWS needs region. Azure needs a mandatory features {} block and, in recent major versions, an explicit subscription_id. GCP needs project.

Authentication. AWS: credential chain ending in instance profile; OIDC with assume_role in CI. Azure: ARM_* environment variables, managed identity, or CLI login; workload identity federation in CI. GCP: application default credentials or a service account key; Workload Identity Federation in CI.

Metadata. AWS and Azure use free-form tags; GCP uses labels with lowercase keys and values. AWS additionally has default_tags on the provider, with no equivalent on the other two.

The canonical table is on this page and every other page links to it rather than restating it.

Scenario and design

A configuration that worked last month now produces a plan proposing changes, and nobody edited it. Explain.

The most likely cause is a provider version change — someone ran init -upgrade, or the lock file was never committed so a fresh clone resolved something newer. New provider versions add attributes, change defaults, and start tracking things they previously ignored, all of which surface as plan diffs.

Other candidates: drift, where reality changed rather than configuration; a data source returning something new; or an unstable expression in the configuration.

Diagnosis: git log on .terraform.lock.hcl first, since that answers it in one step. Then plan -refresh=false to separate drift from configuration-driven change — if the diff persists without refresh, it isn't drift. The systemic fix is committing the lock file and reviewing its diffs.

Design provider configuration for an application in two AWS regions with a shared DNS zone in a third account.

Three provider configurations: a default for the primary region, an alias for the secondary, and an alias for the DNS account with an assume_role block. Resources select the non-default ones with the provider meta-argument, and any module needing them receives them explicitly through a providers map, since aliases aren't inherited.

Constraints and credentials: one ~> 5.0 constraint covering all three configurations — the version is per provider, not per configuration — a committed lock file, and OIDC in CI with a role trusted by both accounts, or a chained assume-role.

The design question worth raising: whether the DNS account belongs in this configuration at all. It almost certainly changes on a different cadence and has a different blast radius, which argues for a separate configuration exposing the zone, consumed here through a data source.

Your organisation requires all third-party binaries to be vetted before use. How do you run Terraform?

A provider mirror — filesystem or network — populated only with versions that have passed review, with the CLI configuration's provider_installation block pointing at it and direct registry access blocked. Engineers then physically cannot install an unapproved provider, and the approval process becomes a queue rather than a policy nobody can enforce.

Supporting pieces: committed lock files so checksums pin exact artefacts; multi-platform lock hashes so CI and developer machines agree; a mirror population step that records provenance; and the same treatment for the Terraform binary itself, since it's a third-party binary too.

The trade-off to state honestly is latency — engineers wait for approval to use a new provider version, including security fixes — so the review process needs an expedited path, or people will work around it.

You need to upgrade the AWS provider across 40 root modules. How?

Not all at once, and not by editing 40 constraints in one pull request. The staged approach: pick one low-risk non-production module, init -upgrade, read the lock diff and the plan, apply, and see whether the upgrade is behaviourally empty or not. That first module tells you what the upgrade actually contains — which is information you don't have until you've done one.

Then roll forward in batches, non-production before production, keeping each batch small enough that a surprising plan is easy to attribute. Because provider version is per configuration, the 40 modules can sit on different versions indefinitely, which is what makes staging possible.

Supporting work: automate the mechanical part with Dependabot or Renovate so each module gets its own PR with a plan attached; and if the major upgrade has known breaking changes, read the provider's upgrade guide first and grep the estate for the affected resource types before starting.


Commands & Gotchas

terraform version                          # Terraform version AND provider versions
terraform init                             # resolve constraints, install, write lock file
terraform init -upgrade                    # re-resolve and rewrite the lock file
terraform providers                        # what configuration and state require
terraform providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64                   # multi-platform lock hashes
terraform providers schema -json           # full schema — what forces replacement, machine-readable
git diff .terraform.lock.hcl               # the diff that means "infrastructure may change"
export TF_LOG=DEBUG                        # see the provider's actual API calls
Behaviour Why it matters
Providers version independently of Terraform Two axes. terraform version prints both, and they're unrelated
~> 5.0 allows any 5.x; ~> 5.4.0 allows only 5.4.x The rightmost component you wrote is the one that may increase
One provider version per working directory Aliases give many configurations of one version, never many versions
A lock file diff is an infrastructure change New defaults, new tracked attributes, reclassified replacements. Require a plan
Lock hashes are platform-specific macOS-only lock files break Linux CI. Use providers lock -platform
required_providers can't use variables Resolved before values exist. Same for required_version
source defaults to hashicorp/<name> Right for the big clouds, wrong for everything else. Always write it
Modules should constrain loosely, roots tightly Resolution must satisfy every constraint at once
Aliased providers are not inherited by modules The default one is. Aliases must be passed explicitly
Credentials belong nowhere near a provider block Environment, ambient identity, or OIDC. Never .tf or .tfvars
An unused alias is not an error Stale aliases accumulate silently
region is a provider argument on AWS, a resource attribute on Azure and GCP Decides whether multi-region needs an alias at all

← Back to The Language · Next: Resources & References →


⚠️ Verification checklist (delete before publishing)

The canonical comparison table — this is the highest-stakes block in the article, because six other pages link to it instead of restating its content. Verify every cell.

  • Scope unit row, all three.
  • Where region/location lives, all three — including the claim that GCP's provider region is only a default.
  • Metadata row: GCP labels requiring lowercase keys and values, and the exact charset.
  • default_tags existing only on AWS.
  • Provider config essentials: that features {} is mandatory and that subscription_id is required in azurerm 4.x+.
  • CI authentication row, all three, using current product names.
  • Ambient identity row: instance profile / managed identity / attached service account.
  • Alias axis and "multi-region needs an alias?" rows — these two drive claims on three other pages.
  • Name uniqueness row: bucket names global on AWS and GCP; storage account 3–24 lowercase alphanumeric. Verify once and reconcile with the four other pages that assert it.
  • Object storage shape row: AWS features as separate resources, GCP as nested blocks.

Credential chains — I have flagged all three inline as unverified order

  • AWS chain order and completeness, including whether SSO / identity-centre sources belong in it.
  • Azure chain order, and the exact ARM_* variable names.
  • GCP chain order, and whether GOOGLE_CREDENTIALS and GOOGLE_APPLICATION_CREDENTIALS both apply and in which order.

Command output

  • terraform init resolution output, including the exact lock-file paragraph.
  • The lock file excerpt.
  • terraform providers output format, including whether "Providers required by state" appears as shown.
  • The constraint-conflict error in step 4. Capture the real text — I've paraphrased it and it's the punchline of the Getting Started section.
  • terraform providers schema -json — confirm the subcommand name.

Behavioural claims

  • That a version in the lock file wins over a matching constraint until -upgrade is run, and that a contradicting constraint is an error rather than a silent re-resolve.
  • That aliased providers are not inherited by child modules while the default one is.
  • That an unused aliased provider is not an error.
  • That only one version of a provider can be used per working directory.
  • That required_providers and required_version cannot reference variables.
  • default_tags interaction with per-resource tags — flagged inline as historically confusing. Either verify current behaviour or soften the recommendation.
  • Provider-defined functions minimum version (I wrote 1.8).
  • The plugin cache mechanism and current configuration syntax — flagged inline.
  • provider_installation block syntax and CLI config file location — flagged inline.

Versions

  • ~> 5.0 for aws — this page is where the fix should start, since it's the page that teaches constraints. Get the current major versions for all three providers and correct all eight pages.
  • ~> 3.5 for random.
  • The example resolved version 5.31.0 used throughout — replace with a real one from an actual init.

Structure

  • One <Tabs> block, covering provider configuration and authentication together rather than as two sets. Confirm that reads well — it's the longest tab set in the article and the tabs are genuinely asymmetric in length, which the rule says to call out in prose. Check the prose above it does so clearly enough.
  • Length: dense page. Check rendered length against target.
  • All relative links resolve. Note that six existing pages link to this page's table — once published, check those inbound links land somewhere sensible, and consider adding an explicit anchor to the table.