SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Terraform fundamentals

Terraform Providers, Version Constraints, and State Basics

16 min readTerraform 004 · Terraform fundamentalsUpdated

Terraform providers are plugins that translate your configuration into real API calls — the aws provider turns an aws_instance block into EC2 requests, the google provider turns google_compute_instance into Compute Engine requests. Terraform core ships with no platform knowledge at all: you declare which providers a configuration needs in a required_providers block, terraform init downloads them from the Terraform Registry, and a dependency lock file (.terraform.lock.hcl) pins the exact versions so every run uses the same plugins. Alongside providers sits state: the file where Terraform records which real-world resource each configuration block maps to, plus dependency metadata and cached attributes. This lesson covers exam objective 2 end to end — installing and versioning providers, the version-constraint operators including ~>, configuring multiple provider instances with alias, writing multi-provider configurations, and why state exists at all.

What you’ll learn
  • Declare provider requirements with required_providers, including source addresses for public, partner and in-house providers
  • Explain what required_version enforces and how terraform version and terraform providers differ
  • Explain what terraform init installs, where provider plugins come from (registry, plugin cache, -plugin-dir), and when to re-run it
  • Apply every version-constraint operator, including ~>, pre-release matching, and how constraints combine across modules
  • Describe what .terraform.lock.hcl records, when it changes, and how -upgrade, -lockfile=readonly and providers lock -platform affect it
  • Configure multiple instances of one provider with alias, and resolve two providers that share a type name
  • Explain why Terraform state exists and why sensitive = true does not protect secrets stored in it

How Terraform uses providers

Terraform uses providers as its only bridge to the outside world. Terraform core parses configuration, builds the dependency graph, and manages state — but every actual API call is made by a provider plugin, a separate binary that Terraform launches and talks to during plan and apply. Each provider defines a set of resource types and data sources and knows how to create, read, update, and delete them against its platform's API.

You can identify a resource's provider from its type prefix: aws_instance belongs to the aws provider, google_compute_instance to google, azurerm_resource_group to azurerm, random_pet to random. That naming convention is worth internalizing — exam questions use it constantly to imply which provider a snippet requires.

Providers are distributed through the Terraform Registry (registry.terraform.io), which hosts official providers (maintained by HashiCorp, such as aws and kubernetes), partner providers (maintained by the vendor, such as datadog), and community providers. Because providers are versioned and released independently of Terraform itself, a new AWS feature only needs a new aws provider release, not a new Terraform release.

Two consequences the exam probes: without the right provider installed, Terraform cannot manage the resource at all — terraform init fails or plan errors on the unknown type; and because providers are just API clients, anything with an API can be managed, from clouds to DNS to GitHub.

Declaring providers with required_providers

You declare which providers a configuration needs in a required_providers block nested inside the terraform block. Each entry maps a local name to a source address and a version constraint:

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

  required_version = ">= 1.5.0"
}

The source address has the form [hostname/]namespace/type. The hostname defaults to registry.terraform.io, so hashicorp/aws is shorthand for registry.terraform.io/hashicorp/aws. The local name (the key, aws) is what the rest of the configuration uses in provider blocks and references. If you omit a provider from required_providers, Terraform falls back to assuming hashicorp/<name> for backward compatibility — but explicit declaration is the practice the exam expects, and it is mandatory for any provider outside the hashicorp namespace.

The implied hashicorp/<name> fallback is why undeclared third-party providers break. A configuration that uses pagerduty_service without declaring the provider makes Terraform look for registry.terraform.io/hashicorp/pagerduty, which does not exist, so terraform init fails to find the provider. The fix is an entry such as pagerduty = { source = "PagerDuty/pagerduty" }.

In-house providers that never go to the public registry still need a source address. HashiCorp recommends a hostname on your organisation's own domain, for example terraform.example.com/examplecorp/ourcloud. The hostname does not have to serve a registry yet: you can distribute the plugin through a filesystem mirror (such as the implied local mirror directories) today and publish it to a private registry at that hostname later, without changing any configuration.

Version constraints belong in required_providers. Older configurations put a version argument inside the provider block; that argument is deprecated (Terraform still accepts it but warns), and new code should never use it. The provider block is for connection settings only.

Do not confuse the two version settings visible above: version inside a required_providers entry constrains the provider plugin, while required_version directly inside the terraform block constrains the Terraform CLI itself. The exam likes to swap them in wrong answers.

