Terraform itself knows nothing about AWS. It is a language and a graph engine; everything that actually creates a load balancer lives in a provider — a separate plugin, versioned separately, released on its own schedule. Understanding that split explains most of what is otherwise confusing about version constraints.
Declaring one
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = "us-west-2"
}Two different things are happening here and they get conflated constantly:
required_providers says which plugins this configuration needs and
which versions are acceptable. The key on the left — aws — is the
local name, and it is what resource type prefixes bind to.
provider "aws" configures an instance of that plugin. Region,
credentials, default tags.
source is a registry address. hashicorp/aws is shorthand for
registry.terraform.io/hashicorp/aws. Omitting source used to default to
the hashicorp/ namespace, and for anything else it silently looked in the wrong place —
always write it.
Version constraints
The operators, in the order you will reach for them:
~> 6.0 >= 6.0.0, < 7.0.0 allow minor and patch <-- for providers
~> 6.61.0 >= 6.61.0, < 6.62.0 allow patch only
>= 1.10 any version at or above <-- for terraform itself
= 6.61.0 exactly this and nothing else
>= 6.0, < 6.50 both, comma-separated⚠️ ~> is the one people misread. It lets the rightmost component you
wrote move. ~> 6.0 allows 6.61.0 because you named two components, so the
second one floats. ~> 6.61.0 allows only 6.61.x because you named three.
The practical advice:
Providers get ~> MAJOR.0. Providers ship breaking changes in
majors — the AWS provider's v4→v5 split of aws_s3_bucket into a dozen separate
resources is the famous one, and it broke essentially every S3 example on the internet. Allowing
7.x would let a routine init -upgrade rewrite what your configuration means.
Terraform itself gets >=. Pinning the binary to one patch means
every colleague and every CI runner must match exactly, which is friction with no safety benefit.
Set the floor at whatever feature you actually depend on. This track says >= 1.10
because that is the release that added native S3 state locking, which lesson 8 uses — and an older
Terraform reading that configuration would proceed without locking rather than fail.
The lock file
terraform init writes .terraform.lock.hcl:
provider "registry.terraform.io/hashicorp/aws" {
version = "6.61.0"
constraints = "~> 6.0"
hashes = [
"h1:hL8hHkNNo2Cnc0ycA9WEfSp8xkPnZbmfjZeVXxLQfR8=",
"zh:0f2e6b1e2c...",
]
}Commit this file. It is the difference between "some 6.x" and "the exact bytes we tested against". Your constraint says what is allowed; the lock file records what was chosen, so your laptop, your colleague's laptop and CI all run the same plugin.
The hashes are checksums, and they are a supply-chain control: if the registry ever
served different bytes under the same version, init fails instead of running it.
terraform init # respects the lock file
terraform init -upgrade # re-resolve within the constraints, rewrite the lock
# ⚠️ Run this when you add the lock file, or CI on Linux will fail.
terraform providers lock \
-platform=darwin_arm64 \
-platform=linux_amd64That last command is a genuine trap. The lock file records hashes per platform,
and by default only for the platform that ran init. Generate it on an Apple Silicon
Mac, commit it, and CI on Linux fails with "the local package does not match any of the
checksums recorded in the dependency lock file" — which reads like a corrupted download rather
than a missing platform.
Configuring the provider
provider "aws" {
region = "us-west-2"
# Applied to every resource this provider creates, on top of whatever tags
# the resource sets for itself. It is the backstop that makes a teardown
# query trustworthy: even a resource where someone forgot to pass tags
# still carries these.
default_tags {
tags = {
Project = "terraform-tutorial"
ManagedBy = "terraform"
}
}
}⚠️ Do not put credentials in here. access_key and
secret_key are real arguments and they work, and using them means your keys are in git.
Leave them out and the provider uses the same chain the AWS CLI does: environment variables, then
~/.aws/credentials, then an instance or container role. In CI, use OIDC — lesson 14.
Aliases: two regions at once
A provider block with an alias is a second configured instance of the
same plugin:
provider "aws" {
region = "us-west-2"
}
provider "aws" {
alias = "east"
region = "us-east-1"
}
resource "aws_s3_bucket" "west" {
bucket = "example-west"
}
resource "aws_s3_bucket" "east" {
provider = aws.east
bucket = "example-east"
}The un-aliased block is the default; anything without a provider argument uses it.
Note the reference is aws.east — bare, not a string.
This is not a nicety. ACM certificates for CloudFront must be in us-east-1, no matter where the rest of your infrastructure lives, and so must a handful of other global services. An alias is how you do that in one configuration.
Upgrading a provider
The sequence that will not surprise you:
terraform init -upgrade # 1. re-resolve within the constraints
terraform plan # 2. READ IT. carefully.Step 2 is the whole thing. A provider upgrade can change a default, and a changed default shows
up as a diff on infrastructure you did not touch. Most are harmless; occasionally one is a
-/+ replacement on something you care about. That is why the upgrade is a deliberate
commit with a plan attached, and not something CI does on its own.
For a major upgrade, read the provider's upgrade guide first. They are good, they list every breaking change, and they usually ship a migration path.
Next
Lesson 5 is the state file — what it holds, why Terraform cannot work without it, and the commands for when it and reality disagree.