
Terraform providers are the link between your HCL configuration files and the APIs of cloud platforms, SaaS services, and other infrastructure systems. They're the backbone of infrastructure as code. How well you configure, manage, and version them decides whether your deployments stay reproducible as your team and infrastructure grow.
This guide covers Terraform provider management end to end, from basic configuration to the advanced patterns and practices you'll want in 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 lets Terraform talk to a specific cloud provider (AWS, Azure, Google Cloud), a SaaS platform, an API, or any external service with a REST or gRPC interface. The provider configuration tells Terraform how to authenticate and connect to those services.

Without a configured provider, Terraform has no way to manage your resources. Each provider plugin adds a set of resource types and data sources that your infrastructure code can then manage.
Providers handle:
Configuring a provider takes two steps: declare the required providers, then configure 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, so it's the natural 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, with 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, like cost estimators and tag-policy checkers, can't resolve variable references the way Terraform itself does. Use plain values in default_tags where you can, and either way, check 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 TerraformWhen you need more than one configuration of the same provider, use provider aliases. You'll want them 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 are what make your deployments reproducible. They pin the specific provider versions that get downloaded and used in every environment.
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 handy because it balances stability against 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 grabs the newest provider version that matches your constraints every time it runs. That's how you get the "works on my machine" problem: different environments end up on different provider versions and start behaving differently.
Lock files solve three problems:
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 come up a lot, especially on multi-platform teams. Knowing how to diagnose and fix them saves you a bad afternoon.
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 call for different ways to authenticate.
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 gets rid of 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 show 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, since 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 the layer that turns your HCL into real API calls, so most of the failures in this guide trace back to how providers are configured, versioned, and authenticated. Get those three right and the rest of your pipeline has far less to go wrong.
Five practices cover most of it:
Whether you manage a single provider or run deployments across several clouds and accounts, the practices in this guide give you something solid to build on.
If you're running Terraform at scale, it's worth looking at an IaC management platform like Scalr. It centralizes provider credentials, enforces policies, and gives you visibility into how providers are configured and used across your whole infrastructure portfolio. Scalr's pricing is usage-based: you pay only for runs that execute, the free tier covers up to 50 runs a month, and there are no concurrent run slots to reserve in advance.
This blog has been verified for Terraform and OpenTofu