Constraining the Terraform CLI with required_version

required_version is a check, not an installer: it states which Terraform CLI versions may run the configuration, and an unmet constraint stops the run. If you run Terraform 1.4 against required_version = ">= 1.7.0", terraform init and plan fail with an unsupported Terraform Core version error before doing anything. Terraform never downloads, upgrades or switches its own binary; installing the right CLI version is your job (or a version manager's, or HCP Terraform's workspace setting).

Every module can declare required_version, and all of the constraints in the configuration must be satisfied by the CLI that runs it. Because Terraform core and providers are released independently, this constraint has no effect on which provider versions are chosen, and a provider version has no effect on which CLI may run.

Two commands show what you have:

CommandWhat it prints
terraform versionThe CLI version, the platform it runs on (for example darwin_arm64), and the versions of providers installed in the working directory
terraform providersThe provider requirements tree: which providers the root module and each child module require, with their constraints, plus providers recorded in state

Installing providers with terraform init

terraform init is the command that installs providers. It reads the configuration's provider requirements, resolves each version constraint against the registry, downloads the chosen plugin binaries into a hidden .terraform/providers directory inside the working directory, and records its selections in the dependency lock file. Providers are never installed at plan or apply time — if a required plugin is missing, those commands tell you to run init first.

Re-run terraform init whenever provider requirements change: you added a provider, changed a version constraint, or pulled teammate changes that did either. Running it again is always safe — it is idempotent and never touches your infrastructure or your state's resource records.

By default, init respects the existing lock file: it reinstalls the exact recorded versions even if newer releases satisfy your constraints. To deliberately move to newer versions within your constraints, run:

terraform init -upgrade

which re-resolves every constraint against the registry and rewrites the lock file with the new selections.

Two options change where plugins come from, and they are easy to confuse:

SettingWhat it doesUse it when
Plugin cache: plugin_cache_dir in the CLI configuration file, or the TF_PLUGIN_CACHE_DIR environment variableKeeps one shared copy of each downloaded provider; other working directories reuse it instead of downloading again. Registry installation still works for anything not yet cached. Since Terraform 1.4 a cached package is reused only when it matches a checksum already in the lock file, so a brand-new lock entry still comes from the origin registry (unless plugin_cache_may_break_dependency_lock_file is set)Many working directories on one machine or CI runner keep re-downloading the same large providers
terraform init -plugin-dir=PATHInstalls providers only from that directory and disables automatic installation from registries. Init still writes the lock fileAir-gapped or strictly controlled environments where every plugin must come from a vetted local directory

Also part of the same command's job: init downloads modules and configures the state backend, which is why it must be the first command in any new or freshly cloned working directory. For this objective, though, the tested association is direct: installing provider plugins is terraform init's job, the plugins land in .terraform/providers, and that directory is disposable and should never be committed to version control.

Version constraint operators

Version constraints are strings like "~> 5.0" that limit which provider releases Terraform may select. Seven operators exist, and the exam tests all of them — especially ~>:

OperatorMeaningExampleAllows
=Exactly this version (also the default when no operator is written)= 5.31.0Only 5.31.0
!=Any version except this one!= 5.31.0Everything but 5.31.0
>Greater than> 5.0.05.0.1 and up, not 5.0.0
>=Greater than or equal>= 5.0.05.0.0 and up
<Less than< 6.0.0Anything below 6.0.0
<=Less than or equal<= 6.0.06.0.0 and below
~>Pessimistic: only the rightmost stated component may increase~> 5.15.1, 5.2, ... 5.x — never 6.0

The pessimistic operator's behavior depends on how many components you write. ~> 5.1 means at least 5.1 and below 6.0 (minor releases may increase). ~> 5.1.0 means at least 5.1.0 and below 5.2.0 (only patch releases may increase). That distinction is a reliable exam question.

Versions compare numerically, component by component, not as text: 2.4.12 is newer than 2.4.5, so it satisfies ~> 2.4.5 and > 2.4.9. Pre-release versions such as 4.0.0-rc1 follow a stricter rule: only an exact match selects them (= 4.0.0-rc1, or the bare version with no operator). Range operators — >, >=, <, <= and ~> — never match a pre-release, so >= 5.0 will not pick up a beta of 6.0.

