Skip to content
beginner Phase 8 · Provider Configuration and Version Pinning

Declaring Required Providers

Using the required_providers block to map short names to source addresses.

45m
0 problems
Topic Progress 0%

Why `provider "github" {}` Fails Until You Name the Source

Your teammate's root module configures the GitHub provider and creates team membership resources. You clone the repository, run terraform init, and get this:

Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider hashicorp/github:
no available packages for registry.terraform.io/hashicorp/github

Nothing is wrong with the configuration. Terraform guessed. The GitHub provider is not published under HashiCorp's namespace — it lives at integrations/github — and no amount of cleverness lets Terraform infer that from a local name. Declaring the mapping is exactly what the required_providers block is for.

Every provider carries two identifiers, and mixing them up is the most common cause of init failures:

Identifier Scope Where it appears Example
Local name One module provider blocks, the provider meta-argument, resource type prefixes github
Source address Global required_providers, init downloads, the lock file, error messages integrations/github

A source address has the shape [<HOSTNAME>/]<NAMESPACE>/<TYPE>. The hostname is optional and defaults to registry.terraform.io, so integrations/github already means registry.terraform.io/integrations/github.

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.31"
    }
    github = {
      source  = "integrations/github"
      version = "~> 6.2"
    }
    http = {
      source  = "hashicorp/http"
      version = ">= 3.0"
    }
  }
}

Terraform tolerates a missing source, but the rule is rigid: it assumes registry.terraform.io/hashicorp/<LOCAL NAME>. That is a backward-compatibility shim left over from the Terraform 0.13 transition, and it only pays off for providers HashiCorp actually publishes under its own namespace. For everything else — third-party providers, forks, and your own in-house providers addressed as terraform.example.com/examplecorp/ourcloud — write the source explicitly, because the failure mode of getting it wrong is silent: an implied hashicorp/aws installs the official provider while you believed you were pinned to a fork.

Local names must be unique inside a module, and the rest of the configuration only ever uses local names. Terraform also fills in a local name for you: when a resource has no provider argument, the first word of the resource type is read as the local provider name, so aws_instance implies aws and github_team_membership implies github. That is why a provider's preferred local name is the prefix of its resource types — match it and you rarely write provider = ... at all.

When two providers genuinely share a prefix (hashicorp/http and mycorp/http), local names must be disambiguated. The convention is a compound name built from namespace and type:

required_providers {
  hashicorp-http = { source = "hashicorp/http", version = "~> 3.0" }
  mycorp-http    = { source = "mycorp/http",    version = "~> 1.0" }
}

After that, every data source and resource from those providers needs an explicit provider = mycorp-http, because the first-word rule can no longer guess correctly.

One provider never needs declaring: the built-in provider behind terraform_remote_state, whose source address is terraform.io/builtin/terraform. It ships inside the Terraform binary, so there is nothing to download.

What init does with a source address is worth stating plainly, because it explains where errors come from. The address is parsed into hostname, namespace, and type; the hostname selects the registry service to query (the public registry at registry.terraform.io, a private registry on HCP Terraform, or a network mirror you configured); the namespace and type select the provider within it. The same address then appears verbatim in init output, in the lock file, in terraform providers, and in every checksum error:

- Finding hashicorp/aws versions matching "~> 5.31"...
- Installing hashicorp/aws v5.31.0...

A useful sanity check when you inherit a configuration: compare the registry page at registry.terraform.io/providers/<namespace>/<type> with what terraform providers prints, segment by segment. A mismatch means the code and the declaration disagree about who provides these resources — which is why declarations belong in review, next to the code that depends on them.

Declaration is only half the contract — the other half is which versions are acceptable, which is the subject of Version Constraint Syntax. And if you are wondering where module source addresses like terraform-aws-modules/vpc/aws fit, they resolve through the same registry machinery described in Publishing to a Private Module Registry.

How a Child Module's `required_providers` Reaches Your Root's `terraform init`

A module you imported declares its own providers:

# modules/storage/versions.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.10"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

You never wrote those lines in the root module, yet terraform init downloads both providers anyway. Requirements are collected across the entire module tree: the root module, every called child, and every grandchild contribute their required_providers blocks, and init resolves one version per source address for the whole configuration.

Two properties make this work:

  • Local names are module-scoped. A child may call its AWS provider aws while a sibling calls the same provider amazon. Local names never leak between modules, so they can collide harmlessly.
  • Source addresses are global. Because the identity of the dependency is the source address, requirements merge by address, not by local name. Two modules pointing at hashicorp/aws produce one requirement — and one downloaded plugin.

Version constraints are merged at the same time. The child asks for >= 5.10; your root can ask for ~> 5.31 and init installs a single AWS provider satisfying both. If the constraints have no common answer — the child demands ~> 4.0 while the root demands ~> 5.0 — init stops and names both requirements, because no binary can serve two incompatible callers. Resolving that tension is the job of Version Constraint Syntax.

