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

Created 18 Oct 2026 · Modified 23 Oct 2026 · 4 versions

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:

  • Data exported the table as Data:ExportsOutputRefItems…, and Api read it with Fn::ImportValue.
  • For the bucket, Data emitted plain outputs (PublishOutput…), and Api read them with Fn::GetStackOutput.
  • TableV2 became AWS::DynamoDB::GlobalTable, with TTL and PITR set.
  • The dev-stage bucket got DeletionPolicy: Delete and a Custom::S3AutoDeleteObjects resource, which adds a Lambda function and role.
  • grants.readWriteData included the stream-read actions.

Rules that prevent failed deploys

  1. Set env (account and Region). Without it, a stack is environment-agnostic. Vpc then uses at most two AZs, and fromLookup can't run. Lookups query the account at synth time and cache results in cdk.context.json: commit that file.
  2. 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, use PhysicalName.GENERATE_IF_NEEDED.
  3. Removal policies: data resources default to RETAIN, so cdk destroy leaves them and the next deploy collides. Use DESTROY (plus autoDeleteObjects for buckets) only in disposable stages. Remember that the policy doesn't protect a resource CloudFormation replaces (reference/cloudformation/change-sets-drift-and-import.md).
  4. 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 an iam.Grant. The older bucket.grantRead() still exists but is marked discouraged in favour of grants.read(). Imported resources (Bucket.fromBucketName, Role.fromRoleArn) can't be modified: addToResourcePolicy on an imported bucket does nothing, so put that policy where the bucket is defined.
  5. Cross-stack references. Passing a construct between stacks in the same environment creates an Export / Fn::ImportValue pair. Removing that reference later fails with Export <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 the exportValue and deploy again.
  • Weak references (ReferenceStrength.WEAK via CrossStackReferences.of(x).produce(…), or .consume(…) on the consuming scope) use Fn::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. BOTH is the transitional strength for migrating.
  • Stacks in other Regions need crossRegionReferences: true on the stack (strong, through custom resources) or a weak reference.
  1. 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 use NestedStack. The limit check reads context @aws-cdk/core:stackResourceLimit in this library version. The guide's maxResources stack property isn't in 2.273.0's types.
  2. Custom qualifier: if the environment was bootstrapped with --qualifier, pass synthesizer: new DefaultStackSynthesizer({ qualifier: "<q>" }) or set context @aws-cdk/core:bootstrapQualifier. Otherwise the deploy looks for /cdk-bootstrap/hnb659fds/version and fails (reference/cdk/errors.md).

Before deploying

  • cdk synth writes cdk.out/<Stack>.template.json. Check its resource types and any DeletionPolicy there.
  • cdk deploy --method prepare-change-set creates the change set without executing it. Review its Replacement values (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 deploy creates and executes a change set by default (guides/cdk/bootstrap-and-deploy.md).

Read this document as markdown — the same bytes an agent receives.