Constraints combine with commas — ">= 3.5.0, < 4.0.0" — and every listed condition must hold. Recommended practice, and the phrasing the exam rewards: pin providers with a pessimistic constraint so you receive compatible updates without silently jumping a major version that may contain breaking changes. With no version at all, any version is acceptable, so init picks the newest available release (and then the lock file keeps it).

Constraints across modules

A configuration installs one version of each provider, shared by the root module and every child module. Terraform therefore combines the root module's constraint with each child module's constraint for that provider and selects a version that satisfies all of them at once. If the ranges do not overlap — a module needs < 5.0 while the root needs ~> 6.0 — no version qualifies and terraform init fails; you cannot run two versions side by side in one configuration.

That is why HashiCorp's guidance splits responsibility: reusable modules declare only a minimum version (>=) for the features they use, and the root module sets the upper bound (typically ~>) that controls upgrades. A shared module that pins tightly forces every caller onto its range and quickly becomes impossible to combine with other modules.

The dependency lock file: .terraform.lock.hcl

.terraform.lock.hcl records exactly which provider versions terraform init selected, so every subsequent run — on your machine, a teammate's, or CI — installs identical plugins. A version constraint like ~> 5.0 can match many releases; the lock file removes that ambiguity by pinning the one that was actually chosen, together with checksums used to verify each downloaded plugin has not been tampered with.

Terraform creates or updates the file automatically during terraform init, in the root of the working directory. It locks provider dependencies only — module versions are not recorded in it. On later runs, init obeys the lock file even when newer matching versions exist; only terraform init -upgrade re-selects the newest version allowed by the constraints. If you edit a constraint so that the locked version no longer qualifies, plain init does not quietly pick another one: it reports that the locked version does not match the configured constraint and tells you to run terraform init -upgrade.

The lock file changes when providers are added, removed or upgraded, or when init or terraform providers lock records new checksums for an already-locked version (for example for another platform). Adding more resources or data sources for a provider that is already selected leaves it untouched.

Commit the lock file to version control. That is HashiCorp's explicit recommendation and a frequent exam answer: committing it makes provider selection reproducible across the team and makes upgrades deliberate, reviewable diffs rather than surprises. The .terraform directory, by contrast, is a disposable local cache and stays out of version control.

Keeping CI honest. terraform init -lockfile=readonly stops init from changing the lock file: it still verifies the downloaded plugins against the recorded checksums, but if the configuration would need a different selection, init fails instead of rewriting the file. It cannot be combined with -upgrade, which exists to change the file. Don't confuse it with -lock=false, which concerns state locking (objective 6) — a completely separate mechanism from the dependency lock file.

Platform checksums. Each provider release is a separate package per operating system and CPU architecture. When init installs from the origin registry, it records the registry's zh: hashes covering every platform, so a lock file made on a Mac usually verifies cleanly on Linux CI. When the lock file is produced from a network or filesystem mirror, Terraform can only record a checksum (h1:) for the platform it actually installed, and a teammate or runner on another platform then fails verification. The fix is terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 …, which fetches the packages and records checksums for every platform the team uses, without installing anything.

Configuring providers, and multiple instances with alias

A provider block supplies the settings a provider needs to reach its API — region, credentials, project, endpoints:

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

This is the default provider configuration for the local name aws: every aws_* resource that says nothing else uses it. Note the division of labor — required_providers says which plugin and version; the provider block says how to connect. Credentials should come from environment variables or CLI/instance credentials, not hard-coded arguments.

Sometimes you need the same provider configured twice — the classic case is deploying to two AWS regions. Additional configurations get an alias:

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

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

resource "aws_instance" "primary" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.micro"
}

resource "aws_instance" "replica" {
  provider      = aws.west
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.micro"
}

A resource opts into an aliased configuration with the provider meta-argument, referencing it as <local_name>.<alias> — here aws.west, unquoted. Resources without the meta-argument (like primary) fall through to the default, unaliased configuration. Data blocks take the same provider meta-argument. Modules receive alternate configurations through a providers map on the module block. Exam triggers: "two regions", "two accounts", or "same provider, different settings" all point at alias.

