Terraform Cheatsheet

Providers

Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

What Providers Are

Providers are plugins that translate Terraform blocks into API calls for one platform. They ship independently of Terraform itself, on their own version numbers, and the Terraform Registry hosts thousands.

A provider supplies four things: resource types (aws_instance), data sources (data "aws_ami"), optionally functions (1.8+) and actions (1.14+), and optionally ephemeral resources (1.10+).

Declaring and Configuring

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"   # <namespace>/<type>, or <host>/<ns>/<type>
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

provider "aws" {
  region = "us-east-1"
  profile = "myprofile"           # ~/.aws/credentials profile
}

The key in required_providers (aws) is the local name. It is what provider "aws", aws_instance, provider = aws.west and provider::aws:: all refer to. You can rename a provider locally, which is how you use two forks of the same provider side by side:

required_providers {
  legacy_aws = { source = "acme/aws", version = "~> 4.0" }
}

Never hardcode credentials in a provider block. They land in plan files and often in state. Use environment variables, an AWS profile, OIDC in CI, or assume_role.

export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"

Version Constraint Operators

OperatorMeaningExample allows
= 6.2.0exact6.2.0 only
!= 6.3.0excludeanything but 6.3.0
>= 6.2.0at least6.2.0 and up, including 7.x
<= 6.9.0at mostup to 6.9.0
~> 6.0pessimistic on minor>= 6.0.0, < 7.0.0
~> 6.2.0pessimistic on patch>= 6.2.0, < 6.3.0
>= 6.2, < 6.9both hold6.2.0 up to 6.8.x

Use ~> MAJOR.0 for providers. Provider majors are where breaking changes land, so a major-pinned constraint gives you every bug fix and new resource type for free while never silently jumping a rewrite. A bare >= 6.0 lets a terraform init -upgrade pull 7.0 and break your config. An exact pin freezes you out of security fixes and forces a manual bump for every provider release.

Constraints from every module in the tree are intersected, so one child module pinned to = 6.1.0 narrows the whole configuration.

The Dependency Lock File

terraform init writes .terraform.lock.hcl recording the exact version and package checksums chosen. Commit it. It is the difference between "works on my machine" and a reproducible build.

# .terraform.lock.hcl (generated, do not hand-edit)
provider "registry.terraform.io/hashicorp/aws" {
  version     = "6.2.0"
  constraints = "~> 6.0"
  hashes = [
    "h1:...",
    "zh:...",
  ]
}
terraform init                      # respects the lock file
terraform init -upgrade             # re-resolve and rewrite the lock file
terraform init -lockfile=readonly   # fail rather than modify the lock

The multi-platform gotcha. A lock file written on an Apple Silicon laptop records only darwin_arm64 hashes. CI on linux_amd64 then fails with "checksums previously recorded in the lock file". Pre-record every platform you build on:

terraform providers lock \
  -platform=linux_amd64 \
  -platform=linux_arm64 \
  -platform=darwin_arm64 \
  -platform=windows_amd64

h1: hashes cover the whole package for the current platform. zh: hashes come from the registry and are what make cross-platform locking work.

Aliased Providers

Use alias for the same provider in more than one configuration, typically multiple regions or accounts.

provider "aws" {
  region = "us-east-1"            # the default, no alias
}

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

resource "aws_s3_bucket" "replica" {
  provider = aws.west             # <local_name>.<alias>, unquoted
  bucket   = "myapp-replica"
}

Rules:

  • A configuration may have at most one un-aliased ("default") configuration per provider.
  • Aliased configurations are never inherited by child modules. Pass them with providers = { aws = aws.west } on the module block.
  • A provider block cannot itself take count, for_each or depends_on. To fan out over N accounts you need one block per account, or one module instance per account.
  • A provider config may reference resource attributes, but doing so can create a chicken-and-egg problem where Terraform cannot plan. Prefer data sources or a two-stage apply.

Provider-Defined Functions (1.8+)

Providers can ship their own functions, namespaced so they cannot collide with built-ins.

# provider::<local_name>::<function>
locals {
  parsed  = provider::aws::arn_parse(aws_s3_bucket.app.arn)
  account = local.parsed.account_id

  tfvars  = provider::terraform::encode_tfvars({ example = "Hello!" })
}

The terraform built-in provider ships encode_tfvars, decode_tfvars and encode_expr. Everything else is per provider, so check that provider's registry docs. Functions need the provider declared in required_providers and installed, so a bare terraform validate before init will not resolve them.

Common Providers

ProviderSourceCredentials from
AWShashicorp/awsAWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY, profile, IRSA, OIDC
GCPhashicorp/googleGOOGLE_CREDENTIALS, or Application Default Credentials
Azurehashicorp/azurermARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID
Kuberneteshashicorp/kuberneteskubeconfig, or in-cluster service account
Helmhashicorp/helmsame as kubernetes
GitHubintegrations/githubGITHUB_TOKEN
Cloudflarecloudflare/cloudflareCLOUDFLARE_API_TOKEN
DatadogDataDog/datadogDD_API_KEY, DD_APP_KEY
Random / Null / TLS / Local / Timehashicorp/<name>none

AWS

provider "aws" {
  region              = var.aws_region
  allowed_account_ids = ["123456789012"]   # refuses to run elsewhere

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

  assume_role {
    role_arn     = "arn:aws:iam::123456789012:role/TerraformRole"
    session_name = "terraform"
  }
}

allowed_account_ids is the cheapest guard against applying prod config to the wrong account. default_tags applies to every taggable resource, so you stop repeating the same tag map.

GCP and Azure

provider "google" {
  project = var.gcp_project
  region  = "us-central1"
  zone    = "us-central1-a"
}

provider "azurerm" {
  features {}                 # required, even when empty
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id
}

Kubernetes from an EKS cluster

provider "kubernetes" {
  host                   = data.aws_eks_cluster.main.endpoint
  cluster_ca_certificate = base64decode(
    data.aws_eks_cluster.main.certificate_authority[0].data
  )
  token = data.aws_eks_cluster_auth.main.token
}

This is the classic case of a provider configured from resource attributes. If the cluster does not exist yet the provider cannot be configured, so create the cluster in one apply and the Kubernetes objects in another.

Provider Commands

terraform providers                  # tree of providers per module
terraform providers schema -json     # full schema dump, huge
terraform providers lock -platform=linux_amd64
terraform providers mirror ./mirror  # local filesystem mirror for air-gapped CI
terraform init -upgrade              # re-resolve versions, rewrite the lock

export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"  # share downloads

terraform providers schema -json | jq '.provider_schemas["registry.terraform.io/hashicorp/aws"].resource_schemas.aws_instance' is the fastest way to answer "what arguments does this resource actually take" without leaving the terminal.