CloudFront draws a line CDK can’t route around: the certificate has to sit in us-east-1, no matter where the rest of the platform runs. Mine ran in us-west-2. So the stacks split along the same line — “we would have two stacks, one for each region” — one that creates the ACM certificate, one that consumes it. (A separate note covers how I choose between Terraform and CDK.)
Getting the second stack to use a resource the first one created was the scar: “it was really difficult for that to work.” The shape that held: write the value to SSM Parameter Store from the stack that creates it, read it back from the stack that needs it. “I would have it write to SSM parameter store, then I would be able to go read from that SSM parameter store in the other stack. And once we got that solved, everything worked just fine. But it was quite painful to get to that point.”
Why a CloudFormation export can’t cross a region
A CDK cross-region stack reference runs into a wall that has nothing to do with CDK’s own code: the
CloudFormation primitive underneath it — a stack Export and the
Fn::ImportValue that reads it — is scoped to a single account and Region by design.
The docs are explicit: “cross-stack references are limited to the same account and Region.” A stack in us-east-1 can export
its certificate ARN; nothing running in us-west-2 can Fn::ImportValue it back — that’s the
wall this scar collided with.
The courier: SSM Parameter Store between stacks
Parameter Store is just as region-scoped as everything else in this story — the courier isn’t a scope
exception, it’s an explicit query.
ssm.StringParameter
in us-east-1 is a resource that lives in us-east-1, full stop. What the docs show makes that work as a
courier: a piece of deploy-time code running in the us-west-2 stack can call SSM’s
GetParameter API against us-east-1 directly, by name — a plain cross-region API call, not a
CloudFormation reference of any kind. Write the certificate ARN under a fixed name in us-east-1; have the
consuming stack ask for that name, in that region, when it deploys. The two stacks never reference each
other through CloudFormation at all, which is exactly why the shape can work where the native mechanism
doesn’t.
None of the code below is from that build. It’s reference, written from the current CDK API docs.
The us-east-1 stack creates the certificate and writes its ARN to a fixed parameter name.
import { Stack, StackProps } from "aws-cdk-lib";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
export const CERT_ARN_PARAM = "/platform/cloudfront/certificate-arn";
export class CertificateStack extends Stack {
public readonly certificate: acm.ICertificate;
constructor(scope: Construct, id: string, props: StackProps) {
// props.env.region must be "us-east-1" — CloudFront's own rule.
super(scope, id, props);
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
hostedZoneId: "Z1EXAMPLE23456",
zoneName: "example.com",
});
this.certificate = new acm.Certificate(this, "Certificate", {
domainName: "www.example.com",
validation: acm.CertificateValidation.fromDns(zone),
});
new ssm.StringParameter(this, "CertificateArnParam", {
parameterName: CERT_ARN_PARAM,
stringValue: this.certificate.certificateArn,
});
}
}
Three CDK APIs read an SSM parameter back, and none of them alone is a deploy-time cross-region read.
StringParameter.valueForStringParameter and
StringParameter.fromStringParameterName (via its stringValue) both resolve, with
no version given, to a CloudFormation parameter of the special
AWS::SSM::Parameter::Value<String> type — not a dynamic reference — filled from SSM
in the stack being deployed: called from us-west-2, either one looks for the parameter in
us-west-2, where it doesn’t exist. StringParameter.valueFromLookup resolves earlier still, at
synthesis time, through a live SDK call the CDK CLI itself makes before any CloudFormation deploy starts —
and it has the same region restriction as the other two: the lookup runs against the calling stack’s own
account and region, with no option in its signature to point it at us-east-1 instead. It also caches
whatever it finds in cdk.context.json, frozen there until that context is explicitly cleared,
so a rotated parameter goes unnoticed until someone remembers to reset it. (It also needs an explicit
account, not just a region, on the stack that performs it, or CDK refuses to synthesize.) None of the
three gets there. What actually works on a first deploy sits outside that list: an
AwsCustomResource
in the us-west-2 stack, making a live GetParameter call against us-east-1 during the
us-west-2 stack’s own deployment — not at synth time — after an explicit dependency guarantees the
certificate stack has already finished.
| API | Resolves when | Reads which region | Crosses? | Gotcha |
|---|---|---|---|---|
StringParameter.valueForStringParameter |
Deploy time, as a CloudFormation AWS::SSM::Parameter::Value<String> parameter
|
The calling stack’s own region | No | Looks for the parameter where it doesn’t exist |
StringParameter.fromStringParameterName (.stringValue) |
Deploy time, same CloudFormation parameter type | The calling stack’s own region | No | Same wrong-region miss |
StringParameter.valueFromLookup |
Synthesis time, via a live SDK call the CDK CLI makes before deploy | The calling stack’s own account and region | No |
Caches the result in cdk.context.json until explicitly cleared — a rotated parameter
goes unnoticed
|
AwsCustomResource (GetParameter) |
The consuming stack’s own deployment, after an explicit dependency on the certificate stack | Whichever region is passed explicitly | Yes | The one that actually works — needs that dependency wired by hand |
The us-west-2 stack reads the ARN back with a custom resource and attaches it to CloudFront.
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as cr from "aws-cdk-lib/custom-resources";
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { CERT_ARN_PARAM } from "./certificate-stack";
export interface DistributionStackProps extends StackProps {
certificate?: acm.ICertificate;
}
export class DistributionStack extends Stack {
constructor(scope: Construct, id: string, props: DistributionStackProps) {
// props.env.region must be "us-west-2" — where the platform runs.
super(scope, id, props);
const certificate = props.certificate ?? this.readCertificateArn();
new cloudfront.Distribution(this, "Distribution", {
defaultBehavior: {
origin: new origins.HttpOrigin("origin.example.com"),
},
domainNames: ["www.example.com"],
certificate,
});
}
// This read side of the SSM courier — skipped when a certificate is
// passed in directly, as the crossRegionReferences exhibit below does.
// (The write side still runs either way: CertificateStack always
// writes the parameter, whether or not this stack ever reads it.)
private readCertificateArn(): acm.ICertificate {
const lookup = new cr.AwsCustomResource(this, "CertificateArnLookup", {
onUpdate: {
service: "ssm",
action: "GetParameter",
parameters: { Name: CERT_ARN_PARAM },
region: "us-east-1",
// A fresh id forces GetParameter to run on every deploy —
// a fixed id would freeze the ARN at its first-read value.
physicalResourceId: cr.PhysicalResourceId.of(Date.now().toString()),
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE,
}),
});
return acm.Certificate.fromCertificateArn(
this,
"ImportedCertificate",
lookup.getResponseField("Parameter.Value"),
);
}
}
Wire the two stacks with an explicit dependency in the app entry file — call addDependency on
the distribution stack, passing the certificate stack — so CloudFormation finishes deploying the
certificate stack, parameter and all, before this stack’s custom resource ever runs. That’s the CDK CLI’s
own default: cdk deploy DistributionStack still deploys CertificateStack first
automatically to satisfy the dependency; only cdk deploy --exclusively, or applying a
synthesized template outside the CDK CLI entirely, skips it. A missing or misnamed parameter fails loudly
too: the custom resource’s create fails and the deploy rolls back, not a silently broken certificate.
The read is also point-in-time, the same trade Fn::GetStackOutput makes below — though
ordinary renewal is a non-event: ACM’s managed renewal replaces a certificate’s contents in place, same
ARN, and CloudFront picks it up without anyone redeploying anything. The gap only opens when the
certificate resource itself gets replaced — a new ARN written to the parameter — and the
us-west-2 stack never redeploys to pick it up: CloudFront keeps serving the old certificate, which
stays eligible for managed renewal
as long as it’s still in use and its DNS validation record still resolves. TLS only actually breaks if
that record gets removed — say, the old stack’s validation records are cleaned up — the certificate is
deleted, or renewal otherwise fails.
Replacing the certificate resource is where the deletion side of that same gap gets sharper.
CloudFormation creates the new certificate first, updates the SSM parameter to its ARN, and only then
tries to delete the old certificate — but
ACM refuses to delete a certificate that’s still in use by another service. If the still-un-redeployed us-west-2 stack has CloudFront pinned to that old ARN,
the delete keeps failing and CloudFormation gives up after three tries
— the certificate stack still reaches its own “complete” state, just with the old certificate orphaned and
a failed-delete event logged against it. Redeploying us-west-2 is what actually clears it: that stack
already has the new ARN waiting in the parameter, and moving CloudFront onto it frees the old certificate
to delete by hand — or set CDK’s RemovalPolicy.RETAIN up front and skip the race entirely.
The documented alternative: crossRegionReferences
CDK has a purpose-made answer to this exact problem:
crossRegionReferences: true
on Stack props. Its own doc comment says turning it on “will create a CloudFormation custom
resource in both the producing stack and consuming stack in order to perform the export/import” — but
which mechanism it actually produces depends on the reference strength the consuming stack is
configured with, via the @aws-cdk/core:defaultCrossStackReferences context flag. Leave that
flag unconfigured — still the default in 2.267.0, and CDK emits a synth warning about it — and the
strength is “strong,” in which case crossRegionReferences does exactly what the doc comment
describes: a paired ExportWriter/ExportReader custom resource, one that writes
to SSM and one that reads it back, wired up for you on both ends instead of by hand. Set the flag to
“weak” instead — the value cdk init writes for new projects, and CDK’s own recommended value
— and crossRegionReferences produces a single Fn::GetStackOutput
reference instead (the intrinsic described below): no custom resources, no SSM. The docs still call the
flag “currently experimental.” It’s also a narrower guardrail than its own doc comment suggests, on the
strong path: CDK’s source calls the default cross-region behavior a
“strong” reference, and that comment says plainly that “the producing stack cannot be deleted while consumers exist” — the
same producer-protecting constraint a native same-region export carries. For a same-region export,
CloudFormation itself enforces that. Cross-region, the enforcement was the generated custom resource
checking a tag on the SSM parameter before allowing a delete — and in the exact aws-cdk-lib version these
exhibits typecheck against (2.267.0), that check no longer runs: the doc comment describes intent the
generated code doesn’t carry out for a cross-region reference. New CDK projects are also initialized with
the opposite default now, a “weak” reference. Not the route in this story either way — the SSM courier is
what I actually shipped.
AWS has since gone further still.
Fn::GetStackOutput
is a newer CloudFormation intrinsic that reads another stack’s output directly — same account or not, same
Region or not — without an Export at all, and CDK exposes it as
Fn.getStackOutput(). The same page says plainly that CDK “can now use Fn::GetStackOutput
natively instead of generating custom resources with SSM parameters,” which “removes the need for the
previous crossRegionReferences workaround.” AWS’s own comparison table calls the reference
“weak”: resolved once at deploy time, not re-checked if the source changes later. That’s the honest trade
for skipping both the export ceremony and the custom-resource plumbing
crossRegionReferences needed.
| Mechanism | Generates | Strength | Producer protection | Status in 2.267.0 |
|---|---|---|---|---|
| SSM courier (what I shipped) |
An SSM String parameter, written by the producer stack, read by a hand-rolled custom
resource in the consumer
|
— | None from CDK — only ACM’s own refusal to delete a certificate still in use provides a safety net | Unaffected — outside the crossRegionReferences machinery entirely |
Strong reference (crossRegionReferences, unconfigured flag) |
A paired ExportWriter/ExportReader custom resource — writes to SSM,
reads it back — wired up automatically on both ends
|
Strong (the default when the flag is left unset) | “The producing stack cannot be deleted while consumers exist,” per CDK’s own source comment — the same guarantee a same-region export carries | The doc comment describes intent the generated code doesn’t carry out: the tag check that used to enforce it on delete no longer runs; new projects now initialize with the opposite (“weak”) default |
Weak reference / Fn::GetStackOutput |
A single Fn::GetStackOutput reference — no custom resources, no SSM parameter |
Weak — the cdk init and CDK-recommended default |
Not stated — resolved once at deploy time, not re-checked if the source changes later |
Current — CDK’s own docs say it “removes the need for the previous
crossRegionReferences workaround”
|
The alternative I didn’t use — a direct property pass instead of a name:
const certStack = new CertificateStack(app, "CertificateStack", {
env: { region: "us-east-1" },
crossRegionReferences: true,
});
new DistributionStack(app, "DistributionStack", {
env: { region: "us-west-2" },
crossRegionReferences: true,
certificate: certStack.certificate,
});
What I’d do again
Two stacks, one hard constraint, one courier. If the constraint recurs — a single-region service pinned by AWS’s own rule, everything else running somewhere else — write the value somewhere both stacks can reach independently, and read it back by name. That’s slower to wire up than a stack export, and “it was quite painful to get to that point” the first time. It held anyway.
More field notes
Model and effort picked by pipeline stage, with the reviewer on a different vendor by design
Claude Code, OpenAI Codex, Grok, Qwen
Newer · Aug 2026
Terraform state split by change velocity, not only by environment
Terraform, Amazon S3, Amazon DynamoDB, AWS Systems Manager Parameter Store
Older · Aug 2026
Start
I’ll tell you in about a day whether I’m the right person. The first conversation is fit, not a free architecture review.