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#
| Constraint | Meaning | Example |
|---|---|---|
= 5.31.0 |
Exact version only | Pin for maximum reproducibility |
~> 5.0 |
Any 5.x (>=5.0.0, <6.0.0) | Allow minor + patch updates |
~> 5.31 |
Any 5.31.x (>=5.31.0, <5.32.0) | Allow patch updates only |
>= 5.0, < 6.0 |
Range | Same as ~> 5.0 but explicit |
>= 5.0 |
Any version 5.0 or newer | Dangerous — 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 fileThe 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_arm64Gotcha: 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#
| Gotcha | Symptom | Fix |
|---|---|---|
| No version constraint | Provider upgrades break plan | Always set version constraints in required_providers |
| Missing lock file | Different versions on different machines | Commit .terraform.lock.hcl to Git |
| Single-platform lock file | CI fails with hash mismatch | Run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 |
| Default provider to wrong region | Resources created in wrong region | Always set region explicitly |
Forgot provider = on resource |
Resource uses default instead of alias | Always specify provider = aws.alias for non-default |
| Module not passed provider | Module uses default instead of intended alias | Pass providers explicitly via providers = { } block |
features {} missing in azurerm |
Init fails with unclear error | Always include features {} even if empty |
| EKS/AKS provider chicken-and-egg | K8s provider fails because cluster does not exist yet | Separate root modules or use -target |
default_tags drift |
tags_all shows changes every plan |
Known issue — use ignore_changes = [tags_all] if needed |