Write CDK stacks in TypeScript that deploy cleanly
Structure a CDK v2 TypeScript app so it deploys and redeploys without surprises — explicit env, generated physical names, removal policies per stage, grants (the new grants API), cross-stack references and the export-in-use lock (and the WEAK reference strength that uses Fn::GetStackOutput), context lookups, the resource limit, qualifiers — with a type-checked, synthesized example.
markdownguidev40EJH-JBZ8stage: buildtrust: officialservice: cdkservice: cloudformationinterface: iacCDK TypeScriptaws-cdk-libStack env account regionenvironment-agnosticPhysicalName.GENERATE_IF_NEEDEDRemovalPolicy RETAIN DESTROYautoDeleteObjectsgrants.readBucketGrants.fromBucketgrantRead deprecatedTableV2 grants.readWriteDatacross-stack referenceExport cannot be deleted as it is in use bydeadly embraceexportValueReferenceStrength WEAKCrossStackReferencesFn::GetStackOutput CDKVpc.fromLookupcdk.context.jsonstackResourceLimitDefaultStackSynthesizer qualifierprepare-change-set
Pin env on every stack, let CloudFormation generate names, decide removal policies per stage, and keep cross-stack references few and deliberate. Most failed CDK re-deploys come from a hard-coded name colliding with a retained resource, or from an export another stack still imports.
The example
It is type-checked with aws-cdk-lib 2.273.0 and --strict, and was synthesized locally. The notes after it describe what the synthesized templates contained.
import { App, Stack, type StackProps, RemovalPolicy, Duration, CfnOutput,
CrossStackReferences, ReferenceStrength } from "aws-cdk-lib";
import type { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION };
const isProd = process.env.STAGE === "prod";
class DataStack extends Stack {
readonly bucket: s3.Bucket;
readonly table: dynamodb.TableV2;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.bucket = new s3.Bucket(this, "Uploads", {
// No bucketName: a generated name can't collide on replacement or redeploy.
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
autoDeleteObjects: !isProd, // only with DESTROY; adds a custom resource that empties the bucket
});
this.table = new dynamodb.TableV2(this, "Items", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
timeToLiveAttribute: "expiresAt",
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
});
}
}
interface ApiStackProps extends StackProps { bucket: s3.IBucket; table: dynamodb.ITableV2 }
class ApiStack extends Stack {
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
const fn = new lambda.Function(this, "Handler", {
runtime: lambda.Runtime.NODEJS_24_X,
handler: "index.handler",
code: lambda.Code.fromAsset("lambda"),
timeout: Duration.seconds(10),
environment: { BUCKET: props.bucket.bucketName, TABLE: props.table.tableName },
});
s3.BucketGrants.fromBucket(props.bucket).read(fn); // = bucket.grants.read(fn); replaces grantRead
props.table.grants.readWriteData(fn);
new CfnOutput(this, "FunctionName", { value: fn.functionName });
}
}
const app = new App();
const data = new DataStack(app, "Data", { env, terminationProtection: isProd });
new ApiStack(app, "Api", { env, bucket: data.bucket, table: data.table });
// Consumers read the bucket through Fn::GetStackOutput (no export lock); the table stays a strong export.
CrossStackReferences.of(data.bucket).produce(ReferenceStrength.WEAK);
app.synth();
What synthesis produced:
Dataexported the table asData:ExportsOutputRefItems…, andApiread it withFn::ImportValue.- For the bucket,
Dataemitted plain outputs (PublishOutput…), andApiread them withFn::GetStackOutput. TableV2becameAWS::DynamoDB::GlobalTable, with TTL and PITR set.- The dev-stage bucket got
DeletionPolicy: Deleteand aCustom::S3AutoDeleteObjectsresource, which adds a Lambda function and role. grants.readWriteDataincluded the stream-read actions.
Rules that prevent failed deploys
- Set
env(account and Region). Without it, a stack is environment-agnostic.Vpcthen uses at most two AZs, andfromLookupcan't run. Lookups query the account at synth time and cache results incdk.context.json: commit that file. - Don't hard-code physical names (
bucketName,tableName,roleName) unless something outside needs them. Fixed names break replacement updates, and they collide with retained resources from an earlier stack (already exists). If another environment needs the name, usePhysicalName.GENERATE_IF_NEEDED. - Removal policies: data resources default to
RETAIN, socdk destroyleaves them and the next deploy collides. UseDESTROY(plusautoDeleteObjectsfor buckets) only in disposable stages. Remember that the policy doesn't protect a resource CloudFormation replaces (reference/cloudformation/change-sets-drift-and-import.md). - Grants over hand-written policies.
x.grants.read(fn),table.grants.readWriteData(fn)and similar add least-privilege statements to the function's role and return aniam.Grant. The olderbucket.grantRead()still exists but is marked discouraged in favour ofgrants.read(). Imported resources (Bucket.fromBucketName,Role.fromRoleArn) can't be modified:addToResourcePolicyon an imported bucket does nothing, so put that policy where the bucket is defined. - Cross-stack references. Passing a construct between stacks in the same environment creates an
Export/Fn::ImportValuepair. Removing that reference later fails withExport <stack>:ExportsOutput… cannot be deleted as it is in use by <stack>, because the producer can't drop the export while the consumer still imports it. Your options:
- Classic two-step fix: in the producer,
this.exportValue(<value>)keeps the export alive. Deploy the consumer without the reference, then remove theexportValueand deploy again. - Weak references (
ReferenceStrength.WEAKviaCrossStackReferences.of(x).produce(…), or.consume(…)on the consuming scope) useFn::GetStackOutput, so there is no lock. The type docs warn that the producer can then change or disappear while consumers still point at it, until they're redeployed.BOTHis the transitional strength for migrating. - Stacks in other Regions need
crossRegionReferences: trueon the stack (strong, through custom resources) or a weak reference.
- The resource limit. CloudFormation caps resources per stack, and one L2 construct expands to several resources (the Fargate pattern produces 50+). Synth fails with
Number of resources in stack '<path>': <n> is greater than allowed maximum of <n>: …, and an info note appears as you approach it. Split stacks or useNestedStack. The limit check reads context@aws-cdk/core:stackResourceLimitin this library version. The guide'smaxResourcesstack property isn't in 2.273.0's types. - Custom qualifier: if the environment was bootstrapped with
--qualifier, passsynthesizer: new DefaultStackSynthesizer({ qualifier: "<q>" })or set context@aws-cdk/core:bootstrapQualifier. Otherwise the deploy looks for/cdk-bootstrap/hnb659fds/versionand fails (reference/cdk/errors.md).
Before deploying
cdk synthwritescdk.out/<Stack>.template.json. Check its resource types and anyDeletionPolicythere.cdk deploy --method prepare-change-setcreates the change set without executing it. Review itsReplacementvalues (reference/cloudformation/change-sets-drift-and-import.md), and treat replacement of a stateful resource as a stop sign. Execute it with--method execute-change-set.cdk deploycreates and executes a change set by default (guides/cdk/bootstrap-and-deploy.md).