Using Multiple Terraform Providers in One CDKTF Stack
Deploying to two AWS regions, two cloud accounts, or two providers from a single CDKTF stack requires provider aliases and explicit per-resource provider routing. This guide shows the typed Python patterns for declaring aliased providers and binding each resource to the correct one. It builds on Terraform Provider Bridging, part of the broader CDKTF Workflows & Terraform Synthesis workflow.
The moment your infrastructure spans more than one region or account, a single default provider is no longer enough. Terraform models this with provider aliases, and CDKTF exposes the same mechanism through the generated Python provider classes. Get the routing wrong and resources silently land in the default region, or synthesis fails because a resource cannot resolve its provider.
Context
Terraform expresses "which credentials and which endpoint does this resource talk to" through the provider meta-argument, and alias is the only mechanism it offers for keeping two live configurations of the same provider plugin loaded at once. CDKTF does not change that model — it only changes how you express it. The generated AwsProvider class is a thin construct that emits one entry into the provider.aws array of cdk.tf.json, and the provider= argument on a resource emits the literal string "aws.<alias>" into that resource's body. Credential resolution, endpoint selection and retry behaviour all still happen inside the Terraform AWS provider plugin at apply time.
Three situations force aliases on you rather than leaving them as a stylistic choice.
Region-pinned services. An ACM certificate consumed by a CloudFront distribution must be issued in us-east-1 even when every other resource in the account lives in eu-central-1. A single provider cannot satisfy both, so a us_east_1 alias is mandatory rather than optional.
Cross-region pairs. An S3 replication rule is attached to the source bucket in one region but names a destination bucket ARN in another. Expressing that as a plain attribute reference (replica_bucket.arn) only resolves if both buckets sit in the same dependency graph — which means the same stack.
Cross-account fan-out. Central logging buckets, shared VPC endpoints and delegated DNS zones normally live in an account other than the workload account. An aliased provider carrying an assume_role block is how one cdktf deploy reaches both without a second pipeline job.
Where you genuinely have a choice — two independent regional deployments of the same service, for example — prefer separate stacks. The alias mechanism buys you a shared dependency graph, and a shared dependency graph is only worth its blast radius when values actually cross the boundary.
Prerequisites
- Python 3.9+ with type annotations (
mypy --strictrecommended for the patterns below). - CDKTF CLI 0.20+ and the AWS provider bindings: pin
"hashicorp/aws@~> 6.0"incdktf.json, then runcdktf get. - IAM permissions for every account and region you target. For cross-account work, a role you can assume in the secondary account (for example
arn:aws:iam::<secondary-account-id>:role/cdktf-deployer). - Credentials supplied via environment variables or assumed roles only — never embedded in Python source.
- A configured remote state backend; a multi-provider stack still writes to one state file, so backend isolation per environment matters more, not less.
Concept: provider aliases and resource routing
A CDKTF stack synthesizes to a single Terraform configuration with one state file. Within that configuration you may declare one default provider per provider type and any number of aliased providers. Each resource either inherits the default provider or is explicitly bound to an alias.
In Python, every generated provider class (for example AwsProvider) accepts an alias argument. You capture the returned provider object and pass it to each resource through the provider= argument. There is no implicit matching by region — the binding is explicit and by object reference.
Provider note: All providers and all aliased resources live in the same synthesized stack and therefore the same state file. This is different from running separate stacks per region; choose multiple providers in one stack when the resources are tightly coupled (for example a primary bucket and its cross-region replica), and separate stacks when they have independent lifecycles.
How the alias reaches the synthesized JSON
CDKTF resolves provider= during synthesis, not at apply time. The construct you hand it is located in the construct tree, its alias string is read off, and the literal "aws.replica" is written into the resource body. Two consequences follow. First, an alias typo is structurally impossible in Python: you pass an object, so a wrong variable name is a NameError or a mypy error long before cdktf synth produces JSON. Second, the default provider is identified by absence — it is the entry in provider.aws that carries no alias key at all.
That second point causes the most confusing failure in this area. If every provider you declare carries an alias, Terraform still accepts resources that omit provider=; it silently manufactures an empty default configuration for the plugin and then fails during plan:
# CLI: cdktf diff --stack multi-region
# Provider note: this is what an all-aliased provider set looks like at plan time.
Error: Invalid provider configuration
Provider "registry.terraform.io/hashicorp/aws" requires explicit
configuration. Add a provider block to the root module and configure the
provider's required arguments as described in the provider documentation.
The mirror-image failure is a resource pointing at an alias that no provider declares — usually the result of hand-editing the synthesized JSON or copying a snippet between stacks:
# CLI: cdktf diff --stack multi-region
Error: Reference to undefined provider
on cdk.tf.json line 61, in resource.aws_s3_bucket.replica_data:
61: "provider": "aws.replicaa"
There is no provider "aws" with alias "replicaa".
Both messages come from Terraform core rather than from CDKTF, which is why they reference cdk.tf.json line numbers instead of your Python file. Reading the synthesized JSON is therefore a normal part of debugging a multi-provider stack, not an exotic last resort.
Implementation
1. Declare a default provider and aliased providers
Create one provider without an alias (the default) and one aliased provider per additional region or account. Capture each in a typed field so resources can reference it.
# CLI: cdktf get && cdktf synth
# Provider note: the no-alias AwsProvider is the stack default; aliased ones are opt-in per resource.
from dataclasses import dataclass
from constructs import Construct
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
@dataclass(frozen=True)
class RegionConfig:
primary_region: str = "us-east-1"
replica_region: str = "eu-west-1"
class MultiRegionStack(TerraformStack):
def __init__(self, scope: Construct, ns: str, config: RegionConfig) -> None:
super().__init__(scope, ns)
# Default provider: resources with no provider= argument use this one.
self.primary: AwsProvider = AwsProvider(
self,
"aws_primary",
region=config.primary_region,
)
# Aliased provider: only resources that pass provider=self.replica use it.
self.replica: AwsProvider = AwsProvider(
self,
"aws_replica",
region=config.replica_region,
alias="replica",
)
2. Bind resources to a specific provider
Pass the captured provider object via provider=. Resources omitting provider= use the default; resources passing the alias deploy to the second region.
# CLI: cdktf synth (inspect cdktf.out/stacks/<name>/cdk.tf.json to confirm provider keys)
# State implication: both buckets are tracked in the SAME state file for this stack.
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
from cdktf_cdktf_provider_aws.s3_bucket_versioning import (
S3BucketVersioningA,
S3BucketVersioningVersioningConfiguration,
)
# Inside MultiRegionStack.__init__, after the providers above:
primary_bucket = S3Bucket(
self,
"primary_data",
bucket="acme-data-primary",
# No provider= -> uses the default (primary) provider.
)
S3BucketVersioningA(
self,
"primary_versioning",
bucket=primary_bucket.id,
versioning_configuration=S3BucketVersioningVersioningConfiguration(
status="Enabled",
),
)
replica_bucket = S3Bucket(
self,
"replica_data",
bucket="acme-data-replica",
provider=self.replica, # Routes this resource to eu-west-1.
)
S3BucketVersioningA(
self,
"replica_versioning",
bucket=replica_bucket.id,
provider=self.replica, # The dependent resource MUST also pin the alias.
versioning_configuration=S3BucketVersioningVersioningConfiguration(
status="Enabled",
),
)
3. Use aliases for multi-account via assume-role
The same alias mechanism handles a second AWS account. Give the aliased provider an assume_role block instead of (or in addition to) a different region.
# CLI: cdktf synth && cdktf deploy --stack multi-account
# Provider note: the assume_role chain must be permitted by the secondary account's trust policy.
from typing import Optional
from cdktf_cdktf_provider_aws.provider import AwsProvider, AwsProviderAssumeRole
def add_secondary_account_provider(
stack: TerraformStack,
secondary_account_id: str,
region: str = "us-east-1",
role_name: str = "cdktf-deployer",
) -> AwsProvider:
role_arn: str = f"arn:aws:iam::{secondary_account_id}:role/{role_name}"
return AwsProvider(
stack,
"aws_secondary",
region=region,
alias="secondary",
assume_role=[
AwsProviderAssumeRole(
role_arn=role_arn,
session_name="cdktf-multi-account",
)
],
)
Bind any resource that must live in the secondary account by passing provider= the object returned from add_secondary_account_provider, exactly as in step 2.
4. Synthesize and deploy
Run synthesis, inspect the generated JSON to confirm both provider entries appear, then deploy the stack as a unit.
# Generate bindings, synthesize, and review the provider block before deploying.
cdktf get
cdktf synth
# Both default and aliased providers should appear under "provider" -> "aws".
cat cdktf.out/stacks/multi-region/cdk.tf.json | python -m json.tool | grep -A2 '"alias"'
cdktf deploy --stack multi-region
Verification
Confirm the synthesized configuration contains both the default and aliased providers, and that the replica resource carries the alias reference. A pytest assertion against the synthesized JSON catches routing mistakes before deploy.
# CLI: pytest tests/test_multi_provider.py
import json
from cdktf import Testing
from my_stack import MultiRegionStack, RegionConfig
def test_both_providers_and_alias_routing() -> None:
app = Testing.app()
stack = MultiRegionStack(app, "multi-region", RegionConfig())
synthesized = json.loads(Testing.synth(stack))
providers = synthesized["provider"]["aws"]
assert isinstance(providers, list) and len(providers) == 2
regions = {p["region"] for p in providers}
assert regions == {"us-east-1", "eu-west-1"}
# The replica bucket must reference the aliased provider as "aws.replica".
replica = synthesized["resource"]["aws_s3_bucket"]["replica_data"]
assert replica["provider"] == "aws.replica"
A passing test plus a cdktf diff that shows resources targeted at the expected regions confirms the routing is correct.
Gotchas & Edge Cases
Dependent resources need the alias too. Binding the parent resource (the bucket) to an alias does not propagate to dependent resources (the versioning config, bucket policy, replication rule). Each resource that should live in the aliased region must pass
provider=itself. Omitting it on a child resource silently deploys that child to the default region, splitting one logical resource across two regions.
assume_roleis a list, not a dict. The CDKTF AWS provider modelsassume_roleas a repeatable block, so the Python argument expects[AwsProviderAssumeRole(...)]— a list with a single element. Passing a bare object raises a synthesis-time type error.
One state file, larger blast radius. Because every provider in the stack shares one state file, a corrupted apply can affect resources across all regions and accounts at once. For loosely coupled environments prefer separate stacks (and separate state) over many aliases; reserve multi-provider stacks for genuinely co-dependent resources such as cross-region replication pairs.
default_tagsdo not carry across aliases. Tag defaults are a property of a provider configuration, not of the provider plugin. Settingdefault_tags=[AwsProviderDefaultTags(tags={"owner": "platform"})]on the primary provider leaves every resource bound to the replica alias untagged. Build the tag dictionary once in Python and pass the same object to everyAwsProvideryou construct — this is exactly the kind of duplication a general-purpose language is supposed to remove.
Data sources bind like resources.
DataAwsCallerIdentity,DataAwsAvailabilityZonesand friends accept the sameprovider=argument. A caller-identity data source without a binding reports the primary account ID, so a guard clause built on it will happily approve a deployment into the wrong account. Bind every data source you use for assertions.
Operational Notes
Multi-provider stacks change the operational envelope of a deployment in ways that are easy to miss until an apply fails in production.
One plugin version for every alias. terraform.required_providers records a single constraint per provider type. All five of your aws aliases load the same plugin binary, so you cannot pin one alias to AWS provider 5.x and another to 6.x. If a legacy account genuinely needs an older provider — because a resource argument was renamed between majors — the only correct answer is a separate stack with its own cdktf.json constraint. Attempting it in one stack surfaces as a plan-time schema error on whichever resource uses the argument that moved.
Credentials resolve per configuration, independently. Each provider entry walks the standard AWS credential chain on its own: explicit arguments, then AWS_ACCESS_KEY_ID/AWS_PROFILE environment variables, then the shared config file, then instance or container metadata. Because you set region= explicitly on every provider in the pattern above, a stray AWS_REGION in the CI runner cannot silently move resources — but a stray AWS_PROFILE absolutely can move the account. Pin the account identity with assume_role or with an explicit profile= argument rather than relying on the runner's ambient environment.
Assume-role sessions expire mid-apply. The default STS session is one hour. A stack that creates an RDS instance and a CloudFront distribution can exceed that comfortably, and the failure looks like this:
# CLI: cdktf deploy --stack multi-account
Error: creating S3 Bucket (acme-logs-central): operation error S3: CreateBucket,
https response error StatusCode: 400, api error ExpiredToken: The provided
token has expired.
Set an explicit duration on the assume-role block — the AWS provider accepts a Go duration string — and make sure the target role's MaxSessionDuration is at least as large, or STS rejects the AssumeRole call outright.
# CLI: cdktf synth && cdktf deploy --stack multi-account
# Provider note: duration must be <= the role's MaxSessionDuration or AssumeRole fails.
from cdktf_cdktf_provider_aws.provider import AwsProvider, AwsProviderAssumeRole
long_running: AwsProvider = AwsProvider(
stack,
"aws_logging",
region="us-east-1",
alias="logging",
assume_role=[
AwsProviderAssumeRole(
role_arn="arn:aws:iam::222222222222:role/cdktf-deployer",
session_name="cdktf-logging",
duration="3h",
)
],
)
Parallelism is a whole-graph budget. Terraform's default of ten concurrent operations is not divided per provider. A stack touching three accounts can therefore drive thirty in-flight API calls at one account if the graph happens to schedule that way, which is how multi-provider stacks discover ThrottlingException: Rate exceeded. Lower it for wide stacks with cdktf deploy --parallelism=4, and accept the longer wall-clock time as the price of a predictable apply.
Targeted recovery. CDKTF does not expose Terraform's -target flag directly, but the synthesized directory is a normal Terraform working directory. After a partial failure you can cd cdktf.out/stacks/multi-region and run terraform apply -target=aws_s3_bucket.replica_data against the same state file, then return to cdktf deploy for the full graph. Treat this as a break-glass procedure and re-run a complete cdktf diff afterwards to confirm the stack converged.
FAQ
How many providers can I declare in a single CDKTF stack? One default plus any number of aliased providers per provider type, and you may mix provider types (for example AWS and Cloudflare) in the same stack. The practical limit is operational: every provider shares one state file, so a very large multi-provider stack concentrates risk. Split into multiple stacks when resources have independent lifecycles.
Do I have to set an alias on the default provider?
No. Declare exactly one provider without an alias argument — that becomes the implicit default for any resource that omits provider=. Adding an alias to every provider and never declaring a default works too, but then every resource must explicitly pass provider=, which is more verbose.
Can I reference outputs from a resource in one provider when creating a resource in another?
Yes. Resource attributes (for example primary_bucket.arn) are plain token references within the stack and cross provider boundaries freely during synthesis. This is the main advantage of one multi-provider stack over separate stacks, where you would need remote state data sources to share values.
Why did my resource deploy to the wrong region despite passing a region to the provider?
The most common cause is a dependent resource missing its provider= binding, so it fell through to the default provider's region. Inspect cdk.tf.json and confirm every related resource carries the same "provider": "aws.<alias>" value. The verification test above catches this.
Can two aliases use different versions of the AWS provider?
No. The version constraint lives in terraform.required_providers and is keyed by provider type, so every aws alias in the stack shares one plugin binary. If one account needs an older major version because of a renamed resource argument, split it into its own stack with its own cdktf.json constraint.
Do data sources need a provider= binding as well?
Yes, and forgetting it is more dangerous than forgetting it on a resource because nothing is created, so nothing looks wrong. A DataAwsCallerIdentity without a binding returns the primary account's ID, which will quietly defeat any guard clause you built on top of it.
How do I keep an alias out of the default provider's tag defaults?
You do not need to — default_tags is per provider configuration and does not propagate between them. The opposite problem is the real one: to get consistent tags you must pass the same tag dictionary explicitly to every AwsProvider you construct.
Related
- Terraform Provider Bridging — the parent guide on translating provider schemas into typed Python classes.
- Converting Existing Terraform HCL to CDKTF Python — replicate aliased and multi-region providers when migrating from HCL.
- State Backend Configuration for CDKTF — isolate and lock the shared state file a multi-provider stack writes to.