Terraform Provider Configuration Patterns#

Providers are Terraform’s interface to cloud APIs. Misconfiguring them causes resources to be created in the wrong region, with the wrong credentials, or with an incompatible provider version. These failures are often silent — Terraform succeeds, but the resource is in the wrong place.

Version Constraints#

The Required Providers Block#

Every configuration should declare its providers with version constraints:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.25"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.12"
    }
  }
}

Version Constraint Syntax#

ConstraintMeaningExample
= 5.31.0Exact version onlyPin for maximum reproducibility
~> 5.0Any 5.x (>=5.0.0, <6.0.0)Allow minor + patch updates
~> 5.31Any 5.31.x (>=5.31.0, <5.32.0)Allow patch updates only
>= 5.0, < 6.0RangeSame as ~> 5.0 but explicit
>= 5.0Any version 5.0 or newerDangerous — allows breaking changes

Recommended patterns:

# For root modules (your own configurations): pin to minor
version = "~> 5.31"   # allows 5.31.0, 5.31.1, etc. but not 5.32.0

# For shared modules (used by others): allow wider range
version = ">= 5.0, < 6.0"   # consumer can choose any 5.x

# For CI/CD reproducibility: use lock file
# terraform init creates .terraform.lock.hcl  commit this file

The Lock File#

.terraform.lock.hcl records the exact provider versions and hashes used. Commit it to version control:

# .terraform.lock.hcl — auto-generated, commit to Git
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.31.0"
  constraints = "~> 5.31"
  hashes = [
    "h1:abc123...",
    "zh:def456...",
  ]
}
# Update lock file when you want newer provider versions
terraform init -upgrade

# Verify lock file is consistent
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64

Gotcha: If you run terraform init on macOS (darwin_arm64) and CI runs on Linux (linux_amd64), the lock file needs hashes for both platforms. Use terraform providers lock to add multiple platform hashes.

Provider Aliasing#

Multi-Region Deployment#

# Default provider — primary region
provider "aws" {
  region = "us-east-1"
}

# Aliased provider — secondary region
provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

# Resources use the default provider unless specified
resource "aws_vpc" "primary" {
  cidr_block = "10.0.0.0/16"
  # Uses default provider (us-east-1)
}

resource "aws_vpc" "secondary" {
  provider   = aws.west
  cidr_block = "10.1.0.0/16"
  # Uses aliased provider (us-west-2)
}

Multi-Account Deployment#

provider "aws" {
  region = "us-east-1"
  # Default: uses current credentials (management account)
}

provider "aws" {
  alias  = "production"
  region = "us-east-1"
  assume_role {
    role_arn     = "arn:aws:iam::111111111111:role/terraform"
    session_name = "terraform-production"
  }
}

provider "aws" {
  alias  = "staging"
  region = "us-east-1"
  assume_role {
    role_arn     = "arn:aws:iam::222222222222:role/terraform"
    session_name = "terraform-staging"
  }
}

Azure Multi-Subscription#

provider "azurerm" {
  features {}
  subscription_id = var.platform_subscription_id
}

provider "azurerm" {
  alias           = "workload"
  features {}
  subscription_id = var.workload_subscription_id
}

GCP Multi-Project#

provider "google" {
  project = var.platform_project_id
  region  = "us-central1"
}

provider "google" {
  alias   = "workload"
  project = var.workload_project_id
  region  = "us-central1"
}

Passing Providers to Modules#

Modules inherit the default provider automatically. For aliased providers, you must pass them explicitly:

# Root module
provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

module "dr_vpc" {
  source = "./modules/vpc"

  providers = {
    aws = aws.west  # pass the aliased provider as the module's default
  }

  vpc_cidr = "10.1.0.0/16"
}

For modules that use multiple providers:

# Module that deploys to two regions
module "cross_region" {
  source = "./modules/cross-region"

  providers = {
    aws.primary = aws          # root module's default → module's aws.primary
    aws.dr      = aws.west     # root module's aws.west → module's aws.dr
  }
}

Inside the module, declare the required providers with configuration_aliases:

# modules/cross-region/providers.tf
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = "~> 5.0"
      configuration_aliases = [aws.primary, aws.dr]
    }
  }
}

# modules/cross-region/main.tf
resource "aws_vpc" "primary" {
  provider   = aws.primary
  cidr_block = "10.0.0.0/16"
}

resource "aws_vpc" "dr" {
  provider   = aws.dr
  cidr_block = "10.1.0.0/16"
}

Gotcha: If a module declares configuration_aliases, the caller MUST pass all aliased providers. Missing one causes a confusing error about provider configuration not found.