Details that decide alias questions:

  • The reference is a bare dotted name — provider = aws.west. A quoted string or an index form such as aws["west"] is not valid.
  • One plugin, many configurations. Aliased configurations share the single installed aws plugin; alias adds no download and no extra required_providers entry.
  • If every block is aliased, there is no explicit default. Resources without a provider argument then use an implied empty default configuration — none of the aliased blocks' settings apply, so for AWS the region and credentials come only from the environment (environment variables, shared config/profile, instance metadata) — so either keep one unaliased block or set provider on every resource.
  • Per-resource region in AWS provider 6.x. Since version 6.0 the AWS provider accepts a region argument on most resources, so one provider configuration can place resources in several Regions. Aliases are still how you separate different accounts, roles or credentials, and the general alias mechanism applies to every provider.

Two providers with the same type name

alias only creates extra configurations of one provider. When two different providers share a type name — say hashicorp/http and acme/http — they cannot both use the local name http. Give each its own local name in required_providers (HashiCorp suggests a compound name such as mycorp-http for the third-party one, as in its documentation), and because neither local name now matches the resource type prefix, set provider = <local name> on each resource that uses them.

Writing configuration with multiple providers

Using several different providers in one configuration takes no special mechanism: declare each in required_providers, add a provider block for any that need settings, and write resources. One terraform init installs all the plugins; one plan and apply covers every resource:

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

provider "aws" {
  region = "eu-west-1"
}

resource "random_pet" "name" {
  length = 2
}

resource "aws_s3_bucket" "logs" {
  bucket = "logs-${random_pet.name.id}"
}

The bucket's name references random_pet.name.id, an attribute of a resource from a different provider. Terraform's dependency graph does not care which provider owns which node: it sees that the bucket depends on the pet name, creates the pet first, and feeds its result into the bucket — cross-provider references are ordinary references.

Note that random needs no provider block at all: providers with no required settings work with an implied empty configuration. And each provider authenticates independently with its own credentials — there is no shared login. This composability is exactly how the multi-cloud story from objective 1 is realized in code: an azurerm or google block would slot into this same file the same way.

How Terraform uses and manages state

State is Terraform's record of what it manages: a JSON document — by default a local file named terraform.tfstate — that binds each resource address in your configuration to the real object it created. Configuration alone cannot do this: nothing about the block aws_instance.web says which of your hundred EC2 instances it is. State stores that binding (aws_instance.web is instance i-0abc123), which is how Terraform knows what to update or destroy instead of creating duplicates on every apply.

The exam wants the reasons state exists, so keep the official list ready:

  • Mapping to the real world — the config-address-to-remote-ID binding above.
  • Metadata — state records resource dependencies, so when you delete a resource from configuration, Terraform still knows the correct destroy order even though the config no longer describes it.
  • Performance — state caches every resource's attribute values. For small setups Terraform refreshes them from real APIs before planning, but at scale that is slow, and teams can plan against the cached state (-refresh=false).
  • Syncing — with remote state, a team shares one source of truth so runs do not collide.

During plan, Terraform compares desired configuration against state (refreshed from reality) and proposes the difference; apply executes it and writes the results back. Two warnings that recur on the exam: state can contain sensitive data in plain text — database passwords, private keys — so protect and never commit it; and never edit the file by hand — inspect and modify it only through Terraform commands. Marking a variable or output sensitive = true does not change that: it only redacts the value in the human-readable output of plan, apply and terraform output. The value is still written to state in plain text (the default local backend writes an unencrypted JSON file), and terraform output -json or -raw print it in clear text. For ordinary arguments, protecting secrets means protecting the state itself (ephemeral values and write-only arguments in Terraform 1.10+/1.11+, covered in Terraform Expressions, Functions, Dependencies, and Sensitive Data, can keep some secrets out of state entirely) — a backend with encryption at rest and access control. Remote backends, locking, and drift handling get their own domain (objective 6); here you need the what and the why.

Tip. This objective is heavily code-driven: expect snippets asking what terraform init will install given a required_providers block, which versions a constraint like ~> 5.1 or ~> 5.1.0 permits, why an undeclared non-HashiCorp provider fails, and how to place a resource under a second provider configuration with alias and provider = aws.west. Harder items combine facts: overlapping constraints across a root module and child modules, pre-release versions, when the lock file does and does not change, init -upgrade versus -lockfile=readonly, checksum mismatches between platforms fixed with terraform providers lock -platform, the plugin cache versus -plugin-dir, and two providers that share a type name. required_version is contrasted with provider version constraints. State questions ask why state exists and whether sensitive = true keeps secrets out of it. Trigger words: "pin provider versions", "same provider different accounts", "air-gapped", "CI must not change the lock file", "source of truth for what Terraform manages".