Once init has run, one command shows you the merged result:

$ terraform providers
Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/aws]
├── provider[registry.terraform.io/hashicorp/random]
└── module.storage
    ├── provider[registry.terraform.io/hashicorp/aws]
    └── provider[registry.terraform.io/hashicorp/random]

Providers required by state:
    provider[registry.terraform.io/hashicorp/aws]
    provider[registry.terraform.io/hashicorp/random]

Read it as a provenance report. The first tree answers "where did every requirement come from?" — a provider listed twice means two modules declare it, which is normal. The second section answers a different question: which providers does the state still reference? A provider that appears only under state belongs to resources you have not destroyed yet, so removing its declaration from the configuration will not make it disappear until those resources are gone.

Init itself works in three passes over that merged view: work out which providers are needed and at which versions, decide what is already present in the working directory, then download only the difference. The output is worth reading once so the vocabulary is familiar:

Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 5.10, ~> 5.31.0"...
- Finding hashicorp/random versions matching "~> 3.6"...
- Installing hashicorp/aws v5.31.0...
- Installing hashicorp/random v3.6.0...

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Review that file and commit it to version control.

Notice that the merged constraints appear in the Finding line — that is the intersection of every module's requirements, shown as Terraform searches for it. Also note what does not propagate: each working directory gets its own copy of the plugins under .terraform/providers, so two repositories using the same AWS version each download it unless you point both at a shared cache. On remote execution platforms, provider installation happens during the run using the versions in the lock file, which is why a child module's requirements must be visible in the repository rather than assumed from a developer's laptop.

Two practical consequences follow for module authors. First, declare every provider you reference, even if the root already declares it — a module that quietly relies on the caller's required_providers breaks the moment it is reused. Second, the propagation is direction-only: requirements flow up to the root, but configuration (credentials, region, aliases) never flows down on its own. Modules can ask for provider configurations they do not create, which is exactly how aliases cross a module boundary — covered in Provider Aliases for Multi-Region Use.

If you change a declaration in any module, run terraform init again before planning: only init reads required_providers, and terraform plan will refuse to run against a stale plugin directory.

Three Declaration Mistakes That Install the Wrong Provider

The required_providers block is short enough that nobody audits it carefully. These are the errors that survive review.

1. Trusting the implied source address. Omitting source makes Terraform assume registry.terraform.io/hashicorp/<local name>. If your local name is aws and you actually distribute an internally patched build as examplecorp/aws, forgetting the source does not fail loudly — it downloads and installs the official HashiCorp provider. Your provider "aws" block still authenticates, terraform plan still succeeds, and the patch you needed is simply absent. The fix is one line:

aws = {
  source  = "examplecorp/aws"   # without this: registry.terraform.io/hashicorp/aws
  version = ">= 5.10"
}

2. Local name that does not match the resource prefix. Terraform derives a missing local name from the first word of the resource type. Declare a provider as mycloud = { source = "hashicorp/aws" } and then write resource "aws_instance" "web" {}, and Terraform does not connect the two: it treats aws as an undeclared requirement, resolves hashicorp/aws, and your provider "mycloud" {} block configures a provider nothing uses. Either keep the local name aligned with the type prefix (aws), or attach provider = mycloud to every resource and data source from that provider.

3. Declaring without a version. The version argument is optional, and omitting it means init accepts whatever the registry offers newest. Two teammates initializing on different days then hold different binaries, the lock file churns on every machine, and a breaking change shows up as a surprise in plan output rather than in a reviewed diff. Every root module should carry a constraint; reusable modules should carry a floor — that distinction is the whole topic of the next chapter.

Verification is one command, and it is worth running after any declaration change:

$ terraform providers | head -5
Providers required by configuration:
.
├── provider[registry.terraform.io/examplecorp/aws]
└── provider[registry.terraform.io/integrations/github]

If the address printed there is not the one you meant, init installed something else — terraform providers is the ground truth for what the configuration depends on, including requirements pulled in by child modules you did not write.

To shorten the loop between symptom and cause, this is the diagnostic table I actually use:

Symptom at init or plan Most likely declaration error
Failed to query available provider packages for hashicorp/<x> source omitted on a provider published outside HashiCorp's namespace
Wrong provider version installed on one machine only missing version constraint, so each init picked its own newest
Resources resolve to a provider you never configured local name differs from the resource type prefix, so a second requirement was implied
Provider configuration not present inside a module an aliased configuration the module expects but the caller never passed
Plan refuses to run after your edit only init reads declarations; the plugin directory is stale

That last row is the one people miss. Editing required_providers and running terraform plan changes nothing about what is installed — plan has no resolver, it only uses what init left in .terraform/providers. The habit worth building is terraform init && terraform providers after any declaration edit, and a CI step that runs terraform init -backend=false && terraform validate so a misdeclared source fails the pull request instead of the first deploy.

