Getting One Resource Into `us-east-1` While Everything Else Stays in `eu-west-1`
CloudFront only accepts ACM certificates that exist in us-east-1. Your whole estate lives in eu-west-1, and the certificate is the one resource that cannot follow. The answer is not a second configuration directory — it is a second instance of the same provider:
provider "aws" {
region = "eu-west-1"
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::111122223333:role/GlobalAdmin"
}
}
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-eu"
# no provider argument → default configuration → eu-west-1
}
resource "aws_acm_certificate" "edge" {
provider = aws.us_east_1 # aliased configuration → us-east-1
domain_name = "example.com"
validation_method = "DNS"
}
The alias label inside a provider block creates an additional configured instance. Everything else — the plugin binary, the version, the single entry in .terraform.lock.hcl — is shared. An alias is a second configuration of one provider, not a second installation. That distinction explains why aliased instances never appear in terraform providers as separate dependencies and why upgrading the provider upgrades all of its instances at once.
The selection rules are small enough to state completely:
| Resource situation | Configuration used |
|---|---|
No provider argument |
the default (unnamed) configuration for its local name |
provider = aws.us_east_1 |
the instance whose alias is us_east_1 |
| Local name not declared as an aliased instance | plan fails: the configuration does not exist |
Because a resource with no provider argument falls back to the default instance, the region you set in the unnamed block is the region of every resource you forgot to annotate. If you declare only aliased blocks, that fallback still exists — an instance you never wrote, configured from environment variables — which is how a bucket quietly lands in us-east-1 while the team believes everything is in eu-west-1.
Aliases are not limited to regions. The same pattern separates environments or accounts: one instance with assume_role into a security account, another into a workload account, each carrying its own region, default_tags, or endpoint override. Data sources accept the meta-argument too, so data "aws_ami" "ubuntu" can read from a different account than the resource it feeds.
State records the choice explicitly. terraform state list prints addresses like:
aws_s3_bucket.logs
aws_acm_certificate.edge
and terraform state show aws_acm_certificate.edge reports provider = provider["registry.terraform.io/hashicorp/aws"].us_east_1. That fully-qualified form — provider address, then .alias — is how state distinguishes the two, and it is the fastest way to confirm which instance actually owns an object.
Two details separate people who have used aliases in anger from people who have only read about them.
First, instances are cheap and unbounded. Declare as many as you need — aws.us_east_1, aws.ap_southeast_2, aws.security, aws.workload — and each becomes its own configured client with its own credentials, region, and default tags, while the plugin binary loads once and one lock entry covers them all. The label must be a valid identifier, so us_east_1 (underscores, matching the region) is the convention; hyphens are not allowed and useast1 reads as a typo in six months. Because each instance resolves its own session at plan time, an expired profile on one instance fails only the resources routed to it.
Second, the human-readable plan does not print which provider a resource uses, which is why terraform state show is the audit tool: it prints the provider = ... address for every managed resource. Spot-check the handful that carry region or account assumptions whenever you add an instance.
Now the harder case: your own reusable module needs one resource in the primary region and a duplicate in a second one, but a module cannot create provider configurations for itself. That contract is the subject of the next chapter.
Handing a Module Its Second Provider with `configuration_aliases`
A disaster-recovery module must write its bucket to the primary region and to a replica region. Inside the module, the second resource needs provider = aws.replica — but the module does not know which region that is, and provider configuration belongs to the caller. The module's job is to declare what it needs:
# modules/dr/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.31"
configuration_aliases = [aws.replica]
}
}
}
# modules/dr/main.tf
resource "aws_s3_bucket" "primary" {
bucket = var.bucket_name
# no provider argument → default aws configuration
}
resource "aws_s3_bucket" "replica" {
provider = aws.replica
bucket = "${var.bucket_name}-dr"
}
configuration_aliases is a list of provider references the module promises not to configure itself. It is metadata on the requirement: "when you call me, hand me a configuration under this name." The caller supplies it through the providers meta-argument on the module block:
# root main.tf
provider "aws" {
region = "eu-west-1"
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
module "dr" {
source = "./modules/dr"
providers = {
aws = aws
aws.replica = aws.us_east_1
}
}
The mapping translates between namespaces: the left side is the module's local view (aws.replica as the module declared it), the right side is the root's view (its aliased instance). Your root could just as easily pass aws.replica = aws.us_east_1 in one environment and aws.replica = aws in another, and the module code would not change — that indirection is the entire point.
Skipping the declaration fails early and specifically. Call the module with a providers entry it never declared, or omit an alias it requires, and terraform init stops with Provider configuration not present, listing the alias the module expected. Terraform refuses rather than improvising because a configured provider inside a child module cannot be controlled by the caller, would be duplicated per instantiation, and historically made a module unusable with count, for_each, and depends_on.
Three rules keep this tidy:
- The child declares, the caller configures. Never write a
providerblock inside a reusable module for a configuration you expect someone else to supply. - Declare every provider the module reads, including the plain default one — a module that assumes the root happens to have
required_providersbreaks the moment it is reused elsewhere. providers = {}replaces inheritance. Passing the argument passes only what you list, so a module expecting two AWS configurations needs both sides of the map.
Two extensions come up immediately in real code.
Forwarding downward. A module that receives aws.replica and then calls a child module of its own must hand the configuration on explicitly — the providers argument does not inherit through the tree:
# inside modules/dr, calling its own child
module "bucket" {
source = "./modules/dr-bucket"
providers = {
aws = aws
aws.replica = aws.replica
}
}
Both sides use the current module's local names, and a module can only forward configurations it actually holds: its declared default plus whatever configuration_aliases it received. Requirements keep propagating upward on their own; configuration needs a hand at every level of the tree.
Choosing not to alias at all. When two targets differ by more than region — separate backends, disjoint variable sets, state that must never be read in the same plan — the cleaner design is separate root modules per target, as in Directory Layouts per Environment, rather than an alias map that keeps growing. Aliases exist for several instances of one provider in a single plan; if two configurations never need to share a plan, split the roots instead.
Modules that need several regions are also modules whose provider requirements must be visible at init time — for what that visibility looks like across the whole tree, see Declaring Required Providers.
Aliasing Traps: Wrong Region, Orphaned State, and Names That Drift
Most aliasing bugs do not fail loudly. They create resources in a plausible wrong place, and you find out during an incident review.
The missing provider argument. You add provider = aws.us_east_1 to nine resources and miss the tenth. The tenth uses the default instance, lands in eu-west-1, and — for many resources — produces a perfectly valid plan. Mitigate it structurally: keep the default block boring (region only), put anything unusual (roles, tags, endpoints) in named instances, and grep the diff for provider = whenever you add an alias.
The alias name is a local contract. alias = "us_east_1" is referenced as aws.us_east_1, and inside a module the name must also appear in configuration_aliases and in the caller's providers map. Rename the alias in the root and every module reference and state address that used the old name goes stale: state still records provider[.../aws].us_east_1 while configuration now says something else. Treat an alias rename as a migration, not an edit.
Recovering a resource created by the wrong instance. Plan output will not save you. For most resource types the region lives in the provider rather than in the object's attributes, so a bucket sitting in the wrong region still diffs as "no changes" — the object is perfectly valid where it is. The sequence that works:
- Confirm ownership with
terraform state show <address>and read itsprovider = ...line. terraform state rm <address>to detach the record while leaving the cloud object alone.- Either
terraform importit under the correct instance, when the object is legal in that target, or destroy and recreate it when it is not — a bucket in the wrong region cannot be re-homed, only replaced.
Audits are cheaper than recoveries: whenever provider blocks change, run terraform plan and check that the resources appearing in the diff belong to the instances you expect.
Aliases do not divide versions. Both instances come from the same lock entry — one version and one set of hashes in Provider Lock File Integrity — so you cannot run 5.31 in one region and 5.32 in another. If two regions genuinely need different provider versions, you no longer have an aliasing problem — you have separate root modules.
Two accounts are not two regions. The classic cross-account pattern puts assume_role inside the aliased block:
provider "aws" {
alias = "security"
region = "eu-west-1"
assume_role { role_arn = "arn:aws:iam::444455556666:role/TerraformDeployer" }
}
Credentials for that instance are resolved when the plan runs, so an expired session on one alias fails only the resources using it — errors that name provider["..."].security are the tell.
Verification beats recall. Three commands settle any doubt about which instance owns what:
terraform providers # what is installed (aliases are not listed here)
terraform state list # which resources exist
terraform state show aws_bucket.x # provider = provider[...].us_east_1
The first command surprises people: it reports requirements, not configurations, so your aliased instances will not show up as extra lines. Configurations are visible in the plan header and in state addresses instead.
A neat alternative exists when a resource itself accepts a region argument — some do, many do not — but for the majority the provider is the only place region lives, which is why aliasing is the mechanism interviewers ask about when they ask "how do you deploy to multiple regions from one configuration?"
With regions handled, the remaining problem is distribution: getting these provider binaries onto machines that cannot reach the public registry at all, covered in Provider Mirrors for Air-gapped Networks.
Quiz
1. An ACM certificate must be created in us-east-1 for CloudFront while the rest of the estate runs in eu-west-1. What is the correct configuration?
2. A child module writes a resource with `provider = aws.replica`, but neither its required_providers nor the caller declares `aws.replica`. What happens?
3. Given `provider "aws" { region = "eu-west-1" }` and `provider "aws" { alias = "us_east_1", region = "us-east-1" }`, where does `resource "aws_s3_bucket" "logs"` with no `provider` argument land?
4. How is an aliased provider instance identified in Terraform state?
Flashcards
Question
What does the `alias` argument inside a provider block create?
Click to reveal answer
Answer
A second configured instance of the same provider — shared plugin binary, version, and a single lock-file entry, but its own region, credentials, assume_role, or endpoint settings. Aliases distinguish configurations, not installations.
Question
Which configuration does a resource with no `provider` argument use?
Click to reveal answer
Answer
The default (unnamed) configuration for its local name, where the local name is the first word of the resource type. If no default block exists, the fallback is a default instance configured entirely from environment variables.
Question
How does a child module receive a second provider configuration?
Click to reveal answer
Answer
It declares `configuration_aliases = [aws.replica]` inside its required_providers entry, and the caller passes it with `providers = { aws = aws, aws.replica = aws.us_east_1 }` on the module block. Missing either side fails with `Provider configuration not present`.
Question
Why must configuration come from the caller instead of the child module?
Click to reveal answer
Answer
The same module must be instantiable against different regions or accounts, and provider configuration created inside a child cannot be controlled by the caller — module-level provider blocks also prevent using count, for_each, and depends_on on that module.
Question
Where do aliases show up when you inspect state?
Click to reveal answer
Answer
As a dot-suffix on the provider address: `provider["registry.terraform.io/hashicorp/aws"].us_east_1`, visible in `terraform state show`. It is the only reliable way to confirm which instance owns an object, and a rename orphans those addresses, so treat alias renames as migrations that may require detach-and-reimport.
Revision Notes
Key Takeaways
- 1. An `alias` creates a second configured instance of one provider — shared binary, version, and lock entry, separate region/credentials/tags
- 2. Resources without a `provider` argument fall back to the default instance for their local name, which is how a stray resource lands in the wrong region
- 3. Modules declare what they need with `configuration_aliases` and receive it from the caller via the `providers` argument; neither side alone is enough
- 4. Aliased instances are identified in state as `provider["..."]` + `.alias`, so an alias rename leaves state pointing at an address configuration no longer uses
- 5. `terraform providers` lists requirements, not configurations — use plan output and state to see which instance a resource uses
Interview Tips
- • "How do you deploy to two regions from one configuration?" — aliased provider blocks plus the `provider` meta-argument, noting that all instances share one plugin and one lock entry
- • "Why did this resource get created in the wrong region?" — the default-provider fallback: no `provider` argument means the unnamed instance; show how you would audit it with `terraform state list`
- • "How does a reusable module get a second provider?" — `configuration_aliases` in the child's required_providers and a `providers = {}` map at the call site; mention that the alias name must match on both sides
- • "What happens in state when you add or rename an alias?" — provider addresses carry the `.alias` suffix, so state keeps describing the instance that created each object; a rename is a migration, and the fix for a wrong-instance resource is `terraform state rm` plus import under the correct provider
Cheat Sheet
Provider Aliases for Multi-Region Use
provider "aws" {
region = "eu-west-1" # default configuration
}
provider "aws" {
alias = "us_east_1" # second instance, same plugin
region = "us-east-1"
}
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs" # no provider arg → default → eu-west-1
}
resource "aws_acm_certificate" "edge" {
provider = aws.us_east_1 # aliased instance → us-east-1
domain_name = "example.com"
validation_method = "DNS"
}
Module contract:
# child: required_providers
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.replica]
}
# caller: module block
providers = {
aws = aws
aws.replica = aws.us_east_1
}
| Rule | Detail |
|---|---|
| Selection | no provider arg → default instance; provider = aws.<alias> → that instance |
| Alias label | referenced as aws.us_east_1; it is a local contract, renames go stale |
| Shared state | one plugin, one lock entry, one version for all instances |
| State address | provider["registry.terraform.io/hashicorp/aws"].us_east_1 |
| Verify | terraform plan header, terraform state list, terraform state show |
Cross-account: put assume_role { role_arn = ... } inside the aliased block; expired sessions then fail only the resources using that instance.