Terraform Professional (002): configuring providers and the Terraform MCP server
A Terraform provider configuration is the provider block (default or aliased) that tells a provider plugin which account, Region, credentials and endpoint to use, and the Terraform MCP server is HashiCorp's Model Context Protocol server that gives AI coding agents current registry documentation and, with a token, HCP Terraform and Terraform Enterprise workspace tools. For the Terraform Infrastructure Engineer Professional (002) exam you need to predict which configuration a resource or module uses, pass configurations into reusable modules, keep provider arguments known at plan time, control where providers install from, and set up the MCP server so agents are useful without being able to change or destroy infrastructure. This lesson covers both objectives, 1g and 1h.
On this page10 sections
- How Terraform selects a provider configuration for a resource
- How child modules receive provider configurations
- Legacy modules that contain provider blocks
- Provider source addresses and local names
- Provider arguments must be known during plan
- AWS provider v6: the resource-level region argument
- Controlling where providers are installed from
- What the Terraform MCP server gives an AI coding agent
- Keeping the MCP server safe and choosing a deployment
- Prompting the agent and reviewing what it writes
- Predict which provider configuration a resource, data source or child module uses, including the implied empty default
- Pass default and aliased configurations into modules with the providers map and configuration_aliases
- Recognise the limits of legacy modules with provider blocks and destroy resources in the safe order
- Fix source address and local name mistakes, and keep provider arguments known during plan
- Use the AWS provider v6 region argument and provider_installation methods appropriately
- Choose Terraform MCP server toolsets, tokens, deployment and ENABLE_TF_OPERATIONS for a stated requirement
- Prompt an AI agent to use the MCP server and review its output against the target provider version
How Terraform selects a provider configuration for a resource
Every resource and data source is managed through exactly one provider configuration, and Terraform picks it in a fixed way: if the block sets the provider meta-argument, that configuration is used; otherwise Terraform uses the default configuration of the provider whose local name matches the resource type's prefix (aws_s3_bucket uses aws, datadog_monitor uses datadog). A resource is never managed by two configurations, and Terraform never copies a resource across configurations.
The default configuration is the provider block without an alias. Any block with an alias is an alternate configuration and is used only where something selects it explicitly. Declaration order never promotes an alias to default, and an alias named default is still an alias.
provider "azurerm" {
features {}
subscription_id = var.app_subscription_id
}
provider "azurerm" {
alias = "shared"
features {}
subscription_id = var.shared_subscription_id
}
resource "azurerm_resource_group" "shared" {
provider = azurerm.shared
name = "rg-shared"
location = "westeurope"
}The provider argument takes an unquoted reference in the form <local name>.<alias>. A bare alias (provider = shared) is not a valid reference, alias is an argument of the provider block only, and the plural providers map belongs to module blocks. Data blocks follow exactly the same rule as resources: a data "aws_caller_identity" block without provider = aws.audit runs with the default configuration's credentials and reports that account, not the audited one.
The implied empty default configuration
When a configuration uses a provider but declares no unaliased block for it, Terraform creates an implied empty default configuration, as if you had written provider "x" {}. This is why a configuration can use hashicorp/random with no provider block at all: random has no required arguments, so the empty configuration works. required_providers only declares the source and version constraints; it configures nothing, and the registry supplies the plugin, never configuration values.
The same rule causes a common failure. If every aws block has an alias (say aws.use1 and aws.euw1), any resource without a provider argument uses the implied empty default, which has no Region unless the environment supplies one (for example AWS_REGION), so plan fails. The fixes are to set provider = aws.use1 on the resource or to add an unaliased block. Child modules are affected too: they inherit that empty default (see the next section).
How child modules receive provider configurations
A child module implicitly inherits the default configurations of its parent, but aliased configurations are never inherited, at any depth and even when the child uses the same alias name. To give a module anything other than the parent's defaults, the module block sets the providers map: keys are the names the child uses, values are the parent's configurations.
module "logging" {
source = "./modules/logging"
providers = {
aws = aws.use1
}
}Four rules decide most module-provider scenarios:
- Setting
providerscancels default inheritance. Once the map exists, the child only gets what it lists, so a module that uses both the default and an alias needsproviders = { aws = aws, aws.dr = aws.usw2 }, not just the alias entry. - Keys are the child's names written as
<local name>or<local name>.<alias>. A key ofdrdoes not matchaws.dr; a key ofaws.use1does not feed resources that use the plainawsname. - Configurations pass one level at a time. A module that received
aws.drmust pass it to its own nested module withproviders = { aws.dr = aws.dr }. Inside that module only its own names exist (the root'saws.usw2is invisible), and the root cannot address a grandchild's names. - One configuration can fill several names.
providers = { aws = aws, aws.accepter = aws }is valid, which is the least-configuration answer when two roles (requester/accepter) happen to live in the same account and Region.
References in the providers map are static: you cannot pick a configuration with an expression such as aws[each.key]. Module blocks have no singular provider argument.
configuration_aliases: modules that need several configurations
A reusable module that needs more than one configuration of the same provider declares the extra names with configuration_aliases in its own required_providers entry, and every caller maps a configuration to each name. This keeps provider blocks out of the module.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.primary, aws.dr]
}
}
}
resource "aws_s3_bucket" "replica" {
provider = aws.dr
bucket = var.replica_name
}Entries are unquoted <local name>.<alias> references: ["aws.dr"] (a string) and [dr] (no local name) are invalid, and there is no aliases argument. The module's resources select the names with provider = aws.dr; the caller supplies them, for example providers = { aws.primary = aws.use1, aws.dr = aws.usw2 }. The root module does not declare configuration_aliases for its own blocks: the declaration belongs to the module that receives configurations. It replaces the older pattern of empty "proxy" provider blocks inside the module.
Legacy modules that contain provider blocks
A module with its own provider blocks still works, but it is the legacy pattern and carries two hard limits. First, a module containing provider configurations is not compatible with count, for_each or depends_on on its module block, so terraform validate rejects such a call; adding an alias to the nested block changes nothing, and the block may still use input variables. Second, Terraform needs a resource's recorded provider configuration to destroy it, so the provider block must stay until its resources are destroyed.
That second rule covers root-level aliases as well. If you delete a resource and, in the same change, the aliased provider block it used (or a whole legacy module block), plan fails because the configuration recorded in state is gone. The safe order is: keep (or restore) the configuration, apply the destroy, then remove the provider block in a later change.
The general principle: a destroy needs the exact provider configuration recorded in state. Anything that leaves that configuration missing either fails with the same error or only makes Terraform forget the object while the real infrastructure keeps running. Plugin versions and provider blocks at other addresses do not stand in for it.
To make a legacy module usable with for_each, remove its provider blocks and supply configurations from the root (or, with AWS provider v6, pass a Region as an input, as described below).
Provider source addresses and local names
A provider's source address is [hostname/]namespace/type. The hostname defaults to registry.terraform.io, so DataDog/datadog means the public registry, and namespaces are case-insensitive. A provider in an HCP Terraform private registry uses app.terraform.io/<organization>/<type>, for example app.terraform.io/acme/inventory; omitting the hostname would point at the public registry.
The key in required_providers is the local name, which is what provider blocks and references use. A resource without a provider argument takes its local name from its type prefix. If that local name is not declared, Terraform assumes the hashicorp namespace: declaring dd = { source = "DataDog/datadog" } while writing datadog_monitor makes init look for hashicorp/datadog. Fix it by declaring the source under the key datadog (or by setting provider = dd on every such resource).
Every module declares its own requirements. A child module that uses github_repository but has no required_providers resolves github to hashicorp/github, whatever the root declared. Passing configurations with providers, adding provider blocks or pinning versions does not change a module's source address; only its own required_providers entry does. (Version constraints and the dependency lock file are covered with module and provider versioning in Domain 3.)
Provider arguments must be known during plan
Terraform configures providers while it plans, so a provider block's arguments must use values that are already known then. Input variables, locals built from them, literals, and resource arguments written directly in the configuration (such as a role's literal name) are known. Attributes the platform computes on creation, such as arn and id of a resource that does not exist yet, are unknown on the first plan; wrapping one in a local only renames it. Provider blocks do not accept depends_on, because the problem is unknown values, not ordering.
Being known is only the first condition. Some providers act while being configured: the AWS provider assumes an assume_role role at configuration time, so that role must actually exist, not just have a predictable ARN.
The classic case is one configuration that creates an EKS cluster and also configures a kubernetes provider from the cluster's endpoint and CA certificate: on an empty account the first plan cannot configure that provider. The design answer is to split the stack into a cluster configuration and a workloads configuration applied in order. The workloads configuration then reads the existing cluster with a data source looked up by a known name (for example data "aws_eks_cluster"), which is read during plan. terraform_remote_state also works but requires read access to the other state; input variables need someone to copy values; and -target is a manual recovery tool, not a pipeline design.
AWS provider v6: the resource-level region argument
AWS provider v6 adds a top-level region argument to most resources and data sources, so a resource in another Region of the same account no longer needs an aliased provider block:
resource "aws_s3_bucket" "replica" {
bucket = "acme-replica"
region = "eu-west-1"
}- It overrides the provider's Region for that resource only, and it changes the Region, not the account; another account still needs a separate configuration (for example an alias with
assume_role). - Changing
regionon an existing resource forces replacement; removing the argument later does not, because the value in state is kept. - Inherently global resources (such as
aws_iam_role) are not Region-aware and do not take it. - The documented migration from per-Region aliases is: upgrade to v6, run
terraform apply -refresh-onlyso state records each resource's Region, then replaceprovider = aws.<alias>withregion. - It does not exist in v5, so a configuration constrained to
~> 5.0rejects it as an unsupported argument.
It also solves the legacy-module problem: drop the module's provider block, add a region variable, set it on the module's resources, and use for_each on the module call while it inherits the root's single default configuration.
Controlling where providers are installed from
The CLI configuration file's provider_installation block decides where terraform init gets provider plugins. Each method takes include and exclude patterns, and Terraform uses the methods whose patterns match a provider.
| Method | What it does | Typical use |
|---|---|---|
network_mirror | Installs from an HTTPS mirror URL | Air-gapped or regulated CI with an internal mirror |
filesystem_mirror | Installs from a local directory, through init with checksum checks | Pre-downloaded providers on the machine |
direct | Installs from the provider's origin registry | Providers allowed to come straight from their source |
dev_overrides | Points a provider at a local build directory and skips version and checksum checks, with no init per rebuild | Provider development only, temporarily |
Patterns are the whole control, so think about each method separately: a provider is eligible for every method whose include matches it and whose exclude does not. To keep a namespace on one source only, make sure no other method can match it, and to force everything else through a mirror, make sure direct cannot match those providers. The plugin cache (plugin_cache_dir) only speeds up repeat installs of packages init already chose; it is not an installation source policy, and only dev_overrides bypasses init for a locally built provider.
What the Terraform MCP server gives an AI coding agent
The Terraform MCP server is a HashiCorp Model Context Protocol server that gives AI coding agents tools to fetch current Terraform Registry information (provider documentation and examples, modules, and Sentinel policies) and, with a token, to work with HCP Terraform and Terraform Enterprise workspaces. It solves the problem of agents writing arguments from stale training data, without installing providers locally.
It is not a Terraform CLI: it does not run terraform plan on local files, read a local terraform.tfstate, or install providers. terraform providers schema -json needs initialized providers, the terraform-ls language server works from locally known schemas, and HCP Terraform agents execute runs on private infrastructure; none of these lets an agent search the registry.
Its tools come in toolsets:
| Toolset | Contains | Needs a valid TFE_TOKEN? |
|---|---|---|
registry (the only toolset that loads without a token) | Public provider docs, module search and details, Sentinel policy search and details, latest versions | No |
registry-private | The organization's private registry modules and providers | Yes |
terraform | Workspaces, runs and plan logs, state versions, variables, no-code workspace creation, and more | Yes |
Token-gated toolsets are simply not registered without a valid token, so the agent cannot see those tools at all. TFE_ADDRESS sets the platform the server calls and defaults to https://app.terraform.io, so a Terraform Enterprise token only works once TFE_ADDRESS points at the Enterprise URL.
Toolset keywords. When --toolsets is omitted, the server uses all: every toolset is requested, but the token-gated ones register only when a valid TFE_TOKEN is present, so with no token the agent sees registry tools alone. The keyword default selects only the registry toolset and can be combined with others (for example --toolsets=default,registry-private); on its own it drops registry-private and terraform even when a token is set. --toolsets limits what loads (for example --toolsets=registry,registry-private keeps registry lookups and drops every workspace, run, state and variable tool); --tools selects individual tools and cannot be combined with --toolsets.
Keeping the MCP server safe and choosing a deployment
Two independent boundaries limit what an agent can do through the server. The first is ENABLE_TF_OPERATIONS, which defaults to false. While it is false, destructive tools are not offered: applying or acting on runs (action_run), deleting workspaces, projects or teams (delete_workspace_safely and similar), force_unlock_workspace, and create_run requests for auto-approved or destroy runs. Read tools (listing workspaces, reading state versions and variables, queuing plans and reading plan logs) remain available, so the flag is not a read restriction; use toolsets for that. Creating a workspace from a no-code module is not a gated operation; that workspace's first run follows its own apply settings.
The second boundary is the token: the server can never do more than the token is allowed to do. A team token with plan-only access cannot apply even with the flag on, an organization token widens access rather than narrowing it, and force-unlocking requires workspace admin permission on top of the flag. Restricting tools with --tools never bypasses the gate.
| Goal | Control |
|---|---|
| No applies, destroys or deletions | ENABLE_TF_OPERATIONS left false, plus a least-privilege team token |
| No workspace, run, state or variable visibility | --toolsets without terraform |
| Allow deleting or force-unlocking workspaces | Flag set to true and a token with the needed (admin) permission |
Deploying the MCP server: local, self-hosted and remote
Choose the deployment from who uses it and which platform it reaches:
| Option | Transport and scope | Limits |
|---|---|---|
| Local (Docker or binary) | stdio by default, one client process on one machine | Each developer configures it; no central governance |
| Self-hosted shared server | Streamable HTTP, centrally managed, works with HCP Terraform or Terraform Enterprise (TFE_ADDRESS) | You harden it |
HashiCorp-hosted remote server (https://mcp.terraform.io/mcp) | Token sent in the Authorization header | Beta, HCP Terraform organizations in the United States only (no HCP Europe, no Terraform Enterprise), configuration not changeable, no destructive operations |
Hardening a self-hosted server that listens on the network (for example TRANSPORT_HOST=0.0.0.0) means HTTPS through MCP_TLS_CERT_FILE and MCP_TLS_KEY_FILE, an organization allowlist (MCP_ORGANIZATION_ALLOWLIST or --organization-allowlist), rate limiting, and an authenticating gateway for remote hosting. Keep MCP_CORS_MODE at its strict default and TFE_SKIP_TLS_VERIFY false. As a rule, transport and session settings only describe how clients reach the server; the platform it calls comes from TFE_ADDRESS, and what it may do comes from the toolsets, the operations flag and the token.
Prompting the agent and reviewing what it writes
An agent calls the server's tools when the request clearly needs registry information, so the way to get current documentation is to make that need explicit. HashiCorp's prompting guidance is to frame requests around registry provider configuration, name the provider or the resource type you mean (for example google_compute_disk), and ask for the latest information; in VS Code chat, adding #terraform to the request invokes the server explicitly. The more a prompt names a provider or resource type, the more likely the agent is to call a tool rather than answer from its general knowledge, which may be out of date.
Treat generated configuration as a draft. Models can misread or omit argument details, so confirm that each resource and argument exists in your target provider version. Registry docs describe the latest release, so an argument added in a new major version can appear in configuration pinned to an older one, and terraform validate then rejects it as unsupported. The fix lives on the review side: check the generated code against the documentation for the version you actually install. Then run your normal validate, plan, policy and review steps before anything is applied.
Tip. Expect short HCL snippets with provider blocks, module calls and required_providers entries, asking which configuration a resource or module uses, why plan, validate or init fails, or which providers map or configuration_aliases line fixes it. Scenario items add constraints such as no provider blocks in the module, root resources unchanged, least configuration, no manual steps, an air-gapped network or the resources must really be destroyed. MCP server items ask which toolset, token, environment variable, deployment option or prompt meets a requirement, often combining a capability with a safety limit. Some items ask you to select two.
- A resource or data block without a provider argument uses the unaliased default configuration; aliases are selected with provider = <local name>.<alias>.
- If every block for a provider is aliased, Terraform creates an implied empty default configuration that unaliased resources and child modules use.
- Child modules inherit default configurations only; aliased ones are never inherited, and setting providers cancels default inheritance, so list everything the child needs.
- Modules that need alternate configurations declare configuration_aliases; callers map them, one level at a time, and one configuration can fill several names.
- Modules with their own provider blocks cannot use count, for_each or depends_on, and any provider configuration must stay until its resources are destroyed.
- Every module declares its own required_providers; an undeclared local name falls back to the hashicorp namespace.
- Provider arguments must be known during plan: split configurations when they depend on computed attributes, and read the other side with a data source.
- The Terraform MCP server's registry toolset needs no token; registry-private and terraform need a valid TFE_TOKEN (plus TFE_ADDRESS for Terraform Enterprise).
- Omitting --toolsets loads all (token-gated toolsets register only with a valid TFE_TOKEN); the default keyword selects the registry toolset alone.
- ENABLE_TF_OPERATIONS (default false) gates destructive tools; the token's permissions bound everything; the hosted remote server reaches US HCP Terraform organizations only and is never destructive.
Frequently asked questions
Why does a Terraform resource fail with no Region when all my AWS provider blocks have aliases?
A resource without a provider argument uses the default configuration, which is the unaliased provider block. When every aws block has an alias, Terraform creates an implied empty default configuration, so the resource gets a provider with no Region unless the environment supplies one. Set provider = aws.<alias> on the resource, add an unaliased block, or for a child module map its default name with providers = { aws = aws.<alias> }.
Do Terraform child modules inherit aliased provider configurations?
No. A child module implicitly inherits only its parent's default provider configurations. Aliased configurations must be passed explicitly in the module block's providers map, keyed by the names the child declares with configuration_aliases. Once a providers map is set, the child receives only the configurations listed, so include the default (aws = aws) too if the child needs it.
Why can't I use for_each on a Terraform module that contains a provider block?
Terraform rejects count, for_each and depends_on on any module call whose module contains its own provider configurations, aliased or not. Remove the provider blocks from the module and pass configurations from the root. With AWS provider v6 you can instead give the module a region input and set the resource-level region argument, then use for_each on the module call.
Why can't Terraform configure my kubernetes provider from an EKS cluster created in the same configuration?
Terraform configures providers during plan, and the cluster's endpoint and CA certificate are unknown until the cluster exists, so on a fresh account the first plan cannot configure the kubernetes provider. Split the cluster and the workloads into two configurations applied in order, and have the workloads configuration read the existing cluster with a data source looked up by name.
What is the Terraform MCP server used for?
The Terraform MCP server lets AI coding agents fetch current Terraform Registry content, such as provider documentation and examples, modules and Sentinel policies, through Model Context Protocol tools, so they stop writing outdated arguments. With a valid TFE_TOKEN it also exposes private registry and HCP Terraform or Terraform Enterprise workspace tools. It does not run the Terraform CLI, read local state or install providers.
How do I stop an AI agent from applying or destroying infrastructure through the Terraform MCP server?
Leave ENABLE_TF_OPERATIONS at its default of false, which withholds destructive tools such as applying runs, auto-approved or destroy runs, workspace deletion and force-unlock. Also give the server a least-privilege team token, because it can never do more than the token allows. If agents must not even read workspaces, state or variables, start the server with --toolsets=registry,registry-private.
Can the hosted remote Terraform MCP server work with Terraform Enterprise?
No. The HashiCorp-hosted remote server is in beta, supports only HCP Terraform organizations in the United States (not HCP Europe), cannot be reconfigured and offers no destructive operations. For Terraform Enterprise, run the server locally or self-host it with TFE_ADDRESS set to the Enterprise URL; a shared self-hosted server uses streamable HTTP with TLS and an organization allowlist.
Source
This lesson covers the "Scalable Terraform configuration design" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.