Finally, remember that declaration and configuration are separate steps. required_providers decides which plugin is installed; the provider block decides how it authenticates and where it operates. A perfect declaration with no provider block still fails at apply time with the provider's own credential error, and a fully configured provider "github" block with a wrong source address fails earlier, at init.

Next: Version Constraint Syntax.

Quiz

1. A module declares `github = { version = "~> 6.0" }` in required_providers with no `source` argument. Which provider does terraform init try to install?

Question 1 options

2. Which statement about provider local names is correct?

Question 2 options

3. You maintain a patched AWS fork published as `examplecorp/aws`, and you keep writing `aws_instance` resources with no `provider` argument. What is the correct declaration?

Question 3 options

4. Which provider can be used without any entry in required_providers?

Question 4 options

Flashcards

Question

What are a provider's two identifiers, and how do their scopes differ?

Answer

The local name is module-scoped and is what the configuration uses (provider blocks, the `provider` meta-argument, resource type prefixes). The source address `[<hostname>/]<namespace>/<type>` is global and is what init downloads and the lock file records — for example `integrations/github`.

Question

What source address does Terraform assume when `source` is omitted from a required_providers entry?

Answer

`registry.terraform.io/hashicorp/<local name>` — a backward-compatibility implication from Terraform 0.13. It is safe only for providers HashiCorp publishes; for forks, community, and in-house providers the source must be written explicitly or the wrong (or no) package is installed.

Question

How does Terraform choose the provider configuration for a resource that has no `provider` argument?

Answer

It takes the first word of the resource type as the local name (`aws_instance` → `aws`) and uses that local name's default (unnamed) configuration. If no default block was written for that local name, the fallback is a default instance configured from environment variables.

Question

What does `terraform providers` report once init has run?

Answer

Two sections: the tree of required provider addresses grouped by module (showing where each requirement came from, root at `.`), and the providers required by state — which can include providers no longer present in the configuration until those resources are destroyed.

Question

How do two providers with the same resource prefix coexist in one module?

Answer

Give them unique compound local names — namespace plus type, e.g. `hashicorp-http` and `mycorp-http` — and attach an explicit `provider = <local-name>` to every resource and data source, because the first-word rule can no longer disambiguate them.

Revision Notes

Key Takeaways

  • 1. required_providers maps a module-scoped local name to a global source address; omitting source implies registry.terraform.io/hashicorp/<local name>, which fails for anything outside HashiCorp's namespace
  • 2. A resource with no provider argument takes its local name from the first word of its type, so preferred local names match resource prefixes
  • 3. Requirements from the root and every child module merge by source address and resolve to one installed plugin, even when each module uses a different local name
  • 4. `terraform providers` is the ground truth after init: it shows the requirement tree and the providers still referenced by state
  • 5. The built-in terraform provider (terraform.io/builtin/terraform) powers terraform_remote_state and needs no declaration

Interview Tips

  • • "Walk me through what init does with providers" — layer it: collect required_providers across the module tree, merge constraints per source address, honor the lock file, download and verify checksums, then update the lock file
  • • "My config works on your machine but init fails on mine" — diagnose with `terraform providers` and the implied-source rule; the classic case is a community provider like integrations/github missing its source
  • • "Can a module depend on a provider the root never declared?" — yes: requirements flow up from children to root, while configuration flows down only through the `providers` argument and configuration_aliases
  • • "What separates a local name from a source address?" — answer by scope: local names are per-module and used in code, source addresses are global and used for identity, download, and the lock file

Cheat Sheet

Declaring Required Providers

The block (root or any module):

terraform {
  required_version = ">= 1.5.0, < 2.0.0"   # the CLI, not a provider

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.31"
    }
    github = {
      source  = "integrations/github"
      version = "~> 6.2"
    }
    mycloud = {
      source  = "terraform.example.com/examplecorp/ourcloud"  # in-house provider
      version = ">= 1.0"
    }
  }
}

Identity rules:

Question Answer
Source address shape [<HOSTNAME>/]<NAMESPACE>/<TYPE>, hostname defaults to registry.terraform.io
source omitted implies registry.terraform.io/hashicorp/<local name>
Local name scope one module; unique within it; used by provider blocks and provider = ...
Resource with no provider arg first word of the type = local name → default configuration
Two providers, one prefix compound local names (hashicorp-http, mycorp-http) + explicit provider
Never declared built-in terraform.io/builtin/terraform behind terraform_remote_state

Lifecycle: init reads declarations (and merges every child module's requirements by source address); plan/apply never do — edit a declaration, then run terraform init again.

Verify: terraform providers → requirement tree by module + providers required by state.

Continue Learning