Authentication Patterns#

Local Development#

# AWS — uses ~/.aws/credentials or AWS_PROFILE
provider "aws" {
  region  = "us-east-1"
  profile = "dev"  # or omit for default profile
}

# Azure — uses az login credentials
provider "azurerm" {
  features {}
}

# GCP — uses gcloud auth application-default login
provider "google" {
  project = "my-project"
  region  = "us-central1"
}

CI/CD with OIDC (No Stored Credentials)#

The most secure CI/CD authentication: no secrets stored anywhere.

# AWS — OIDC via GitHub Actions
# The assume_role is handled by aws-actions/configure-aws-credentials
# Provider just needs the region
provider "aws" {
  region = "us-east-1"
}

# Azure — OIDC via GitHub Actions
provider "azurerm" {
  features {}
  use_oidc = true
}

# GCP — Workload Identity Federation via GitHub Actions
provider "google" {
  project = var.project_id
  region  = "us-central1"
}

CI/CD with Service Account Key#

# GCP — service account key file (less secure than OIDC)
provider "google" {
  credentials = file(var.credentials_file)
  project     = var.project_id
  region      = "us-central1"
}

Agent rule: Always recommend OIDC over stored credentials. OIDC tokens are short-lived and cannot be leaked from CI/CD secrets.

Provider-Specific Configuration#

AWS Provider Features#

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

  # Default tags applied to all resources
  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Project     = var.project
    }
  }

  # Retry configuration for API throttling
  retry_mode  = "adaptive"
  max_retries = 10
}

Gotcha: default_tags are inherited by all resources but show as tags_all in state. If you also set tags on a resource, both tags and tags_all appear in state — and tags_all always shows plan diffs if there are default tags. This is a known provider issue.

Azure Provider Features Block#

provider "azurerm" {
  features {
    resource_group {
      prevent_deletion_if_contains_resources = true
    }
    key_vault {
      purge_soft_delete_on_destroy    = false
      recover_soft_deleted_key_vaults = true
    }
    virtual_machine {
      delete_os_disk_on_deletion     = true
      graceful_shutdown              = true
      skip_shutdown_and_force_delete = false
    }
  }
}

Gotcha: The features {} block is required even if empty. Without it, terraform init fails with a confusing error.

Kubernetes Provider from EKS/AKS/GKE#

# Kubernetes provider configured from EKS cluster
provider "kubernetes" {
  host                   = aws_eks_cluster.main.endpoint
  cluster_ca_certificate = base64decode(aws_eks_cluster.main.certificate_authority[0].data)

  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", aws_eks_cluster.main.name]
  }
}

# Kubernetes provider configured from AKS cluster
provider "kubernetes" {
  host                   = azurerm_kubernetes_cluster.main.kube_config[0].host
  client_certificate     = base64decode(azurerm_kubernetes_cluster.main.kube_config[0].client_certificate)
  client_key             = base64decode(azurerm_kubernetes_cluster.main.kube_config[0].client_key)
  cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.main.kube_config[0].cluster_ca_certificate)
}

# Kubernetes provider configured from GKE cluster
provider "kubernetes" {
  host  = "https://${google_container_cluster.main.endpoint}"
  token = data.google_client_config.default.access_token
  cluster_ca_certificate = base64decode(google_container_cluster.main.master_auth[0].cluster_ca_certificate)
}

Gotcha: These providers depend on the cluster existing. On first apply (creating the cluster), the Kubernetes provider cannot configure itself. Use -target to create the cluster first, then apply the Kubernetes resources, or use a separate root module for cluster creation vs cluster configuration.

Common Provider Gotchas#

GotchaSymptomFix
No version constraintProvider upgrades break planAlways set version constraints in required_providers
Missing lock fileDifferent versions on different machinesCommit .terraform.lock.hcl to Git
Single-platform lock fileCI fails with hash mismatchRun terraform providers lock -platform=linux_amd64 -platform=darwin_arm64
Default provider to wrong regionResources created in wrong regionAlways set region explicitly
Forgot provider = on resourceResource uses default instead of aliasAlways specify provider = aws.alias for non-default
Module not passed providerModule uses default instead of intended aliasPass providers explicitly via providers = { } block
features {} missing in azurermInit fails with unclear errorAlways include features {} even if empty
EKS/AKS provider chicken-and-eggK8s provider fails because cluster does not exist yetSeparate root modules or use -target
default_tags drifttags_all shows changes every planKnown issue — use ignore_changes = [tags_all] if needed