Prefer Loose Dependencies Between CDK Stacks
Passing construct references across stacks looks clean until you remove one — then CloudFormation export/import order bites you. Publish ARNs via SSM instead.
What you will read
This is a field note from multi-stack CDK apps where “just pass the bucket” turned into a stuck deploy.
You will see what CDK does behind a cross-stack reference (CloudFormation exports and imports), how that creates a hard dependency, why removing the dependency later often fails in a way that is painful to unwind — especially across parameterized environments — and the pattern I use instead: own the resource in one stack, publish its identity in SSM, import it in the other.
The through-line: do not share live CDK references between sibling stacks; share names and import.
The tempting pattern
CDK makes cross-stack wiring feel like ordinary TypeScript:
// StackA owns the bucket
const bucket = new s3.Bucket(this, 'Uploads')
// StackB receives the construct and uses it
new StackB(app, 'StackB', { bucket })
That reads well. One object, one type, permissions and grants “just work.” Under the hood, though, CDK is not passing a pointer at deploy time. By default it asks CloudFormation to export a value from StackA and import it into StackB (Fn::ImportValue).
That is a hard (strong) dependency.
How CDK deployment actually wires stacks
When you pass a construct across sibling stacks, CDK records a cross-stack dependency and, on cdk deploy --all (or equivalent), orders those stacks: producers first, consumers next. CloudFormation then applies each stack’s template and enforces export/import rules between them.
Imagine:
- StackA creates an S3 bucket
- StackB depends on that bucket from StackA
When you deploy, CDK wires them like this:
- Provision S3 bucket
- Create CFN output / export
- Import CFN export (
Fn::ImportValue) - Provision resources that use it
While both stacks keep that link, deploys are usually fine. The trap is not creation — it is deletion of the link.
Where it breaks: removing the dependency
Suppose StackB no longer needs the bucket. You delete the reference in code and redeploy.
What you expect: StackB updates, StackA drops the unused export, life goes on.
What often happens — when the producer updates first:
- Remove cross-stack reference from code
- Tries to delete CFN export
- Deploy fails — export still in use
- Still imports the CFN export
- Not updated yet
That is the classic cross-stack “deadly embrace”: the producer cannot drop the export while the consumer still imports it. If CDK happens to deploy the consumer first, the remove can succeed — but producer-first is common (especially if another hard link still ties the stacks together), so you should not rely on luck.
The painful escape hatch
The fix is a two-phase deploy, not a one-shot cdk deploy --all:
exportValue, then two deploysPhase 1 — update the consumer first
- Keep export alive via
exportValue(...)
- Drop
Fn::ImportValue - Deploy exclusively
Phase 2 — then drop the export
- Remove
exportValue(...) - Deploy — export can disappear
- No longer imports the export
That is already annoying in one account. In a multi-environment, parameterized CDK app it gets worse: you repeat the two-phase dance per stage. exportValue removes the “guess the export name” problem, but it does not remove the operational tax of staged deploys across every environment that still has the hard link.
I stopped wanting that class of incident in the first place.
Hard dependency vs loose dependency
| Hard (pass the construct) | Loose (publish + import) | |
|---|---|---|
| How StackB learns the resource | CDK reference → CFN export/import | Stable name (SSM param) → import by ARN/name |
| Deploy graph | CDK orders StackB after StackA’s export | No CFN export coupling; add an explicit dependency only if you need first-deploy ordering |
| Removing the link | Often needs exportValue + two-phase deploy |
Drop usage in StackB; StackA can keep or delete the param on its own schedule |
| Multi-env pain | Two-phase recovery in every stage that still exports | You choose the parameter path; it is predictable |
Hard dependencies optimize for “one deploy wires everything.” Loose dependencies optimize for change and teardown — which is most of the life of a real system.
Newer CDK also has weaker reference strengths (for example mechanisms that avoid a lasting Fn::ImportValue). I still prefer SSM for sibling-stack sharing: the contract stays an explicit, environment-scoped name I control.
The pattern I use: SSM as the handshake
Do not pass the bucket (or VPC, queue, table, …) construct across sibling stack boundaries. Publish an identifier; import it. (Passing constructs into a NestedStack is a different shape and is fine when that boundary is intentional.)
- Provision S3 bucket
- Write ARN to SSM parameter
- Read SSM parameter
- Import bucket by ARN
- Provision using the import
No CFN Export / ImportValue between the stacks.
StackA — own the resource, write the ARN
const bucket = new s3.Bucket(this, 'Uploads', {
// …your settings
})
new ssm.StringParameter(this, 'UploadsBucketArn', {
parameterName: `/myapp/${envName}/uploads-bucket-arn`,
stringValue: bucket.bucketArn,
})
StackA is the source of truth for the bucket and for a stable, environment-scoped parameter path.
StackB — read the parameter, import the bucket
const bucketArn = ssm.StringParameter.valueForStringParameter(
this,
`/myapp/${envName}/uploads-bucket-arn`,
)
const bucket = s3.Bucket.fromBucketArn(this, 'Uploads', bucketArn)
// use bucket — role policies, event sources, env vars, etc.
No construct hand-off. No CDK-managed export between these stacks. StackB depends on a name it already knows, not on StackA’s generated output id.
valueForStringParameter resolves through CloudFormation’s SSM dynamic reference. That is still “look up at deploy time,” but it is not a cross-stack Export / ImportValue pair — so removing usage in StackB does not require StackA to keep a brittle export alive for the next update.
One practical caveat: an imported bucket is not “owned” in StackB. Grants typically land on the consumer role (identity policy). If you need a bucket resource policy, cross-account access, or APIs that insist on a concrete name at synth time, plan for that explicitly — do not assume construct-passing behavior comes along for free.
Practical rules of thumb
- Same stack (or nested stack) when resources truly share a lifecycle. If they always create and destroy together, co-locate them and skip the ceremony.
- Sibling stacks: publish identity, import by ARN/name. SSM Parameter Store is my default handshake; Secrets Manager or a well-known naming convention can play the same role when that fits better.
- Avoid passing
IBucket/IVpc/IFunctionprops between sibling stacks just because TypeScript allows it — that is how the hard export graph appears. Nested stacks are the intentional exception. - Parameter paths are part of the contract. Keep them stable and parameterized by env (
/myapp/dev/...,/myapp/prod/...) so every stage is predictable without inspecting generated exports. - First deploy order still matters once. StackA (or whatever writes the parameter) must exist before StackB can resolve it — use
addDependencyor deploy producers first. After that, you can evolve consumers without dancing around exports. - Expect a migration if you already have hard links. Use
exportValue+ consumer-first deploy to break the embrace, then switch producers to SSM. Do not invent that under an outage.
What this is not
This is not “never use nested stacks” or “one stack forever.” Nested stacks and carefully owned shared stacks are fine when the boundary is intentional.
It is also not a claim that SSM is free of ops concerns — you still need IAM to read parameters, and you should not put secrets in plain StringParameter values.
It is a default for sibling-stack resource sharing after hitting the export-removal wall enough times: loose coupling via published identifiers beats construct-passing convenience.
Conclusion
Cross-stack construct references feel like good design in the IDE. By default they become CloudFormation exports and imports — a hard dependency graph that is easy to grow and hard to shrink.
When you delete the dependency, the deploy often fails if the producer updates first while the consumer still imports the export. Recovery is a two-phase deploy (keep the export with exportValue, update the consumer, then drop the export); multi-env CDK multiplies that tax across every stage.
Own the resource in one stack. Put its ARN (or name) in SSM. Import it in the other. You keep clear ownership, predictable environment paths, and the freedom to remove a dependency without fighting CloudFormation’s export rules.
Resources
- AWS CDK: Pass values between stacks — how CDK turns cross-stack references into exports/imports
- CDK:
Stack.exportValue— keep an export alive during the two-phase unblock - CloudFormation: Outputs — export names and
Fn::ImportValue - CloudFormation: SSM dynamic references — how templates resolve parameter values at deploy time
- CDK:
StringParameter—valueForStringParameterand related helpers - CDK:
Bucket.fromBucketArn— importing an existing bucket without owning it - AWS Systems Manager Parameter Store — the handshake store in this pattern