Terraform modules do not operate in isolation. A module must know which provider to use and what values to receive from its caller. Provider configuration flows across module boundaries in two distinct ways, while variables are always passed explicitly. Understanding the mechanics of implicit inheritance, explicit provider mapping, and the role of required_providers and configuration_aliases is essential for multi-region, multi-account, and multi-cloud deployments.
Introduction
Reusable Terraform modules solve the problem of duplication, but they introduce a boundary problem: how does a resource inside a module find its provider configuration? Terraform treats provider configurations as global to an entire configuration and can be shared across module boundaries. Provider configurations can be defined only in a root Terraform module. Modules are not allowed to define provider blocks for themselves if they are intended to be called by other modules. A module containing its own provider configurations is not compatible with the foreach, count, and dependson arguments that were introduced in Terraform v0.13.
Variables behave differently. Unlike providers, variables are never automatically shared between modules. Each module has its own isolated variable scope, and you must explicitly pass values when calling a module. This explicit passing makes dependencies clear and keeps modules self-contained.
Provider Reach: Implicit Inheritance vs Explicit Passing
There are exactly two ways a module gets its provider configuration:
- Implicit inheritance - The module automatically gets the default non-aliased provider from its parent
- Explicit passing - The caller uses the
providersargument to map specific provider configurations to the module
Implicit inheritance is the default. When you do not specify a providers argument, the module inherits the default provider from the calling module.
```
provider "aws" {
region = "us-east-1"
}
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
}
```
Inside the vpc module, any aws_* resource uses the inherited provider. No additional configuration is needed. Implicit inheritance only works for the default non-aliased provider. Providers must be configured in the root module before any modules are evaluated.
Explicit passing is required when you need an aliased provider, a different configuration for the same provider, or when you are passing a specific provider instance down through multiple levels. The caller uses the providers argument within a module block to map provider configurations.
Provider configurations are used for all operations on associated resources, including destroying remote objects and refreshing state. Terraform retains, as part of its state, a reference to the provider configuration that was most recently used to apply changes to each resource. If Terraform finds a resource instance tracked in the state whose provider configuration block is no longer available then it will return an error during planning, prompting you to reintroduce the provider configuration.
When a module is not using the provider you expect, check:
- Does the module have a
providerblock inside it? That can prevent the caller's passed provider configuration from being used. - Are you passing the provider explicitly? If yes, is the key name correct?
- Is the module nested? Providers might not be flowing through intermediate modules.
Use terraform providers to see which providers are configured.
```
Show provider requirements for the entire configuration
terraform providers
```
Declaring Provider Requirements Without Configuring Providers
A module intended to be called by one or more other modules must not contain any provider blocks. Modules should declare their provider requirements with required_providers but never define provider blocks.
To declare that a module requires particular versions of a specific provider, use a required_providers block inside a terraform block:
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
A provider requirement says, for example, "This module requires version v2.7.0 of the provider hashicorp/aws and will refer to it as aws." It doesn't, however, specify any of the configuration settings that determine what remote endpoints the provider will access, such as an AWS region; configuration settings come from provider configurations, and a particular overall Terraform configuration can potentially have several different configurations for the same provider.
Although provider configurations are shared between modules, each module must declare its own provider requirements, so that Terraform can ensure that there is a single version of the provider that is compatible with all modules in the configuration and to specify the source address that serves as the global module-agnostic identifier for a provider.
The declaration serves as documentation and helps Terraform understand version requirements. It tells anyone using your module:
- Which provider(s) the module needs
- The minimum version required
- Where to find the provider
The actual provider configuration still comes from the root module, but this declaration ensures version compatibility.
| Item | Purpose |
|---|---|
required_providers |
Documents provider need and version constraint |
source |
Identifies provider namespace, e.g., hashicorp/aws |
version |
Minimum compatible version |
| Provider configuration block in root | Supplies region, credentials, endpoints |
When a module needs multiple configurations of the same provider, declare configuration_aliases.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 2.7.0"
configuration_aliases = [...]
}
}
}
configuration_aliases tells Terraform the module can reference additional named provider configurations.
Passing Provider Configurations Explicitly
By default, Terraform passes provider configurations from your root module down to any child modules you call. Providers are automatically inherited by child modules in Terraform, so you don't need to declare provider blocks inside modules unless you need custom configuration.
Explicit passing is needed for aliased providers and for non-default mappings. The providers argument within a module block allows explicit mapping.
Providers can be passed down to descendant modules in two ways: either implicitly through inheritance, or explicitly via the providers argument within a module block.
If you need different providers for different instances, you need separate module blocks. Passing provider configurations to modules is how you enable multi-region, multi-account, and multi-cloud Terraform deployments.
For multi-region scenarios, the root configures multiple provider configurations with aliases:
```
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
```
The child module must be called with an explicit mapping:
module "vpc_west" {
source = "./modules/vpc"
providers = {
aws = aws.west
}
...
}
The default non-aliased provider is inherited implicitly. Aliased providers must be passed explicitly using the providers argument. When nesting modules that need aliased providers or non-default mappings, make sure to pass those provider configurations through each level.
Variables Are Never Inherited
Unlike providers, variables are never automatically shared between modules. Each module has its own isolated variable scope, and you must explicitly pass values when calling a module.
Root module example:
```
variable "environment" {
type = string
}
variable "vpc_cidr" {
type = string
}
module "networking" {
source = "./modules/vpc"
environment = var.environment
vpccidr = var.vpccidr
}
```
Module declaration:
```
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
```
This explicit passing might feel redundant, but it makes dependencies clear and keeps modules self-contained. Use outputs to share information between modules.
Testing Modules With Different Provider Configurations
When developing modules, test them with different provider configurations to make sure they work correctly.
```
test/fixtures/main.tf
provider "aws" {
region = "us-west-2"
}
module "testvpc" {
source = "../../modules/vpc"
vpccidr = "10.99.0.0/16"
environment = "test"
}
output "vpcid" {
value = module.testvpc.vpc_id
}
```
Run this in a separate directory with terraform plan to verify the module behaves correctly without actually creating infrastructure.
Common Mistakes and Anti-Patterns
Placing a provider block inside a reusable module prevents the caller's passed provider configuration from being used. A module containing its own provider configurations is not compatible with foreach, count, and dependson introduced in Terraform v0.13.
Provider configurations are global to an entire Terraform configuration and can be shared across module boundaries. Provider configurations can be defined only in a root Terraform module. Keep providers configured at the root level, explicitly pass all variables that modules need, and use outputs to share information between modules.
Debugging provider issues:
- Check for an internal provider block in the module
- Verify the providers argument key name matches the module's required provider name
- Verify provider flow through intermediate modules
Conclusion
Provider configuration in Terraform modules can be confusing because it works differently depending on whether you use aliased providers, whether the module declares its own provider requirements, and whether you are calling the module from the root or from another module. The recommended approach is to keep provider configuration at the root, declare requirements in modules with required_providers, and never define provider blocks inside reusable modules.
Implicit inheritance handles the default provider automatically. Explicit passing via the providers argument is required for aliased providers and for precise control in multi-region or multi-account designs. configuration_aliases documents the need for additional provider configurations. Variables remain strictly explicit, which preserves isolation and readability.
This separation of concerns produces modules that are composable, version-constrained, and safe to reuse across environments. Keeping providers at the root and variables explicitly passed ensures that dependencies are visible, state references remain stable, and infrastructure code remains maintainable as it grows.