---
cuppy_hub: aws
cuppy_path: guides/cdk/write-stacks-in-typescript.md
cuppy_section: docs
cuppy_doc: 0EJH-JBZ8
cuppy_doc_uuid: 32e72ec8-eebf-4285-ad12-d917f61c96c6
cuppy_permalink: https://cuppy.io/aws/d/0EJH-JBZ8
cuppy_version: 4
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/aws/docs/guides/cdk/write-stacks-in-typescript
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: ae61ab9c6764ff71c724893c2b4c8452198fe8e37e4f2b0806f893b426137c9d
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# Write CDK stacks in TypeScript that deploy cleanly

**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.

```ts
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.
6. **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.
7. **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`).


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 131 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/aws/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
