
TL;DR
required_providers with a version constraint, then configure auth/region/etc. in a provider block.~> (allow patch updates) for stability; never leave a provider unconstrained..terraform.lock.hcl to git and pre-populate hashes for every platform your team or CI runs on — this is the supply-chain security layer most teams skip.providers argument when calling modules.Terraform providers are the backbone of infrastructure as code, acting as the critical connection between your HCL configuration files and the APIs of cloud platforms, SaaS services, and other infrastructure systems. Understanding how to configure, manage, and version providers effectively is fundamental to building scalable, secure, and reproducible infrastructure.
This pillar article provides a complete guide to Terraform provider management, covering everything from basic configuration to advanced patterns and best practices for enterprise environments. Whether you're on Terraform or the open-source OpenTofu fork, the configuration model is identical — both use the same provider plugin protocol.
A Terraform provider is a plugin that enables Terraform to interact with specific cloud providers (AWS, Azure, Google Cloud), SaaS platforms, APIs, or any external service with a REST or gRPC interface. The provider configuration tells Terraform how to authenticate and connect to these services.

Without a properly configured provider, Terraform has no way of managing your resources. Each provider plugin adds a set of resource types and data sources that your infrastructure code can then manage.
Providers handle:
Configuring providers in Terraform involves two essential steps: declaring the required providers and then configuring them.
Provider requirements are defined in the required_providers block within the top-level terraform block. This tells Terraform where to find each provider and which versions are acceptable.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.0.0, < 4.0.0"
}
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}Each provider entry specifies:
aws): How you reference the provider in your configuration[hostname/]namespace/type)After declaring requirements, configure providers with provider blocks. This is where you specify authentication details and default settings.
# Default AWS provider
provider "aws" {
region = "us-east-1"
# Authentication typically handled via environment variables or IAM roles
}
# Azure provider
provider "azurerm" {
features {}
}
# Google Cloud provider
provider "google" {
project = "my-gcp-project"
region = "us-central1"
}The AWS provider's default_tags block applies a tag set to every taggable resource the provider manages, which makes it the standard home for cost-allocation and ownership tags:
provider "aws" {
region = "us-east-1"
default_tags {
tags = local.standard_tags
}
}Two failure modes show up repeatedly in Scalr's support queue, both involving the gap between what's written in the default_tags block and what actually lands in tags_all.
The first is merge-vs-replace semantics. A platform team we worked with at Scalr defined default_tags { tags = local.standard_tags } in code — cost-centre, environment, and app-tier tags — while their management platform layered its own default tag on top with duplicate-tag behavior set to "skip". They expected the two layers to merge. Instead, the platform-level tags replaced the code-level block entirely, and the cost-allocation tags vanished from tags_all on every resource. If anything in your toolchain injects default tags above the code level, confirm whether the layers merge or replace before you depend on either.
The second is expressions inside default_tags. One Scalr customer set Project = var.app_name and Environment = var.environment in the block, and their plans showed the literal strings "var.app_name" and "var.environment" in tags_all, alongside the warning Could not auto-resolve dynamic AWS default_tags via Terraform; using statically-parsed tags. Tooling that statically parses provider blocks — cost estimators, tag-policy checkers — cannot resolve variable references the way Terraform itself does. Prefer plain values in default_tags where practical, and in any case verify the resolved tags in plan output rather than trusting the source.
Never hardcode credentials in your configuration files. Instead:
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc. before running TerraformFor scenarios requiring multiple configurations of the same provider, use provider aliases. This is essential for:
# Default provider for us-east-1
provider "aws" {
region = "us-east-1"
}
# Aliased provider for us-west-2
provider "aws" {
alias = "west"
region = "us-west-2"
}
# Aliased provider for eu-west-1
provider "aws" {
alias = "europe"
region = "eu-west-1"
}Resources use the default provider unless explicitly specified:
# Uses default us-east-1 provider
resource "aws_instance" "app_east" {
ami = "ami-0c55b31ad20f0c502"
instance_type = "t3.micro"
tags = {
Name = "app-east"
}
}
# Uses west alias (us-west-2)
resource "aws_instance" "app_west" {
provider = aws.west
ami = "ami-068f09e03c69f0b76"
instance_type = "t3.micro"
tags = {
Name = "app-west"
}
}
# Uses europe alias (eu-west-1)
resource "aws_vpc" "europe_vpc" {
provider = aws.europe
cidr_block = "10.0.0.0/16"
tags = {
Name = "europe-vpc"
}
}When using modules that require specific provider configurations, pass them via the providers argument:
module "vpc_east" {
source = "./modules/vpc"
# Uses default provider
}
module "vpc_west" {
source = "./modules/vpc"
providers = {
aws = aws.west
}
cidr_block = "10.1.0.0/16"
}The child module must declare the provider in its required_providers block.
Provider requirements form the foundation of reproducible infrastructure deployments. They ensure that specific provider versions are downloaded and used consistently across all environments.
Terraform supports several operators for expressing version constraints:
| Operator | Description | Example | Effect |
|---|---|---|---|
= |
Exact version | = 5.0.0 |
Only version 5.0.0 |
!= |
Exclude version | != 5.0.1 |
Any version except 5.0.1 |
> |
Greater than | > 5.0.0 |
Version 5.0.1 and newer |
>= |
Greater or equal | >= 5.0.0 |
Version 5.0.0 and newer |
< |
Less than | < 6.0.0 |
Versions before 6.0.0 |
<= |
Less or equal | <= 5.10.0 |
Version 5.10.0 and earlier |
~> |
Pessimistic constraint | ~> 5.0.0 |
Only rightmost version component increments |
The ~> operator is particularly useful as it balances stability with automatic updates:
~> 5.0 allows any version in the 5.x series (5.0.0, 5.1.0, 5.9.9)~> 5.0.0 allows only patch updates (5.0.0, 5.0.1, 5.0.99)~> 5.1.0 allows patch updates starting from 5.1.0Create complex version ranges by combining constraints with commas:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0, != 5.5.0" # Allow 5.x except 5.5.0
}
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.50.0, < 4.0.0" # Range constraint
}
}
}Pinned versions (critical environments): Use exact versions
version = "5.67.1" # Exact version requiredReusable modules (libraries): Use looser constraints for compatibility
version = ">= 5.0.0" # Specify minimum version onlyRoot modules (applications): Use tight constraints for stability
version = "~> 5.67.0" # Allow patch updates onlyThe .terraform.lock.hcl file is Terraform's critical security and consistency mechanism. Introduced in Terraform 0.14, it records the exact provider versions and cryptographic checksums used in your configuration.
Without lock files, terraform init would select the latest provider version matching your constraints each time it runs. This creates the "works on my machine" problem where different environments use different provider versions, leading to inconsistent behavior.
Lock files solve three critical challenges:
A .terraform.lock.hcl file has this structure:
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.38.0"
constraints = "~> 5.0"
hashes = [
"h1:A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R9S0T1U2V3W4X5Y6Z7A8B9C0D1E2F3",
"zh:0573de96ba316d808be9f8d6fc8e8e68e0e6b614ed4d8d11eed83062c9b60714",
"zh:37560469042f5f43fdb961eb6c6b7f6e0bccec04c1c7cbf90f5d6d97893e6c3d",
# Additional platform-specific hashes...
]
}
provider "registry.terraform.io/hashicorp/azurerm" {
version = "3.85.0"
constraints = ">= 3.0, < 4.0"
hashes = [
# Hashes for this provider...
]
}Each provider block contains:
terraform init for the first timeterraform init -upgrade or terraform init with new providersLock files are NOT updated during terraform plan, terraform apply, or terraform destroy operations.
Lock files created on one platform (e.g., macOS) only contain checksums for that architecture. To support multi-platform teams, pre-populate checksums:
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_amd64 \
-platform=darwin_arm64 \
-platform=windows_amd64This ensures your lock file works across all team members' development machines and CI/CD pipelines.
.terraform.lock.hcl to .gitignoreterraform init -upgrade when you explicitly want to update providersLock file issues are common, especially in multi-platform and team environments. Understanding how to diagnose and resolve them is essential.
The most common error:
ERROR: Failed to install provider
Error while installing hashicorp/null v3.2.4: the current package for
registry.terraform.io/hashicorp/null 3.2.4 doesn't match any of the
checksums previously recorded in the dependency lock file.
Causes:
Solutions:
Delete and reinitialize (last resort):
rm .terraform.lock.hcl
terraform initUpgrade providers intentionally:
terraform init -upgradeThen commit the updated lock file.
Add checksums for all platforms (most common fix):
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_amd64Problem: Version constraints updated but lock file not refreshed
Solution:
terraform init -upgrade
git add .terraform.lock.hcl
git commit -m "Update provider versions"Problem: Multiple developers update different providers simultaneously
Solution: After resolving the merge conflict, run terraform init to validate all entries:
# Manually resolve conflict in .terraform.lock.hcl
terraform init
git add .terraform.lock.hcl
git commit -m "Resolve lock file merge conflict"Different providers and environments require different authentication approaches.
The simplest method for development:
# AWS
export AWS_ACCESS_KEY_ID="your-key"
export AWS_SECRET_ACCESS_KEY="your-secret"
# Azure
export ARM_CLIENT_ID="your-client-id"
export ARM_CLIENT_SECRET="your-secret"
export ARM_TENANT_ID="your-tenant-id"
# GCP
export GOOGLE_CREDENTIALS='{"type": "service_account", ...}'AWS IAM Roles (recommended for EC2, ECS, Lambda):
provider "aws" {
region = "us-east-1"
# Automatically uses EC2 instance role credentials
}Azure Managed Identities:
provider "azurerm" {
features {}
# Automatically uses managed identity credentials
}GCP Service Accounts:
provider "google" {
project = "my-project"
# Automatically uses default application credentials
}The most secure approach for automated deployments. (Note: Terraform Cloud's free tier was discontinued on March 31, 2026 — Scalr and Spacelift offer the same OIDC flow.)
terraform {
cloud {
organization = "my-org"
hostname = "app.terraform.io"
}
}
provider "aws" {
region = "us-east-1"
# Terraform Cloud handles OIDC token exchange for short-lived credentials
}OIDC removes static credentials, but it moves the failure surface into the IAM trust policy — and trust policies fail in ways that are hard to diagnose from the Terraform side. Two incidents from our own support queue illustrate the patterns to watch for.
In the first, an engineer at a customer tidied up what looked like a redundant condition in the trust policy of an IAM role — one role that, undocumented, backed both their state storage and their provider authentication. The two subsystems issue OIDC tokens with different sub claim formats: the storage integration's tokens carry a subject like scalr:account:<name>, while provider-configuration tokens carry account:<name>:environment:...:workspace:.... With one of the two StringLike patterns removed, every run in the organization failed, surfacing as a pair of errors that look unrelated: Cannot download the configuration version due to i/o error from the storage side and AccessDenied ... Not authorized to perform sts:AssumeRoleWithWebIdentity from the provider side. The whole org was blocked until both subject patterns were restored. Two lessons: different token issuers within the same platform can use different sub formats, and sharing one IAM role across state storage and provider auth without documenting it turns a one-line policy edit into an org-wide outage.
In the second, a team in a regulated industry copy-pasted a working commercial-AWS OIDC trust policy into their GovCloud account and got 403s on every assume-role call. GovCloud is a separate AWS partition: it needs its own OIDC identity provider resource registered in that partition, with its own thumbprints and ARNs. A trust policy is a per-partition, per-account artifact, not portable configuration you can lift between accounts.
For sensitive credentials, integrate with dedicated systems:
provider "aws" {
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::123456789012:role/terraform-role"
}
}The most widely used provider for cloud infrastructure.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "main-vpc"
}
}
resource "aws_subnet" "main" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
}See detailed guides: Top 10 Most Popular Terraform Providers and AWS Provider v6.0: What's Breaking in April 2025
For managing resources in Microsoft's cloud platform.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
resource "azurerm_storage_account" "main" {
name = "examplestg"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
}For Google Cloud Platform infrastructure.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
provider "google" {
project = "my-gcp-project"
region = "us-central1"
}
resource "google_storage_bucket" "main" {
name = "my-unique-bucket-name"
location = "US"
force_destroy = false
}
resource "google_compute_instance" "main" {
name = "web-server"
machine_type = "e2-micro"
zone = "us-central1-a"
}For managing Kubernetes clusters and resources.
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.0"
}
}
}
provider "kubernetes" {
config_path = "~/.kube/config"
config_context = "my-cluster"
}
resource "kubernetes_namespace" "example" {
metadata {
name = "example-namespace"
}
}
resource "kubernetes_deployment" "example" {
metadata {
name = "example-deployment"
namespace = kubernetes_namespace.example.metadata[0].name
}
spec {
replicas = 3
# ... deployment specification
}
}
See detailed guide: Mastering Kubernetes with Terraform: A Provider Deep Dive
For monitoring and observability infrastructure.
terraform {
required_providers {
datadog = {
source = "DataDog/datadog"
version = "~> 3.0"
}
}
}
provider "datadog" {
api_key = var.datadog_api_key
app_key = var.datadog_app_key
api_url = "https://api.datadoghq.com/"
}
resource "datadog_monitor" "cpu_alert" {
type = "metric alert"
query = "avg(last_5m):avg:system.cpu.user{*} > 0.9"
message = "CPU utilization is high"
}
See detailed guide: How to Use Terraform or OpenTofu to Manage Datadog
For identity and access management.
terraform {
required_providers {
okta = {
source = "okta/okta"
version = "~> 4.0"
}
}
}
provider "okta" {
org_name = var.okta_org_name
base_url = "okta.com"
api_token = var.okta_api_token
}
resource "okta_user" "example" {
first_name = "John"
last_name = "Doe"
login = "[email protected]"
email = "[email protected]"
}See detailed guide: How to Use the Terraform Okta Provider
Problem: Provider blocks without version constraints allow unexpected major version upgrades
# BAD: No version constraint
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
# Missing version!
}
}
}Solution: Always specify meaningful version constraints
# GOOD: Explicit version constraint
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Allow patch updates only
}
}
}Problem: Credentials visible in source code and version control
# BAD: Never do this
provider "aws" {
access_key = "AKIAIOSFODNN7EXAMPLE"
secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
region = "us-east-1"
}Solution: Use environment variables or cloud-native authentication
# GOOD: Credentials from environment
provider "aws" {
region = "us-east-1"
# Uses AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables
}
# BETTER: Use IAM roles or OIDC
provider "aws" {
region = "us-east-1"
# Automatically uses IAM role credentials
}Problem: Lock file ignored or not committed, leading to inconsistent provider versions
Solution:
# Remove from .gitignore if present
git rm --cached .terraform.lock.hcl
# Commit the lock file
git add .terraform.lock.hcl
git commit -m "Add Terraform provider lock file"Problem: Root module configures providers but modules redefine them differently
Solution: Configure providers only in root modules, have modules declare requirements without configuration
# In root module
provider "aws" {
region = "us-east-1"
}
# In child module
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# No provider block here - inherits from rootProblem: Credentials configured in a provider block authenticate the provider plugin — and nothing else. Anything Terraform shells out to, like a local-exec provisioner, starts with the runner's own environment and walks the default credential chain from there.
A storage team we worked with at Scalr hit this with a terraform_data resource running aws storagegateway update-gateway-information through local-exec. The apply itself succeeded — the provider's assume-role credentials were fine — but the CLI call failed with AccessDeniedException ... assumed-role/<agent>-instance-profile/i-... is not authorized. The AWS CLI had fallen back to the runner's EC2 instance profile, a completely different identity from the one the provider was using. The same configuration had worked in their lab environment and failed in dev, because the two environments differed in whether assumed-role credentials were exported into the shell.
Solution: If a provisioner or external script needs the provider's identity, export the assumed-role credentials into the subprocess environment explicitly (for example, via an assume-role wrapper that sets AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN). Do not assume the provider's auth context leaks downward — it doesn't.
Problem: On IaC platforms, a "provider configuration" object attached to a workspace supplies credentials to the provider — it does not populate Terraform input variables. The two are separate delivery mechanisms that happen to carry similar data.
A platform team migrating from Terraform Cloud created a GitHub App provider configuration (with github_app_id, github_app_pem_file, and github_app_installation_ids), linked it to their workspace, and every plan failed with Error: No value for required variable for all three. A pre-plan dump of the environment confirmed it: no TF_VAR_* injection at all, because their module declared those values as input variables, which provider configurations never set. Removing the app_auth block from the provider just moved the failure to 401 Requires authentication. The fix was recreating their old TFC variable-set wiring as workspace and shell variables.
Solution: Check how each value reaches Terraform. Settings consumed inside a provider block can come from a platform provider configuration or environment variables; values declared as variable blocks need TF_VAR_* environment variables, workspace variables, or .tfvars entries. When migrating between platforms, inventory every variable set on the old platform and recreate it through the equivalent mechanism — linking a provider configuration is not a substitute.
Terraform providers are fundamental to infrastructure as code. Mastering their configuration, management, and versioning practices is essential for building stable, secure, and reproducible infrastructure at scale.
The key to successful provider management is:
As infrastructure continues to grow in complexity, proper provider management becomes increasingly critical. Whether managing a single provider or orchestrating deployments across multiple clouds and accounts, the practices outlined in this guide will serve as a solid foundation for your infrastructure as code journey.
For enterprise organizations managing Terraform at scale, consider IaC management platforms like Scalr that centralize provider credential management, enforce policies, and provide visibility into how providers are configured and used across your entire infrastructure portfolio.
This blog has been verified for Terraform and OpenTofu
