Skip to content
ioob.dev
Go back

Terraform Part 5 — Providers

· 6 min read
Terraform Series (5/15)
  1. Terraform Part 1 — What Is Terraform
  2. Terraform Part 2 — Installation and First Deploy
  3. Terraform Part 3 — HCL Syntax
  4. Terraform Part 4 — Variables and Outputs
  5. Terraform Part 5 — Providers
  6. Terraform Part 6 — Resources and Dependencies
  7. Terraform Part 7 — Data Sources and Import
  8. Terraform Part 8 — State Management
  9. Terraform Part 9 — Modules
  10. Terraform Part 10 — Loops and Conditionals
  11. Terraform Part 11 — Workspaces and Environment Separation
  12. Terraform Part 12 — Kubernetes and Helm Providers
  13. Terraform Part 13 — CI/CD Integration
  14. Terraform Part 14 — Testing and Policy
  15. Terraform Part 15 — Practical Patterns and Pitfalls
Table of contents

Table of contents

What Providers Do

When Terraform creates resources in AWS or GCP, it’s not Terraform Core that actually calls the APIs. Dedicated plugins for each cloud/service handle that work. These plugins are called providers.

Here’s the structure in a diagram.

flowchart TB
    CORE[Terraform Core<br/>Planning/State/Graph]
    subgraph Providers
        AWS[AWS Provider]
        GCP[GCP Provider]
        K8S[Kubernetes Provider]
        GH[GitHub Provider]
    end
    AWS_API[(AWS API)]
    GCP_API[(GCP API)]
    K8S_API[(Kubernetes API)]
    GH_API[(GitHub API)]

    CORE --> AWS
    CORE --> GCP
    CORE --> K8S
    CORE --> GH
    AWS --> AWS_API
    GCP --> GCP_API
    K8S --> K8S_API
    GH --> GH_API

Terraform Core acts as the “brain” — computing dependency graphs between resources, building execution plans, and managing state. The actual HTTP calls like “create an EC2 instance” are the provider’s job. Thanks to this division of labor, Terraform can manage platforms like AWS/GCP/Cloudflare/GitHub/Datadog with the same workflow.

As of 2025, over 3,000 providers are registered in the Terraform Registry. If “it has an API,” there’s probably a provider for it.

The Basic Form of a provider Block

Using a provider requires two steps. Declare the source and version in required_providers, then provide actual settings (region, profile, etc.) in the provider block.

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

provider "aws" {
  region  = "ap-northeast-2"
  profile = "terraform-demo"
}

The reason for the two-step split is separation of concerns. required_providers declares “what provider version this code requires,” while the provider block handles runtime configuration. When creating modules, it’s typical to only declare required_providers and have the provider block injected from the root module.

source — The Provider’s Address

source is the address that uniquely identifies a provider. It has three parts.

[HOST/]NAMESPACE/TYPE

Real examples make it clear.

required_providers {
  # HashiCorp official — namespace is hashicorp
  aws = {
    source  = "hashicorp/aws"
    version = "~> 5.0"
  }

  # Partner provider — namespace is the service vendor
  cloudflare = {
    source  = "cloudflare/cloudflare"
    version = "~> 4.0"
  }

  # Community provider — namespace is an individual/organization
  cloudinit = {
    source = "hashicorp/cloudinit"
  }
}

Omitting the namespace and writing just aws is interpreted as hashicorp/aws for backward compatibility, but modern Terraform recommends being explicit.

version — Version Constraints

Not specifying a provider version might be fine at first, but months later when you run terraform init fresh, a different version gets installed and unexpected changes appear. Version constraints are essential.

Operators

version = "5.0.0"       # Exactly this version
version = ">= 5.0"      # This version or higher
version = "<= 5.10"     # This version or lower
version = "~> 5.0"      # Latest within 5.x (major pinned)
version = "~> 5.1"      # Latest within 5.1.x (minor pinned)
version = ">= 5.0, < 6.0"  # Range

~> 5.0 in particular is called a “pessimistic constraint.” Same as Ruby’s ~> operator. Allows 5.0, 5.1, up to 5.99, but rejects 6.0. Since major version upgrades typically contain breaking changes, blocking them this way is safe practice.

Lock File

When you run terraform init, the selected version is pinned in .terraform.lock.hcl.

# .terraform.lock.hcl (auto-generated)
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.70.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:abc123...",
    ...
  ]
}

With the lock file in place, subsequent init runs use the same version. Commit this file to Git. That way team members and CI use identical providers.

To upgrade a provider, you must explicitly allow it.

terraform init -upgrade

This option ignores the lock file version and installs the latest version within the constraint range. Review the plan carefully before applying to production.

provider Block Settings

The provider "<name>" block holds provider-specific arguments. Let’s look at AWS as an example.

provider "aws" {
  region                   = "ap-northeast-2"
  profile                  = "terraform-demo"
  shared_credentials_files = ["~/.aws/credentials"]

  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Environment = var.environment
      Project     = var.project_name
    }
  }
}

default_tags is especially useful in practice. These tags are automatically applied to every AWS resource created with this provider. No need to repeat the same tags on every resource. If a resource needs different tags, specifying tags separately merges them.

Credentials can be mixed from multiple sources.

In practice, CI environment variables or IAM Role assume are typically used. For local labs, profiles are convenient. Never hardcode access keys in code.