Key takeaways
  • Providers are plugins that map resource types to platform API calls; Terraform core makes no cloud API calls itself
  • required_providers (inside the terraform block) declares each provider's source and version; an undeclared provider is assumed to be hashicorp/<name>, so non-HashiCorp providers must be declared
  • required_version constrains the Terraform CLI and stops init/plan when unmet; Terraform never installs its own binary. A version argument in a provider block is deprecated
  • terraform init installs providers into .terraform/providers; a plugin cache reuses downloads across directories, while -plugin-dir installs only from that directory
  • ~> 5.1 allows 5.x from 5.1 upward but never 6.0; ~> 5.1.0 allows only 5.1.x. Pre-releases match only an exact constraint
  • One version per provider serves the root and every module, so all constraints must overlap; reusable modules set a minimum (>=), root modules bound upgrades
  • Commit .terraform.lock.hcl; init keeps locked versions, -upgrade re-selects, -lockfile=readonly guards CI, and providers lock -platform adds checksums for other platforms
  • alias adds configurations of one provider, selected with provider = aws.west; two different providers with the same type need distinct local names
  • State maps configuration to real objects, stores dependency metadata and caches attributes; it holds secrets in plain text, and sensitive = true only redacts CLI output

Frequently asked questions

What does terraform init actually download and where does it go?

terraform init resolves the configuration's required_providers constraints against the Terraform Registry, downloads the selected provider plugin binaries into the hidden .terraform/providers directory in your working directory, and records the chosen versions and checksums in .terraform.lock.hcl. It also downloads any referenced modules and configures the state backend. The .terraform directory is a local cache — never commit it.

Should I commit .terraform.lock.hcl to Git?

Yes — HashiCorp explicitly recommends committing the dependency lock file to version control. It pins the exact provider versions and checksums that terraform init selected, so teammates and CI install identical plugins instead of whatever latest release happens to match your constraints. Provider upgrades then show up as reviewable diffs, made deliberately with terraform init -upgrade.

What does the ~> version constraint mean in Terraform?

The pessimistic constraint operator ~> allows only the rightmost version component you specify to increase. So ~> 5.1 accepts 5.1, 5.2, and any later 5.x, but never 6.0; while ~> 5.1.0 accepts only patch releases 5.1.x, never 5.2.0. It is the recommended way to pin providers: you receive compatible updates while blocking potentially breaking major (or minor) version jumps.

What happens if my Terraform version does not satisfy required_version?

Terraform refuses to run: terraform init and terraform plan stop with an unsupported Terraform Core version error before touching anything. Terraform does not download or switch to a matching CLI version itself, so you must install a version that satisfies the constraint. Run terraform version to see which CLI version and platform you are on and which provider versions are installed.

Why does terraform init fail when two modules need different versions of the same provider?

A Terraform configuration uses exactly one version of each provider, shared by the root module and all child modules, so Terraform must find a single version that satisfies every module's constraint. If the ranges do not overlap, init fails. HashiCorp recommends that reusable modules declare only a minimum version with >= and that the root module set the upper bound, which keeps modules combinable.

How do I deploy to two AWS regions in one Terraform configuration?

Write two provider blocks for aws: one default (no alias) with the first region, and one with alias = "west" (or any name) and the second region. Resources use the default configuration automatically; to place a resource in the second region, add the meta-argument provider = aws.west to its block. Modules receive alternate configurations via a providers map on the module block.

Why does Terraform need a state file at all?

Because configuration alone cannot tell Terraform which real-world objects it already manages. State maps each resource address to a real resource ID (so applies update rather than duplicate), stores dependency metadata (so removed resources are destroyed in the correct order), and caches attribute values for performance on large infrastructures. Without state, Terraform could not compute the difference between what you declared and what exists.

Is the Terraform state file sensitive?

Treat it as sensitive, yes. State stores all resource attributes in plain text, which can include database passwords, private keys, and other secrets a provider returns. Marking a variable or output sensitive = true only hides the value in plan, apply and terraform output display; it is still stored in state, and terraform output -json or -raw print it in clear text. Do not commit terraform.tfstate, restrict access to it, and prefer remote backends with encryption and access control.

Source

This lesson covers the "Terraform fundamentals" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
Terraform 004 practice questions, with full explanations.
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?