Skip to content

02 · Installing Terraform & Providers

Terraform ships as a single self-contained binary — no runtime to install separately. This module covers getting it installed, verifying the version, and understanding how providers are downloaded and pinned.

Installing the CLI

HashiCorp publishes Terraform for macOS, Linux, and Windows. The three common paths:

# macOS (Homebrew) — HashiCorp maintains its own tap
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

# Linux (apt, Debian/Ubuntu) — add HashiCorp's repo, then install
wget -O- https://apt.releases.hashicorp.com/gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
  https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

# Manual (any OS) — download the matching zip from releases.hashicorp.com,
# unzip, and place the `terraform` binary somewhere on PATH.

Whichever route, confirm it worked:

terraform version
# Terraform v1.9.x
# on darwin_arm64

The version manager option

Because different projects can require different Terraform versions, many teams use tfenv (or tfswitch) instead of a single pinned install:

brew install tfenv
tfenv install 1.9.5
tfenv use 1.9.5
tfenv list          # shows installed versions, marks the active one

This mirrors tools like nvm for Node or pyenv for Python — useful once you work across several repositories with different required_version constraints (covered next).

Pinning the Terraform version in a project

A terraform block's required_version documents (and enforces) which CLI versions are acceptable for a given configuration:

terraform {
  required_version = ">= 1.6.0, < 2.0.0"
}

Running terraform plan with an incompatible CLI version fails immediately with a clear error rather than proceeding and hitting a confusing syntax or behavior mismatch later.

Providers: plugins Terraform downloads, not part of the core binary

Terraform's core binary knows the language (HCL) and the workflow (plan/apply/state), but it has no built-in knowledge of AWS, Azure, GCP, or any other API. That knowledge lives in providers — plugins Terraform downloads separately, one per API you want to manage.

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

source is <namespace>/<name> on the Terraform Registry (module 04 covers the registry itself); version is a constraint, not an exact pin — ~> 5.0 allows 5.1, 5.2, ... but not 6.0.

terraform init: the command that fetches providers

terraform init

init reads every required_providers block in the configuration, resolves a version for each that satisfies all constraints, downloads the matching plugin binaries into a local .terraform/ directory, and records the exact versions it chose in a dependency lock file, .terraform.lock.hcl:

# .terraform.lock.hcl (excerpt — generated by `terraform init`, do not hand-edit)
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.60.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:abc123...",
  ]
}

That lock file should be committed to version control. It's the Terraform equivalent of a package-lock.json or Gemfile.lock: without it, two different machines running terraform init against ~> 5.0 on different days could resolve two different patch versions and behave subtly differently. terraform init -upgrade deliberately re-resolves to the newest versions satisfying the constraints and rewrites the lock file.

.terraform/ should never be committed

terraform init also creates a .terraform/ directory holding the downloaded provider binaries and (for module 03's remote backends) cached configuration. It's large, machine-specific, and fully regenerable by re-running init — add it to .gitignore:

# .gitignore
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl.backup

(Whether .terraform.lock.hcl itself is ignored or committed is the one exception worth calling out explicitly — HashiCorp's guidance is to commit it for applications, so every contributor and CI run resolves identical provider versions.)

Verifying everything is wired up

mkdir demo && cd demo
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

provider "random" {}
EOF

terraform init
# Initializing the backend...
# Initializing provider plugins...
# - Finding hashicorp/random versions matching "~> 3.6"...
# - Installing hashicorp/random v3.6.3...
# Terraform has been successfully initialized!

The random provider is a good first one to experiment with because it needs no cloud account or credentials at all — it just generates values like random strings, IDs, and passwords locally.

How It Actually Works: what terraform init actually does

terraform init looks like one command, but it performs several distinct, independently-observable steps — none of these require a real cloud account, just the local filesystem and the public registry:

  1. Backend initialization. It reads the terraform { backend "..." {} } block (or defaults to local) and prepares wherever state will be read/written — for the local backend this is just confirming terraform.tfstate is writable in the current directory.
  2. Provider requirement resolution. It walks every required_providers block across every .tf file in the directory (Terraform merges them all into one configuration, as covered in module 01), builds a combined version constraint per provider (e.g. ~> 5.0 from one file and >= 5.2 from another must both be satisfiable), and resolves that to one concrete version.
  3. Registry protocol handshake. For hashicorp/aws, Terraform queries the registry's service discovery document at registry.terraform.io/.well-known/terraform.json, then hits /v1/providers/hashicorp/aws/versions to list available versions and their platform-specific download URLs (a separate binary per OS/architecture — this is why a provider install on Linux vs. macOS downloads different .zip files).
  4. Download, verify, and cache. The provider .zip is fetched, its SHA256 checked against the registry's published checksums (and a GPG signature over those checksums, verified against HashiCorp's or the publisher's public key), then unpacked into .terraform/providers/registry.terraform.io/....
  5. Lock file write. The exact resolved version and checksums for every supported platform are written to .terraform.lock.hcl, which — unlike .terraform/ — is meant to be committed, so that everyone on a team (and CI) resolves to the byte-identical provider binary rather than "whatever satisfies ~> 5.0 today."

The provider binary itself is not a library Terraform links against — it is a standalone executable that Terraform Core spawns as a subprocess and talks to over a local RPC channel each time a resource in your config needs to be read, planned, or applied.

Exercise

Install Terraform (or a version manager) locally, then create a directory with a terraform block requiring hashicorp/random at ~> 3.6 and run terraform init. Open .terraform.lock.hcl afterward and identify: the exact version it resolved, and the registry hostname it came from.