alias — Same Provider, Different Settings

In practice, you often need to use two regions or two accounts simultaneously in one project. A classic example is “creating a CloudFront certificate in us-east-1 and deploying the rest in ap-northeast-2.” ACM certificates must be in us-east-1 to be attached to CloudFront.

This is where alias comes in.

# Default provider — Seoul region
provider "aws" {
  region  = "ap-northeast-2"
  profile = "terraform-demo"
}

# Additional provider with alias — Virginia region
provider "aws" {
  alias   = "us_east_1"
  region  = "us-east-1"
  profile = "terraform-demo"
}

To select a specific provider for a resource, specify provider explicitly.

# Seoul region resource (default)
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
}

# Certificate for CloudFront goes in the Virginia region
resource "aws_acm_certificate" "cdn" {
  provider          = aws.us_east_1
  domain_name       = "www.example.com"
  validation_method = "DNS"
}

# Data sources can also use alias
data "aws_ami" "us_east_amazon_linux" {
  provider    = aws.us_east_1
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

The structure of multi-region/account deployment can be visualized like this.

flowchart LR
    subgraph TF[Terraform Project]
        P1[provider aws<br/>default = Seoul]
        P2[provider aws<br/>alias = us_east_1]
        P3[provider aws<br/>alias = prod_account]
    end

    subgraph CLOUD[Cloud]
        R1[(Seoul: EC2, RDS)]
        R2[(N.Virginia: ACM Cert)]
        R3[(Prod Account: S3)]
    end

    P1 --> R1
    P2 --> R2
    P3 --> R3

The same provider (aws) but with different regions and accounts can be managed within a single project. Without knowing this, you’d get stuck at “how do I create a certificate in us-east-1?”

Passing Providers to Modules

There’s an important caveat when creating modules. Aliased providers are not automatically passed to child modules. You must explicitly pass them.

# Module side — declare required providers
# modules/cdn/versions.tf
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = "~> 5.0"
      configuration_aliases = [aws.us_east_1]
    }
  }
}

# Inside the module, use the alias explicitly
resource "aws_acm_certificate" "cdn" {
  provider          = aws.us_east_1
  domain_name       = var.domain_name
  validation_method = "DNS"
}
# Caller side — pass mapping via providers
module "cdn" {
  source = "./modules/cdn"

  providers = {
    aws.us_east_1 = aws.us_east_1
  }

  domain_name = "www.example.com"
}

configuration_aliases declares “this module needs this alias,” and the caller passes the actual mapping via the providers map. If this step is missing, you’ll get errors like “provider aws.us_east_1 is not defined.”

Official vs Partner vs Community

Providers in the Terraform Registry are divided into three tiers. Knowing which tier matters in practice.

Official

Maintained directly by HashiCorp. Namespace is hashicorp.

Support and updates are stable. The most widely used providers belong here.

Partner

Managed directly by the service vendor. Certified by HashiCorp. Namespace is the vendor name.

Since the vendor builds them directly, API updates are fast, and they can essentially be considered official.

Community

Providers contributed as open source by individuals or organizations. Namespace is a GitHub handle or organization name.

Before using, check maintenance status (recent commits, issue count), download count, and license. Sometimes the only provider for a specific SaaS is community-tier — get team consensus before adopting.

The priority for selection looks like this.

flowchart LR
    NEED[Need a provider] -->|1st choice| OFF[Official<br/>hashicorp/*]
    NEED -->|2nd choice| PART[Partner<br/>vendor/*]
    NEED -->|3rd choice| COM[Community<br/>individual/*]
    NEED -->|Last resort| CUSTOM[Build your own]

Community providers can be well-made, but be cautious before bringing them into production. If a repo has been abandoned for 2-3 years, you’re taking on that risk.

Using Multiple Providers Simultaneously

Real projects don’t use just one provider. You might create AWS resources while also deploying to Kubernetes, manage domains with Cloudflare, and handle monitoring with Datadog.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-2"
}

# Kubernetes auto-configures from EKS cluster info after creation
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]
  }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

An important note: when provider settings reference other resource attributes, a dependency is created. In the example above, the kubernetes provider depends on aws_eks_cluster.main, so Kubernetes resources can only be applied after the EKS cluster is created. Creating EKS and deploying resources inside it in a single apply can get tangled in practice. For production, it’s cleaner to separate “infrastructure (EKS)” and “applications (K8s resources)” into different Terraform projects.

Cache and Network Considerations

Provider binaries are large. The AWS provider easily exceeds 100MB. Repeatedly downloading the same provider across multiple projects wastes time and disk space.

The solution is plugin caching. Add the following to ~/.terraformrc (Linux/macOS) or %APPDATA%\terraform.rc (Windows).

plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

Create this directory and all Terraform projects will cache and share providers there. Essential for practical work switching between multiple projects.

Common Mistakes

What’s Next

Now that we’ve covered providers, it’s time to look closely at Terraform’s main stage: resources. We’ll examine the resource block structure, cross-resource references, implicit vs explicit dependencies, and lifecycle meta-arguments (create_before_destroy, prevent_destroy, ignore_changes) in detail.


-> Part 6: Resources and Dependencies


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform Part 4 — Variables and Outputs
Next Post
Terraform Part 6 — Resources and Dependencies