Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 005 / 100

Terraform providers explained: best practices explained

Plugins that talk to cloud APIs.

Expona Admin6 min read

Introduction

Terraform providers explained: best practices explained means understanding how Terraform connects to cloud APIs and managing those connections reliably across your team and infrastructure. We often see production incidents trace back to provider version mismatches, misconfigured credentials, or undeclared provider dependencies. Getting providers right early saves hours of debugging later.

What are Terraform providers?

Providers are plugins that act as the bridge between your Terraform configuration and cloud APIs. When you write resource "aws_instance" "web" {}, Terraform uses the AWS provider plugin to translate that into actual API calls to AWS. Each provider—whether it's AWS, Google Cloud, Azure, Kubernetes, or a SaaS platform—handles authentication, communication, and resource lifecycle management.

Providers are not automatically included with Terraform. You must declare them explicitly. This is where many teams stumble: assuming a provider is available globally, or mixing different versions across environments. Explicit declaration prevents surprises.

Declare providers in required_providers

The required_providers block is your contract with your codebase. It lists every provider your configuration needs, the source where Terraform should fetch it, and the version constraint. This block belongs at the root of your Terraform configuration, typically in a versions.tf or terraform.tf file.

Here's a realistic example from an e-commerce platform we've worked on:

hcl
terraform {
  required_version = ">= 1.5"
  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.30"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.11"
    }
  }
}

The source attribute tells Terraform exactly where to find the provider. For HashiCorp-maintained providers, the pattern is hashicorp/provider-name. For community or vendor-specific providers, you might see terraform-providers/terraform-provider-xyz or a custom registry path.

Without required_providers, developers on your team might run Terraform with different provider versions, or newer developers might accidentally install a major version that breaks your infrastructure. Making it explicit prevents guesswork.

Pin versions with the ~> operator

Version pinning is about balancing stability with bug fixes. The caret operator ~> is your friend here. It allows patch version updates but prevents major version changes.

~> 5.30 means "accept any version from 5.30 up to but not including 6.0." This gives you security patches and minor fixes without risking breaking changes. Compare this to 5.30 (exact pin), which locks you in place and makes you miss critical updates, or 5.* (loose pin), which could upgrade to 5.99 and surprise you with behavior changes.

Let's look at a versioning scenario from a travel booking system:

Constraint What it allows Use case
= 5.30.0 Exactly 5.30.0 only Emergency lockdown, debugging production incidents
~> 5.30 5.30.0 through 5.99.x Standard production use; most teams do this
>= 5.30, < 6.0 5.30.0 through 5.99.x Same as ~> 5.30 but more explicit
5.* Any 5.x version Avoid: too loose for production

We recommend ~> for production systems because it's precise enough to be safe but flexible enough to receive patches. When a provider releases a new major version, your CI/CD will tell you clearly that an upgrade is available, and you can test it in staging first.

Multiple provider configurations with aliases

Real-world infrastructure often spans multiple regions, multiple cloud accounts, or requires different authentication methods for the same provider. Aliases let you declare the same provider multiple times with different configurations.

Here's an example from a SaaS platform serving multiple geographies:

hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.30"
    }
  }
}

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

  assume_role {
    role_arn = "arn:aws:iam::123456789:role/TerraformPrimary"
  }
}

provider "aws" {
  region = "eu-west-1"
  alias  = "eu"

  assume_role {
    role_arn = "arn:aws:iam::123456789:role/TerraformEU"
  }
}

resource "aws_s3_bucket" "app_data_us" {
  provider = aws.primary
  bucket   = "app-data-us-east"
}

resource "aws_s3_bucket" "app_data_eu" {
  provider = aws.eu
  bucket   = "app-data-eu-west"
}

Notice the alias attribute on each provider block. This distinguishes them. Then, when declaring a resource, you reference the specific provider with provider = aws.primary or provider = aws.eu.

Common scenarios for aliases include:

  • Multi-region deployments (us-east-1 and eu-west-1 need separate provider instances).
  • Multiple AWS accounts (production, staging, dev each with their own role ARN).
  • Different authentication methods (one provider with IAM role, another with access keys).
  • Cross-account cluster management (your Kubernetes provider points to a different cluster than your AWS provider).

Without aliases, you'd be forced to use separate Terraform workspaces or modules for each region/account combination, which complicates state management and code reuse.

The lock file pins exact versions

Terraform's lock file (.terraform.lock.hcl) is auto-generated and critical for team consistency. When you run terraform init, Terraform resolves your ~> 5.30 constraint to an actual version (say, 5.32.1), downloads the provider plugin, and records that exact version in the lock file.

This is what the lock file looks like:

hcl
# This file is maintained automatically by "terraform init".

provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.32.1"
  constraints = "~> 5.30"
  hashes = [
    "h1:abc123...",
    "h2:def456...",
  ]
}

provider "registry.terraform.io/hashicorp/kubernetes" {
  version     = "2.24.0"
  constraints = "~> 2.23"
  hashes = [
    "h1:ghi789...",
  ]
}

Always commit this file to version control. When your teammate or CI/CD pipeline runs terraform init after pulling your changes, Terraform reads the lock file and installs the exact same provider versions. No surprises. No "it worked on my machine" debugging.

If you want to upgrade a provider, run terraform init -upgrade (or use terraform init -upgrade=aws to upgrade only one). This updates the lock file. Test the changes in a dev environment, commit the updated lock file, and merge to main only after CI passes.

The hashes in the lock file are checksums that verify provider authenticity. Terraform will warn you if a provider's hash doesn't match, catching tampering or supply chain issues.

Common mistakes

Missing required_providers: Some teams forget to declare required_providers and rely on providers being "available globally." This works on their laptop but breaks in CI/CD because the runner doesn't have the provider. Always declare it.

Not committing the lock file: Forgetting to commit .terraform.lock.hcl defeats its purpose. Your CI/CD will resolve versions independently, potentially pulling newer patch versions than your local environment used. Commit the lock file as part of your changes.

Using loose version constraints: Constraints like 5.* or >= 5.0 are dangerous. A provider update can break your infrastructure silently. Use ~> to stay safe.

Forgetting aliases in multi-region setups: If you declare two AWS providers (for different regions) without aliases, Terraform will overwrite the first with the second, and you'll end up deploying everything to one region. Use aliases consistently.

Hardcoding credentials in provider blocks: Never put AWS access keys, Azure credentials, or API tokens directly in Terraform code. Use IAM roles, environment variables, or external credential files. Provider blocks should authenticate via secure methods only.

Key takeaways

  • Declare all providers explicitly in required_providers with source and version. This prevents version conflicts and makes dependencies clear to your team.
  • Pin provider versions with ~> (e.g., ~> 5.30) to get patches safely while avoiding breaking changes from major upgrades.
  • Use provider aliases when deploying across multiple regions, accounts, or authentication methods. Each resource references its provider explicitly.
  • Commit .terraform.lock.hcl to version control. It ensures your entire team and CI/CD use identical provider versions, eliminating environment drift.
  • Upgrade providers intentionally: run terraform init -upgrade, test in dev, review changes, then merge. Never let CI/CD auto-upgrade production infrastructure.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles