Providers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Terraform core knows nothing about AWS, Docker, PostgreSQL or DNS. Everything that touches a real system lives in a
provider: a separate executable plugin that translates resource, data source and function blocks into API calls.
This page is the reference for how a configuration declares the providers it needs, how Terraform chooses, verifies
and caches them, how each provider is configured and authenticated, how several configurations of the same provider
coexist (regions, accounts) and how they are handed to modules. It targets Terraform 1.17 (released 2026-10-21) and
OpenTofu 1.13, and the provider majors current at the time of writing: hashicorp/aws 6.x, kreuzwerker/docker 4.x
and hashicorp/random 3.x. It follows
HCL Syntax and Configuration Files, which introduced
the terraform block and the lock file, and it prepares
Resources and Data Sources and
Modules. How to write a provider is covered in
Provider Development. For the section reading path see
the section landing page.
The examples extend the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers
bucket). The local pieces use the kreuzwerker/docker and random providers; the multi-region and multi-account
examples use hashicorp/aws with placeholders only and never contain credentials.
What a provider is
A provider is a plugin that Terraform launches as a child process and talks to over a gRPC protocol. Terraform core reads the configuration, builds the dependency graph and decides what must change; the provider owns everything specific to one platform:
-
the schema of each resource type, data source, ephemeral resource, action and function it offers (which arguments and attributes exist, their types, which are required, computed or sensitive);
-
the provider configuration arguments (a region, an endpoint, credentials);
-
the create, read, update and delete calls to the remote API, and the translation of API errors into diagnostics.
Because the split is strict, Terraform core never needs an update to support a new cloud service, and a provider
never needs to know about the dependency graph or state storage. The same split is why provider versions are managed
separately from the Terraform CLI version: the CLI is pinned with required_version, providers with
required_providers and the lock file, as described below. (The plugin protocol and the Go Plugin Framework are
covered in Provider Development.)
Besides the plugins downloaded from a registry, there is one built-in provider, terraform.io/builtin/terraform. It
supplies the terraform_remote_state data source and a handful of functions (encode_tfvars, decode_tfvars and
encode_expr), and it never needs to be declared or installed.
Requiring providers
Every module must say which providers it uses. The declaration lives in the required_providers block nested inside
the top-level terraform block, and maps a local name to a source address and a version constraint.
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
Source addresses
A source address has the form [<HOSTNAME>/]<NAMESPACE>/<TYPE>:
| Part | Meaning |
|---|---|
Hostname (optional) |
The registry that distributes the provider. It defaults to |
Namespace |
The organization that publishes the provider on the registry, such as |
Type |
A short name for the platform or system the provider manages, such as |
If source is omitted, Terraform assumes the implied address registry.terraform.io/hashicorp/<LOCAL NAME>. That
only works for providers published by HashiCorp, and the documentation recommends writing the source explicitly in
every configuration. Providers from an organization’s own registry or a private mirror use an explicit hostname, for
example terraform.example.com/examplecorp/ourcloud.
Local names
The key in required_providers is the local name. It is the only name used everywhere else in that module: in
provider blocks (provider "docker"), in the provider meta-argument (provider = docker.alt), in the prefix of
resource types (docker_container belongs to the provider whose local name is docker) and in
provider::<local name>::<function> calls. Local names are module-specific, so two modules may use different local
names for the same source, but they must be unique inside one module. Use the provider’s preferred local name unless
there is a conflict. When two providers share a type name, give each a compound local name with a dash and refer to
the provider explicitly:
terraform {
required_providers {
hashicorp-http = {
source = "hashicorp/http"
version = "~> 3.4"
}
mycorp-http = {
source = "mycorp/http"
version = "~> 1.0"
}
}
}
data "http" "health" {
provider = hashicorp-http
url = "https://example.com/health"
}
Terraform maps a resource type to a provider by the first word of its type name, so a resource type only resolves
automatically when it starts with the provider’s local name. A compound local name therefore requires the explicit
provider meta-argument shown above.
Version constraints
The version argument is a constraint string made of one or more comma-separated conditions. Terraform picks the
newest version that satisfies all of them.
| Operator | Meaning |
|---|---|
|
Exactly this version. It cannot be combined with other conditions. |
|
Excludes one exact version. |
|
Greater than, at least, less than, at most. |
|
The pessimistic operator: only the rightmost component may increase. |
Pre-release versions (those with a dash suffix such as 1.2.0-beta) are matched only by an exact = constraint; the
range operators never select them. The recommended practice differs by kind of module:
-
Root modules (where
terraform applyruns) should give every provider both bounds, normally with~>, for example~> 6.0for the AWS provider to stay on major version 6. -
Reusable modules should declare only a minimum, for example
>= 5.0, and leave the upper bound to the root module. Constraints from the root and from every child module are combined, and Terraform proceeds only if all of them can be satisfied at once.
If version is omitted, any version is accepted, which makes upgrades unpredictable; always set one. The deprecated
version argument inside a provider block is a legacy form: put constraints in required_providers.
Inspecting what a configuration requires
terraform providers prints the provider requirements of the working directory and where each was found (the root
configuration or a particular module), which is the quickest way to see why an unexpected provider is being
installed. terraform providers schema -json prints the full schema of every provider in machine-readable form; it
is used by editors, documentation generators and wrappers, and is described in
Machine-Readable Output and Integrations.
Source: Command: providers
The dependency lock file
Version constraints describe what is acceptable; the dependency lock file, .terraform.lock.hcl, records what was
actually selected. It is created and updated by terraform init, lives in the root module’s working directory next
to the .terraform directory, uses HCL syntax (but is not a configuration file) and tracks providers only, never
modules. Commit it to version control so every machine and pipeline installs the same provider builds and so that
dependency changes show up in code review.
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/random" {
version = "3.9.1"
constraints = "~> 3.9"
hashes = [
"h1:<hash>",
"zh:<hash>",
"zh:<hash>",
]
}
Each provider block is labelled with the provider’s fully qualified source address and has three arguments:
| Argument | Meaning |
|---|---|
|
The exact version Terraform selected. Later |
|
The version constraints that were in force when the selection was made. Informational only; it is not used when installing. |
|
Checksums accepted for the selected version. Terraform refuses to use a package whose checksum is not in the list. |
zh and h1 hashes
Two hash schemes appear in the file:
-
zh:("zip hash") is the SHA-256 of the official.zippackage as served by the registry. It is a legacy scheme and only verifies the zip file itself, so it cannot validate an unpacked directory such as a filesystem mirror. -
h1:("hash scheme 1") is a SHA-256 over the contents of the package. It matches the official zip, an unpacked directory and a re-compressed zip, and is the preferred scheme. Terraform addsh1:hashes opportunistically when a package matches an existingzh:hash.
Upgrading providers deliberately
Constraints are not the upgrade mechanism; the lock file is. With a file present, terraform init keeps the recorded
versions. To move to the newest versions allowed by the constraints, ask for it explicitly and review the diff:
terraform init -upgrade
git diff .terraform.lock.hcl
Source: Command: init
-upgrade ignores existing selections for providers (and also re-fetches modules). The opposite switch,
-lockfile=readonly, makes init verify checksums against the file but refuse to modify it; it cannot be combined
with -upgrade, and it is a good default in CI so that a pipeline can never silently change the lock file.
# CI: fail instead of changing the lock file
terraform init -lockfile=readonly -input=false
Source: Command: init
Locking for several platforms
When init installs a provider from the registry it can only record checksums for the platform it is running on, plus
those it can derive from the registry’s published values. If a team develops on macOS and runs CI on Linux, the first
init on the other platform adds more hashes, producing a lock-file change. Pre-record all platforms once, from any
machine:
terraform providers lock \
-platform=darwin_arm64 \
-platform=linux_amd64 \
-platform=linux_arm64 \
-platform=windows_amd64
Source: Command: providers lock
The command fetches the packages for each listed platform from the origin registries, checks that they exist, and saves enough checksums to cover at least those platforms. Review the signing information it prints before committing. Its other options:
| Option | Meaning |
|---|---|
|
A platform you plan to use, such as |
|
Look for packages in a filesystem mirror directory instead of the registries. The directory must use the standard mirror layout. |
|
Look for packages in a network mirror that implements the provider network mirror protocol. |
|
Use the configured plugin cache to speed up locking. Off by default, because the cache is not an authoritative source and could hide an untrusted package. |
Mirror-based installs cannot verify checksums for other platforms, so terraform init against a mirror records only
the mirror’s own hashes. When a team installs from mirrors, run terraform providers lock against the origin
registry (or with the mirror options plus the platform list) to record the registry’s checksums, so later installs
can verify that the mirror serves the expected packages. OpenTofu 1.13 adds an -oci-mirror option to the same
command, described under Terraform and OpenTofu differences.
A lock-file change is a normal pull request in a Terraform repository:
terraform init -upgrade
terraform providers lock -platform=darwin_arm64 -platform=linux_amd64
terraform plan
git add .terraform.lock.hcl
git commit -m "Upgrade providers"
Provider configuration and authentication
A provider block configures one instance of a provider. Its label is the provider’s local name and its arguments
are defined by the provider; the only arguments common to all providers are alias (below) and the deprecated
version.
provider "docker" {
# uses the local Docker daemon through the default socket
}
provider "random" {}
Some providers need no configuration at all (random, local, null, time), and for those the block can be
omitted: Terraform uses an empty default configuration. A provider that has required arguments must have a block, and
the block is normally written only in the root module.
Using variables and locals
Unlike the terraform block, a provider block can use input variables, locals and other expressions, as long as the
values are known at plan time. It cannot depend on attributes of a resource that does not yet exist, although
attributes of resources created in an earlier apply are fine; the usual pitfall of configuring a Kubernetes or Helm
provider from a cluster created in the same configuration is covered in
Docker, Kubernetes and Helm Providers.
variable "aws_region" {
type = string
default = "eu-west-1"
}
locals {
common_tags = {
project = "bookshelf"
managed = "terraform"
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
default_tags is an AWS-provider feature, not a core one: it adds the tags to every taggable resource the provider
creates. Whether a provider offers an equivalent is part of that provider’s documentation.
Authentication without secrets in code
Never put credentials in the configuration: it ends up in version control, in plan output and in state. Providers support one or more of these routes, in roughly the order of preference:
-
Workload identity — OpenID Connect from CI, a cloud instance profile, a Kubernetes service account or a managed identity. No long-lived secret exists. See Sensitive Data, Ephemeral Values and Write-Only Arguments for secrets that must still pass through Terraform.
-
The platform’s own credential chain — for AWS, the shared config and credentials files and named profiles (
AWS_PROFILE) or single sign-on sessions, on a developer workstation. -
Environment variables —
AWS_ACCESS_KEY_ID-style variables,DOCKER_HOSTand so on, set by the shell or the CI system and never committed. -
Input variables marked
sensitiveand supplied throughTF_VAR_environment variables, for credentials the platform has no native chain for, such as an API token for a SaaS provider.
Each provider documents its own precedence and variable names on its registry page, which is the authoritative reference; a provider block with no credentials at all is therefore normal and desirable.
provider "aws" {
region = var.aws_region
# no access keys here: the AWS credential chain (profile, SSO, OIDC role) supplies them
}
provider "postgresql" {
host = var.db_host
username = var.db_admin_user
password = var.db_admin_password # supplied with TF_VAR_db_admin_password
}
variable "db_admin_password" {
type = string
sensitive = true
}
Marking the variable sensitive redacts it from plan output, but the provider may still persist it in state if the
provider stores its configuration; check where state lives and who can read it
(Backends and Remote State).
Aliases: several configurations of one provider
One provider block configures one instance. When a configuration needs the same provider twice (two regions, two
accounts, two Kubernetes clusters), add further blocks with an alias. The block without an alias is the default
configuration, used by every resource that does not name another.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
provider "aws" {
alias = "us_east"
region = "us-east-1"
}
resource "aws_s3_bucket" "covers" {
bucket = "bookshelf-covers-${var.environment}"
}
resource "aws_s3_bucket" "covers_replica" {
provider = aws.us_east
bucket = "bookshelf-covers-${var.environment}-replica"
}
The provider meta-argument is available on resource and data blocks and selects <local name>.<alias>. The
reference is static: it cannot be computed from a variable, because Terraform needs it before it evaluates anything
in order to build the graph. If every provider block of a type has an alias, Terraform still creates an implied empty
default configuration, which fails when the provider requires arguments. Aliases cannot be combined with the
deprecated version argument. Data sources follow the same rule.
Multi-account
The same mechanism handles several accounts. Keep the role ARNs in variables (they are identifiers, not secrets) and authenticate with the workload’s own identity, so that the single trusted identity assumes a role in each account.
provider "aws" {
region = var.aws_region
}
provider "aws" {
alias = "shared_services"
region = var.aws_region
assume_role {
role_arn = var.shared_services_role_arn
session_name = "terraform-bookshelf"
}
}
data "aws_route53_zone" "bookshelf" {
provider = aws.shared_services
name = var.dns_zone_name
}
resource "aws_route53_record" "web" {
provider = aws.shared_services
zone_id = data.aws_route53_zone.bookshelf.zone_id
name = "bookshelf.${var.dns_zone_name}"
type = "CNAME"
ttl = 300
records = [aws_lb.web.dns_name]
}
Aliases are a good fit for a handful of fixed configurations. They are poor at scale: each alias is a static block, a
resource can pick its provider only statically, and count or for_each instances of a module cannot each use a
different alias. For one environment per account the idiomatic answer is separate root modules or workspaces
(Workspaces, Environments and Project
Structure); OpenTofu additionally offers provider for_each, covered below.
Passing providers to modules
Providers are configured in the root module and flow down the module tree.
Implicit inheritance
A child module automatically inherits the default (unaliased) provider configurations of its parent. Only the
configuration is inherited: source address and version constraints are not, so each module still declares its own
required_providers. Aliased configurations are never inherited implicitly.
provider "aws" {
region = "eu-west-1"
}
module "network" {
source = "./modules/network"
}
The providers meta-argument
To pass an aliased configuration, or to override inheritance, use the providers map in the module block. Its keys
are the provider names the child module expects and its values are configurations of the calling module:
provider "aws" {
alias = "usw2"
region = "us-west-2"
}
module "example" {
source = "./example"
providers = {
aws = aws.usw2
}
}
Setting providers replaces the default inheritance for that provider type, so list every provider the module needs
when you use it (a provider not listed is still inherited only if you pass nothing at all for it).
configuration_aliases
A module that wants more than one configuration of a provider declares the alias names it expects with
configuration_aliases in its required_providers, and its resources select them with provider:
# modules/replicated-bucket/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
configuration_aliases = [aws.primary, aws.replica]
}
}
}
# modules/replicated-bucket/main.tf
resource "aws_s3_bucket" "primary" {
provider = aws.primary
bucket = "${var.name}-primary"
}
resource "aws_s3_bucket" "replica" {
provider = aws.replica
bucket = "${var.name}-replica"
}
The caller wires each expected name to one of its own configurations:
module "covers" {
source = "./modules/replicated-bucket"
name = "bookshelf-covers"
providers = {
aws.primary = aws
aws.replica = aws.us_east
}
}
Legacy modules and static association
Since Terraform 0.13 a module that contains its own provider blocks cannot be used with count, for_each or
depends_on, and Terraform reports an error such as "Module cannot be used with count because it contains a nested
provider configuration". Remove the provider blocks from the module and pass configurations in. Because the
association is static, instances of one counted or iterated module share the same provider configuration; use
separate module blocks when each needs a different one. A module that holds both resources and the provider that
manages them is also hard to remove, as the provider configuration must still exist to destroy its resources.
Authoring guidance for reusable modules is in
Module Development and Publishing.
Provider tiers
The registry groups providers into tiers that signal who builds and maintains them:
| Tier | Meaning |
|---|---|
Official |
Owned and maintained by HashiCorp (namespace |
Partner |
Written, maintained, validated and published by a technology partner for its own platform. Partners who meet additional requirements earn the Partner Premier badge. |
Community |
Published by individuals, groups or other community members under their own namespace, for example
|
Archived |
Official or partner providers that are no longer maintained, often because the API was retired or interest faded. |
Source: Terraform Registry — Providers
The tier does not change how Terraform treats a provider; it is information for choosing dependencies. Whatever the
tier, the lock file protects against a package changing under a fixed version, and the registry publishes release
signatures that terraform init verifies. OpenTofu has its own registry at registry.opentofu.org with a
compatible API and tooling, discussed under Terraform and OpenTofu differences.
The plugin cache
Each initialized working directory normally gets its own copy of every provider in .terraform/providers. Provider
binaries are large (the AWS provider is hundreds of megabytes), so a repository with many root modules, or a CI
runner that runs init repeatedly, downloads and stores them over and over. A shared plugin cache avoids that.
# ~/.terraformrc (macOS and Linux) or %APPDATA%\terraform.rc (Windows)
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"
mkdir -p "$HOME/.terraform.d/plugin-cache"
# or, per shell or pipeline, without a CLI configuration file
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
terraform init
Source: CLI Configuration File
How it behaves:
-
The directory must already exist; Terraform does not create it. On Windows use forward slashes in the setting.
-
terraform initstill contacts the registry (or mirror) for metadata. If the cache holds the selected version it is linked or copied into the working directory; otherwise Terraform downloads it into the cache first. Symlinks are used where possible. -
Terraform never deletes cached plugins, so remove old versions yourself.
-
The cache is not guaranteed to be safe under concurrent
terraform initruns. -
The cache directory must not be the same directory as a filesystem mirror.
-
By default Terraform uses a cached package only if its checksum matches an entry in the lock file. That ensures a complete lock entry is created the first time a provider is used. Setting
plugin_cache_may_break_dependency_lock_file = true(orTF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE) relaxes this, but may write an incomplete lock entry that fails on another operating system or architecture, and the documentation says the option will eventually be silently ignored. Preferterraform providers lockinstead.
In CI, caching the directory between runs (with a cache key derived from the hash of .terraform.lock.hcl) gives the
same speed-up; see CI/CD and automation for a pipeline
that does this.
Mirrors and the CLI configuration
By default Terraform downloads providers directly from their origin registry. Air-gapped networks, strict egress policies, reproducibility requirements or registry outages call for a mirror: a copy of the packages that Terraform reads instead. Mirrors are configured in the CLI configuration file, not in the Terraform configuration, because they belong to the machine rather than to the project.
The CLI configuration file
The CLI configuration is named .terraformrc in the home directory on macOS and Linux and terraform.rc in
%APPDATA% on Windows. Setting TF_CLI_CONFIG_FILE points Terraform at a different file (which should be named
*.tfrc), a convenient way to keep a per-project or per-pipeline configuration. It uses HCL syntax and holds,
among other settings, plugin_cache_dir, credentials blocks and the provider_installation block.
Building a mirror
terraform providers mirror downloads the providers required by the current configuration into a directory with the
layout Terraform expects of a filesystem mirror:
terraform providers mirror \
-platform=linux_amd64 \
-platform=darwin_arm64 \
./provider-mirror
Source: Command: providers mirror
By default it fetches packages for the current platform; repeat -platform for others. If the directory was
initialized before, it uses the versions in the lock file (-lock-file=false ignores them). -var and -var-file
set root module variables that influence provider requirements. Besides the .zip packages, the command writes JSON
index files that implement the network mirror protocol, so uploading the same directory to a static web host also
produces a network mirror; Terraform ignores those files when the directory is used as a filesystem mirror. Running
the command again on an existing directory adds new packages and merges the index files without removing earlier
packages.
Installing from a mirror
The provider_installation block lists installation methods, each with optional include and exclude patterns
(exclude wins). Terraform tries every method whose patterns match a provider and picks the newest version that
satisfies the constraints.
provider_installation {
filesystem_mirror {
path = "/opt/terraform/provider-mirror"
include = ["registry.terraform.io/hashicorp/*", "registry.terraform.io/kreuzwerker/*"]
}
network_mirror {
url = "https://mirror.example.com/providers/"
include = ["terraform.example.com/*/*"]
}
direct {
exclude = [
"registry.terraform.io/hashicorp/*",
"registry.terraform.io/kreuzwerker/*",
"terraform.example.com/*/*",
]
}
}
Source: CLI Configuration File
The methods are:
| Method | Behaviour |
|---|---|
|
Download from the provider’s origin registry. Takes only |
|
Read from a local directory given by |
|
Read from an HTTPS server implementing the provider network mirror protocol. The |
|
Development only: point named providers at a local build directory, bypassing version and checksum verification (see below). |
Source: CLI Configuration File
Without a provider_installation block Terraform uses an implied configuration: filesystem mirrors in well-known
directories (for example ~/.terraform.d/plugins, the XDG data directories on Linux, and terraform.d/plugins in the
current working directory) followed by direct, with providers found in a mirror automatically excluded from
direct. As soon as you write an explicit block, you take over these exclusions: to mirror exclusively, remove
direct or exclude every mirrored provider from it, otherwise Terraform may still use the registry.
Forcing a directory with -plugin-dir
terraform init -plugin-dir=PATH forces providers to be read only from one directory, as if it were configured as a
filesystem_mirror. The documentation recommends the CLI configuration for routine use and -plugin-dir for
one-off overrides, such as testing a local build. The older -get-plugins=false option is obsolete since
Terraform 0.13.
Source: Command: init
Testing a local provider build
dev_overrides points a provider at a local directory containing the executable, which is how provider developers
iterate without publishing. Put it first in provider_installation, keep direct for everything else, and place the
file outside the project (for example dev.tfrc selected with TF_CLI_CONFIG_FILE). terraform init still records a
published version in the lock file, while other commands ignore the lock entry for the overridden provider. It
disables version and checksum verification and may change in future releases, so use it only temporarily. After the
change is released, run terraform init -upgrade to refresh the lock file and drop the override.
provider_installation {
dev_overrides {
"example.com/bookshelf/bookshelf" = "/home/developer/src/terraform-provider-bookshelf"
}
direct {}
}
Source: CLI Configuration File
How terraform init resolves providers
Putting the pieces together, terraform init follows this path for each provider in the dependency tree:
The documentation does not spell out an explicit ordering between the lock file, the mirrors and the registry; the
diagram follows the described behaviour: the lock file (or -upgrade) decides which version, and the
provider_installation methods and the cache decide where the bytes come from.
Source: Command: init
Provider-defined functions
Since Terraform 1.8 (and OpenTofu 1.7) a provider can offer functions that are used in expressions alongside the
built-in ones. They are called with the prefix provider::<local name>::, where the local name must appear in
required_providers:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
terraform = {
source = "terraform.io/builtin/terraform"
}
}
}
locals {
extra_tfvars = provider::terraform::encode_tfvars({
environment = "staging"
})
catalog_role_arn = provider::aws::arn_parse(var.catalog_role_arn)
}
Source: Terraform Docs — Functions
The built-in terraform provider contributes encode_tfvars, decode_tfvars and encode_expr. Functions from other
providers are documented on those providers' registry pages (for example arn_parse and arn_build in the AWS
provider). The general function catalogue is in
Functions.
Variables and locals in provider requirements
Terraform 1.17 adds the ability to use input variables and locals inside required_providers, for example to select a
provider source from a private registry or to centralize a version. The values are needed while terraform init is
choosing and installing providers, before any plan exists, so they follow the same rule as dynamic module sources:
a variable must be declared with const = true, and the values cannot depend on any result computed during a plan.
terraform {
required_providers {
random = {
source = "app.terraform.io/${var.organization}/random"
version = local.random_version
}
}
}
variable "organization" {
type = string
const = true
}
locals {
random_version = "3.9.1"
}
Const variables can be supplied in the usual ways: -var, -var-file, TF_VAR_ environment variables or .tfvars
files. The same commands that read provider requirements (providers mirror, providers lock) accept -var and
-var-file so they can resolve them. The feature is new in 1.17 and the registry documentation for
required_providers itself does not yet describe it, so confirm behaviour against the release notes and the
variable block reference for the version you run. OpenTofu has allowed variables and locals in module sources and backend
configurations since 1.8; whether it accepts them in provider requirements is not documented on the pages used for
this guide, so check the OpenTofu documentation (see OpenTofu).
OpenTofu provider for_each
OpenTofu 1.9 added for_each to aliased provider blocks, so one block can produce an instance per element of a map,
object or set of strings. This removes the need to hand-write one alias per region.
variable "aws_regions" {
type = map(object({
vpc_cidr_block = string
}))
}
provider "aws" {
alias = "by_region"
for_each = var.aws_regions
region = each.key
}
resource "aws_vpc" "private" {
for_each = var.aws_regions
provider = aws.by_region[each.key]
cidr_block = each.value.vpc_cidr_block
}
The rules, as documented by OpenTofu:
-
Only configurations with an
aliascan usefor_each; the default configuration must stay a single instance. -
The map key (or the string value of a set) becomes the instance key, selected with
aws.by_region["eu-west-1"]oraws.by_region[each.key]. The provider reference remains static; only the key is dynamic. -
Provider arguments can use only values known before apply (variables are safe; resource attributes are not).
-
The resource’s
for_eachshould not reuse the provider’s expression: removing an element then fails, because a provider instance must remain in the configuration until its resource instances are destroyed. Removal takes extra plan and apply rounds, withnullvalues or a subset expression as workarounds; otherwise OpenTofu reports "Provider instance not present". -
A
moduleblock can pass a chosen instance withproviders, and every resource in the module then uses it.
Terraform has no equivalent: there, regions and accounts remain static aliases (or separate root modules, or Terraform Stacks, covered in HCP Terraform and Enterprise).
Terraform and OpenTofu differences
| Area | Terraform 1.17 | OpenTofu 1.13 |
|---|---|---|
Registry and addresses |
|
|
Lock file |
|
The same file name and format. Because packages are fetched from the OpenTofu registry, refresh the hashes with
|
Provider-defined functions |
Since 1.8. |
Since 1.7. |
Provider |
Not available. |
Since 1.9, aliased providers only. |
Variables and locals in provider requirements |
1.17, via |
Early evaluation exists for module sources and backends (since 1.8); check the OpenTofu docs for provider requirements. |
OCI registries as provider mirrors |
Not available. |
Added in 1.10 as a new |
|
Ignored. |
Loaded in preference to the same-named |
Read the OpenTofu migration guide before switching a production project. A provider that is missing from one registry can still be used by giving its full source address with a hostname.
Pitfalls
-
Unpinned providers: a
required_providersentry withoutversion, or a lock file that is not committed, means two machines can run different provider builds, and a major provider release can change behaviour overnight. -
Provider blocks in child modules: they block
count,for_eachanddepends_onon the module and make removal hard. Keep provider blocks in the root module and pass them in. -
Forgetting
provideron resources in an aliased setup: a resource without the meta-argument silently uses the default configuration, so a "replica" bucket may be created in the primary region. -
Lock file churn between platforms: run
terraform providers lockfor every platform the team uses. -
-upgradein CI: it makes pipelines non-reproducible; do it in a reviewed change and commit the new lock file, and use-lockfile=readonlyin the pipeline. -
An explicit
provider_installationwithout adirectexclusion: Terraform quietly falls back to the internet registry for providers you meant to serve from the mirror. -
Credentials in provider blocks or
.tfvarsfiles: use workload identity andTF_VAR_variables instead.
What changed recently
-
Terraform 1.17 (2026-10-21): variables and locals can be used in provider requirements (const variables only).
terraform initlog messages now include provider versions. -
OpenTofu 1.13 (current release 1.13.2):
tofu providers lockgained-oci-mirror, mirroring theoci_mirrorblock ofprovider_installationthat arrived in 1.10. -
Provider majors: the AWS provider is in its 6.x line (6.70.0 at the time of writing; the v5 to v6 upgrade is covered in Terraform on AWS) and the Docker provider in its 4.x line (4.6.0), which the older 2.x and 3.x examples in books and tutorials predate.
-
Terraform 1.8 and OpenTofu 1.7 introduced provider-defined functions; OpenTofu 1.9 introduced provider
for_each. -
Plugin cache and the lock file:
plugin_cache_may_break_dependency_lock_fileremains available but is documented as a stopgap that will be silently ignored once the lock-file mechanism improves. -
-get-pluginsand theprovidersblock of the CLI configuration: superseded since Terraform 0.13 byprovider_installationandplugin_cache_dir. -
Books written for Terraform 0.12 or earlier show provider
versioninside theproviderblock, resource types resolved withoutrequired_providers, and~/.terraform.d/pluginspopulated by hand; since 0.13 therequired_providerssource address is mandatory in practice and theprovider_installationblock is the supported way to use mirrors.
The overall list of migration notes is collected in What Changed and Migration.