← Back to Learning

Terraform Providers

Terraform's core knows nothing about AWS, Cloudflare, Kubernetes, or any other real system. Every resource type you actually write comes from a provider: a plugin that translates resource blocks into real API calls against one specific system. This is why Terraform can manage almost anything with the same workflow — the providers do the system-specific work, Terraform just orchestrates plan/apply/state.

Declaring and pinning a provider

terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.52"
    }
  }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

source is <namespace>/<name> in the Terraform Registry — almost always the vendor's own namespace for an official provider. version is a constraint, not an exact pin by default: ~> 4.52 allows any 4.52.x release but not 4.53. That's a deliberate middle ground between "pin the exact version and never get a bugfix without a manual bump" and "always take whatever is newest and get surprised by a breaking change mid-apply." For anything you actually depend on staying stable, pin tighter; a `.terraform.lock.hcl` file (generated by init, meant to be committed) additionally records the exact version and checksum actually installed, so a second person running init gets the exact same provider build, not just one that satisfies the constraint.

Authentication: prefer environment over hardcoding

The example above reads a token from a variable, which is already better than a literal string in the file — but where does that variable get its value? Almost every provider also supports reading credentials from an environment variable (for Cloudflare, CLOUDFLARE_API_TOKEN) with no provider block configuration at all. That's usually the better default: it means the actual secret never has to pass through a .tfvars file, a CI variable that gets logged, or anywhere else it could end up committed by accident. Reserve explicit provider-block credentials for cases where you genuinely need more than one identity active at once (see below).

More than one instance of the same provider

Sometimes one configuration genuinely needs two accounts, two regions, or two of anything the same provider talks to — most commonly, an AWS config that needs both us-east-1 and a second region for one global resource (ACM certificates for CloudFront famously have to live in us-east-1 regardless of where everything else runs). Provider aliases solve this without needing two separate provider plugins or two separate Terraform runs:

provider "aws" {
  region = "us-west-2"
}

provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

resource "aws_acm_certificate" "cert" {
  provider          = aws.us_east_1
  domain_name       = "example.com"
  validation_method = "DNS"
}

Every resource that doesn't set provider = ... explicitly uses the default (unaliased) provider instance. Only the ones that genuinely need the other one need the extra line.

Data sources: reading without managing

Not every provider block is about creating something. A data block reads existing information through the same provider without taking ownership of it — useful for referencing something that already exists (a DNS zone, an AMI, an existing VPC) without Terraform trying to manage its full lifecycle:

data "cloudflare_zone" "this" {
  name = "example.com"
}

resource "cloudflare_record" "www" {
  zone_id = data.cloudflare_zone.this.id
  name    = "www"
  type    = "CNAME"
  content = "example.com"
}

This is the same pattern that shows up constantly in real configurations: look something up with a data source, feed its output into the resources you're actually creating.