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 registry.terraform.io, so hashicorp/http is shorthand for registry.terraform.io/hashicorp/http.

Namespace

The organization that publishes the provider on the registry, such as hashicorp or kreuzwerker.

Type

A short name for the platform or system the provider manages, such as aws or docker. It is normally also the preferred local name.

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

= or none

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. ~> 1.0.4 accepts 1.0.5 and 1.0.10 but not 1.1.0; ~> 1.1 accepts 1.2 and 1.10 but not 2.0.

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 apply runs) should give every provider both bounds, normally with ~>, for example ~> 6.0 for 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.

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

version

The exact version Terraform selected. Later terraform init runs reselect it even if a newer one exists.

constraints

The version constraints that were in force when the selection was made. Informational only; it is not used when installing.

hashes

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 .zip package 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 adds h1: hashes opportunistically when a package matches an existing zh: 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

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

-platform=OS_ARCH

A platform you plan to use, such as linux_amd64; repeat for several.

-fs-mirror=PATH

Look for packages in a filesystem mirror directory instead of the registries. The directory must use the standard mirror layout.

-net-mirror=URL

Look for packages in a network mirror that implements the provider network mirror protocol.

-enable-plugin-cache

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:

  1. 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.

  2. 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.

  3. Environment variables — AWS_ACCESS_KEY_ID-style variables, DOCKER_HOST and so on, set by the shell or the CI system and never committed.

  4. Input variables marked sensitive and supplied through TF_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 hashicorp; the registry also lists the IBM namespaces). Examples: hashicorp/aws, hashicorp/random.

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 kreuzwerker/docker. Quality and maintenance vary; read the repository activity and issues before depending on one.

Archived

Official or partner providers that are no longer maintained, often because the API was retired or interest faded.

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

How it behaves:

  • The directory must already exist; Terraform does not create it. On Windows use forward slashes in the setting.

  • terraform init still 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 init runs.

  • 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 (or TF_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. Prefer terraform providers lock instead.

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

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/*/*",
    ]
  }
}

The methods are:

Method Behaviour

direct

Download from the provider’s origin registry. Takes only include and exclude.

filesystem_mirror

Read from a local directory given by path. The packed layout is HOSTNAME/NAMESPACE/TYPE/terraform-provider-TYPE_VERSION_TARGET.zip and the unpacked layout is HOSTNAME/NAMESPACE/TYPE/VERSION/TARGET, where TARGET is a platform such as linux_amd64. Unpacked packages are symlinked instead of copied.

network_mirror

Read from an HTTPS server implementing the provider network mirror protocol. The url must use https: and end with a slash. Do not use untrusted mirror URLs: a mirror can serve modified providers even with a valid TLS certificate.

dev_overrides

Development only: point named providers at a local build directory, bypassing version and checksum verification (see below).

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 {}
}

How terraform init resolves providers

Putting the pieces together, terraform init follows this path for each provider in the dependency tree:

flowchart TD A["terraform init"] --> B["Collect required_providers from the root module and all child modules"] B --> C{"Lock file entry exists and no -upgrade?"} C -- yes --> D["Use the locked version"] C -- no --> E["Choose the newest version that satisfies all constraints"] D --> F{"provider_installation methods that match"} E --> F F --> G{"Version already in plugin_cache_dir?"} G -- yes --> H["Check cached checksum against the lock file"] G -- no --> I["Fetch from the matching filesystem mirror, network mirror or origin registry"] I --> J["Verify the release signature or mirror package"] J --> K["Store in the plugin cache if configured"] K --> L["Install into .terraform/providers"] H --> L L --> M["Verify the package checksum against the lock file hashes"] M --> N["Add or update .terraform.lock.hcl unless -lockfile=readonly"]

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)
}

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 alias can use for_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"] or aws.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_each should 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, with null values or a subset expression as workarounds; otherwise OpenTofu reports "Provider instance not present".

  • A module block can pass a chosen instance with providers, 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

registry.terraform.io is the default hostname.

registry.opentofu.org is the default hostname for source addresses without one; use a full hostname to pin one registry or the other.

Lock file

.terraform.lock.hcl, shared format.

The same file name and format. Because packages are fetched from the OpenTofu registry, refresh the hashes with init -upgrade or providers lock after switching tools.

Provider-defined functions

Since 1.8.

Since 1.7.

Provider for_each

Not available.

Since 1.9, aliased providers only.

Variables and locals in provider requirements

1.17, via const = true variables.

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 oci_mirror kind of mirror; 1.13 adds -oci-mirror to providers lock.

.tofu files

Ignored.

Loaded in preference to the same-named .tf file.

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_providers entry without version, 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_each and depends_on on the module and make removal hard. Keep provider blocks in the root module and pass them in.

  • Forgetting provider on 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 lock for every platform the team uses.

  • -upgrade in CI: it makes pipelines non-reproducible; do it in a reviewed change and commit the new lock file, and use -lockfile=readonly in the pipeline.

  • An explicit provider_installation without a direct exclusion: Terraform quietly falls back to the internet registry for providers you meant to serve from the mirror.

  • Credentials in provider blocks or .tfvars files: use workload identity and TF_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 init log messages now include provider versions.

  • OpenTofu 1.13 (current release 1.13.2): tofu providers lock gained -oci-mirror, mirroring the oci_mirror block of provider_installation that 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_file remains available but is documented as a stopgap that will be silently ignored once the lock-file mechanism improves.

  • -get-plugins and the providers block of the CLI configuration: superseded since Terraform 0.13 by provider_installation and plugin_cache_dir.

  • Books written for Terraform 0.12 or earlier show provider version inside the provider block, resource types resolved without required_providers, and ~/.terraform.d/plugins populated by hand; since 0.13 the required_providers source address is mandatory in practice and the provider_installation block is the supported way to use mirrors.

The overall list of migration notes is collected in What Changed and Migration.