AWS-CDK
Basics
Why CDK
The CDK is infrastructure as code via a TypeScript API and is much faster and less error-prone than creating AWS resources yourself manually.
Behind the scenes, the CDK compiles into CloudFormation terraform code that then declaratively spins up AWS resources behind the scenes.
CDK architecture

- constructs: the individual AWS resources or architecture deployments that serve a single purpose, like a lambda, DynamoDB table, S3 bucket, or a load balancer that combines ELB, EC2, and auto-scaling.
- stacks: a logical grouping of related constructs together so you can organize them and also have the constructs within the same stack easily interact with each other.
- app: a grouping of all the stacks you want to deploy. You can only have one app per CDK project.
Constructs
AWS CDK structures its API for creating cloud resources in three increasing levels of abstraction called constructs, going from L1 -> L2 -> L3.
- L1 constructs: also called CFN resources, these are the lowest level abstraction and represent individual building blocks that make up AWS services, and are pretty much one-to-one with CloudFormation configuration for resources.
- use case: when you want compete control over a resource's configuration and provide every single detail.
- con: too unnecessarily complicated.
- L2 constructs: also called curated constructs, these abstraction represent single AWS service components, like a lambda or a DynamoDB table or a single S3 bucket. This is the most common construct.
- pro: These resources come with sensible defaults, good security by default, a decent amount of configuration, and a good set of helper methods.
- use case: when you want to use a single instance of a service, like a DynamoDB table or create a lambda function.
- L3 constructs: Also called patterns, these constructs represent high-level architectures combining multiple AWS services, like a load balancer with an auto-scaling group or a lambda connecting to DynamoDB and S3 buckets all at once.
- pro: offer a simple abstraction over big architectures like a load balancer.
- con: offer less configuration for the individual L2 constructs that make up the L3 construct
Stacks
A logical grouping of related constructs together so you can organize them and also have the constructs within the same stack easily interact with each other.
For example, an event listener stack would consist of multiple constructs related to events, logging, and notifications, such as:
- notification lambdas that send emails with SES
- eventbridge rules
- Cloudwatch alarms that send SNS topics
- DynamoDB streams
Apps
An app is a grouping of all the stacks you want to deploy.
You can have multipel stacks per app, but you can only deploy one single app from your CDK project.
CDK code
Basics
File structure
my-cdk-app/
├── bin/
│ └── my-cdk-app.ts # Entry point — instantiates the App and Stacks
├── lib/
│ └── my-cdk-app-stack.ts # Your stack definition(s) live here
├── test/
│ └── my-cdk-app.test.ts # Jest tests
├── cdk.json # CDK config: how to run the app, feature flags
├── cdk.out/ # Synthesized output (git-ignored)
├── package.json
├── tsconfig.json
└── jest.config.js
cdk.json — the config file
The most important line is app, which tells the CLI how to run your program:
{
"app": "npx ts-node --prefer-ts-exts bin/my-cdk-app.ts",
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true
}
}
The context block contains feature flags — versioned behavior toggles CDK uses so upgrading the library doesn't silently change how your existing infrastructure synthesizes.
WARNING
Don't hand-edit the feature flags in the "context" block unless you know what you're doing; cdk init sets them to sensible values for your CDK version.
Because the app is run via ts-node, you never need a manual compile step for deployment — the CLI transpiles on the fly. (You still compile for tests and type-checking.)
Constructs
A construct is the basic building block. It represents one or more AWS resources — or a logical grouping of them. Everything in CDK is a construct: a single S3 bucket is a construct, a whole VPC is a construct, and your entire application is a construct.
Constructs form a tree. Each construct is created with three arguments, which is a pattern you'll type thousands of times:
new SomeConstruct(scope, id, props);
scope— the parent construct. This is what places the new construct into the tree. Usually you passthis.id— a string that must be unique among siblings under the same scope. CDK uses it to generate stable logical IDs for CloudFormation.props— a configuration object specific to that construct.
NOTE
You will deal with the scope parameter a lot, but it's exactly what is sounds like: each construct (L1, L2, L3) needs to be "scoped" into a parent construct so it knows where it belongs.
- L2 constructs like lambdas, DynamoDB tables, etc. should be scoped into an L3 construct like a stack or a load balancer.
- L3 constructs like load balancers should be scoped into a stack, and stacks should be scoped into an app.
NOTE
The parent construct must always be at the same level or greater.
- An L1 construct cannot be the parent of an L2 construct.
- An L3 construct can be the parent of L1 and L2 constructs, or even other L3 constructs.
Here are the different important types of L3 constructs:
- multi-service systems: stuff like load balancers which combine many services together.
- stacks: logical collections of multiple cloud resources and services.
- app: the culmination of all stacks you want to deploy.
Obviously, these L3 constructs are of different sizes, and follow a nesting order:
L3 constructs -> stacks -> 1 app
Stack and app
A stack extends from the cdk.Stack type and is considered an L3 construct, inheriting from the Construct class.
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
// import * as sqs from 'aws-cdk-lib/aws-sqs';
export class CdkLearningStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// The code that defines your stack goes here
// example resource
// const queue = new sqs.Queue(this, 'CdkLearningQueue', {
// visibilityTimeout: cdk.Duration.seconds(300)
// });
}
}
When instantiating a stack, its constructor takes in three arguments:
scope: AConstructtype which can be any L1, L2, L3 construct that should define the parent construct.- In the context of deciding the
scopefor a stack, a stack is a L3 construct, and a stack belongs to an app, so we should pass in the entire CDK app as the parent L3 construct for the stack.
- In the context of deciding the
id: a unique string identifier to uniquely identify this stack in cloudformation.props: a list of configuration, like the AWS account ID to run this IaC code in, the specific region to spin up all this services in, etc.
Here's a complete example, where we create a S3 bucket as an L2 construct scoped to a stack, then we instantiate the stack by scoping it to the app.
import { Bucket } from "aws-cdk-lib/aws-s3";
import * as cdk from "aws-cdk-lib/core";
import { Construct } from "constructs";
export class CdkLearningStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// example resource: create bucket with ID Level2Bucket and dynamic name
const s3Bucket = new Bucket(this, "Level2Bucket", {
versioned: true,
});
}
}
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { CdkLearningStack } from '../lib/cdk-learning-stack';
const app = new cdk.App();
new CdkLearningStack(app, 'CdkLearningStack', {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */
/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },
/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
#!/usr/bin/env node
import { App } from 'aws-cdk-lib';
import { MyCdkAppStack } from '../lib/my-cdk-app-stack';
const app = new App();
new MyCdkAppStack(app, 'MyCdkAppStack', {
// env determines which account/region the stack deploys to.
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
Now when we run cdk deploy, a new bucket with an ID "Level2Bucket" gets created, but it has a unique bucket name that cloudformation creates at runtime.
NOTE
CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION are set automatically by the CLI from your current credentials.
Stacks
A stack is the unit of deployment. It maps one-to-one to a CloudFormation stack. Everything you want deployed together lives inside a stack. When you cdk deploy, you're deploying stacks.
A stack is itself a construct (it extends Stack, which extends Construct). You define resources by creating constructs inside a stack, passing the stack as their scope.
Apps
An app is the root of the tree. It's a container for one or more stacks. When CDK runs, it executes your app, walks the construct tree, and synthesizes each stack into a template.
Here's how they nest:
App
├── Stack (e.g. "NetworkStack")
│ ├── Vpc construct
│ └── SecurityGroup construct
└── Stack (e.g. "AppStack")
├── Function (Lambda) construct
└── Bucket (S3) construct
The smallest complete example
import { App, Stack, StackProps } from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
class MyFirstStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// A construct created inside the stack.
// `this` is the scope — the bucket becomes a child of the stack.
new Bucket(this, 'MyBucket', {
versioned: true,
});
}
}
const app = new App();
new MyFirstStack(app, 'MyFirstStack');
app.synth();
IDs and retention policies
CDK derives CloudFormation logical IDs from the construct's path in the tree (its chain of ids) plus a hash. Logical IDs are how CloudFormation tracks resources across deployments. This has a consequence that trips up beginners:
If you rename a construct's id, or move it to a different scope, CloudFormation sees it as a delete-and-recreate, not a rename. For a stateful resource like a database or bucket, that means data loss. Choose IDs thoughtfully and be very careful about renaming or restructuring stateful resources after they're in production.
The ID of a cloudformation resource/construct is static and must be unique as to uniquely identify the construct so the CDK can intelligently read diffs between a resource's previous state and a current state, uniquely targeted by the ID.
Let's say you change an important property of the bucket like the bucket name:
import { Bucket } from "aws-cdk-lib/aws-s3";
import * as cdk from "aws-cdk-lib/core";
import { Construct } from "constructs";
export const constants = {
APP_NAME: "cdk-learning-stack",
userPrefix: "2022amallick",
createResourceName: (resourceName: string) =>
`${constants.userPrefix}-${constants.APP_NAME}-${resourceName}`,
};
export class CdkLearningStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// example resource: create bucket with ID Level2Bucket and static name
const s3Bucket = new Bucket(this, "Level2Bucket", {
versioned: true,
bucketName: constants.createResourceName("level2bucket"),
// NOT recommended for production code
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
}
}
For non-conflicting properties like enabling/disabling bucket encryption or bucket versioning, any new resource doesn't have to be created, but for static properties that can't be changed once the resource has been created, like the bucket name, Cloudformation has two policies for how to deal with a property change to this resource:
- UpdateReplacePolicy: The policy that defines what happens to constructs that have their properties updated.
- If
Retain, then it keeps the old version of the construct (no tear down) and then provisions the new one as well. - if
Delete, then upon a property change to a construct, it deletes the old version and then provisions the version.
- If
- DeletionPolicy: The policy that defines whether or not to delete the specific construct scoped to a stack when the user runs
cdk destroy [STACK]to destroy a specific stack. You have granular control over this by setting this policy for each construct scoped in a stack being destroyed, choosing whether or not they get deprovisioned.- If
Retain, it just deletes the stack, but not the provisioned resource - If
Delete, it deletes the stack and the provisioned cloud resource of the construct scoped in the stack.
- If
You can change the UpdateReplacePolicy and DeletionPolicy for each construct in code with the special removalPolicy property:
// example resource: create bucket with ID Level2Bucket and static name
const s3Bucket = new Bucket(this, "Level2Bucket", {
versioned: true,
bucketName: "my-unique-bucket-name",
// NOT recommended for production code
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
Here are the different values you can set for the removal policy:
"DESTROY": enum value undercdk.RemovalPolicy.DESTROY, which sets theDeletevalue for both theUpdateReplacePolicyandDeletionPolicy."RETAIN": enum value undercdk.RemovalPolicy.RETAIN, which sets theRetainvalue for both theUpdateReplacePolicyandDeletionPolicy.
Stack environment
Every stack can specify an environment — the target account and region. This is set via the env prop:
new MyStack(app, 'MyStack', {
env: { account: '111111111111', region: 'us-east-1' },
});
Environment-agnostic vs. environment-specific. If you omit env, the stack is "environment-agnostic" — it can deploy to whatever account/region your credentials point at, but CDK can't do environment lookups (like finding a default VPC) because it doesn't know the target at synth time. For anything that needs lookups or for production, always specify env explicitly. The common pattern:
new MyStack(app, 'MyStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
}
});
CDK_DEFAULT_ACCOUNT/CDK_DEFAULT_REGION come from your active credentials, so the stack targets wherever you're authenticated — while still being a concrete environment CDK can reason about.
Constructs
L1 constructs (Cfn — "Cloudformation" resources)
L1 constructs are auto-generated, one-to-one mappings of raw CloudFormation resources. Their names always start with Cfn. They expose exactly the properties CloudFormation exposes, with the same names, and provide no defaults or convenience. You have to specify everything.
typescript
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
new CfnBucket(this, 'RawBucket', {
bucketName: 'my-explicit-bucket-name',
versioningConfiguration: { status: 'Enabled' },
});
You rarely start here, but L1 is your escape valve: if a brand-new AWS feature isn't yet supported by a higher-level construct, the L1 version almost always has it, because L1 constructs are generated directly from the CloudFormation spec.
L2 constructs (the sweet spot)
L2 constructs are hand-written, curated abstractions over L1. They provide sensible defaults, helper methods, and an intuitive API. This is where you should spend most of your time. Compare the S3 bucket:
import { Bucket, BucketEncryption } from 'aws-cdk-lib/aws-s3';
const bucket = new Bucket(this, 'MyBucket', {
versioned: true,
encryption: BucketEncryption.S3_MANAGED,
});
// L2 constructs give you convenience methods you'd never get from L1:
bucket.grantRead(someRole); // wires up the exact IAM policy
bucket.addLifecycleRule({ expiration: Duration.days(90) });
The grant* methods are a huge part of L2's value. Instead of hand-writing IAM policy JSON, you say "this function can read this bucket" and CDK computes the least-privilege policy and attaches it to the right role.
L3 constructs (patterns)
L3 constructs — often called patterns — compose multiple resources into an opinionated, ready-to-use architecture. AWS ships a set in aws-cdk-lib/aws-*-patterns. For example, a load-balanced Fargate service:
import { ApplicationLoadBalancedFargateService }
from 'aws-cdk-lib/aws-ecs-patterns';
new ApplicationLoadBalancedFargateService(this, 'Service', {
cluster,
taskImageOptions: {
image: ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
},
publicLoadBalancer: true,
});
That single construct creates an ECS cluster service, a task definition, an Application Load Balancer, target groups, listeners, security groups, and all the IAM wiring — dozens of underlying resources. L3 constructs are fantastic for getting started fast, but they make architectural decisions for you; when you outgrow those decisions, you compose your own from L2s.
Custom constructs
The power of CDK is composition: wrapping resources into your own constructs with a clean, typed interface. This is how teams build internal platforms.
A custom construct extends Construct and takes a typed props interface. Here's a reusable "secure bucket" that bakes in your organization's standards:
import { Construct } from 'constructs';
import { Bucket, BucketEncryption, BlockPublicAccess, IBucket } from 'aws-cdk-lib/aws-s3';
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
// Define the inputs your construct accepts, with types and docs.
export interface SecureBucketProps {
/** Days after which to expire objects. Defaults to no expiration. */
readonly expirationDays?: number;
/** Whether to enable versioning. Defaults to true. */
readonly versioned?: boolean;
}
export class SecureBucket extends Construct {
// Expose the inner bucket so consumers can grant access, add notifications, etc.
public readonly bucket: IBucket;
constructor(scope: Construct, id: string, props: SecureBucketProps = {}) {
super(scope, id);
this.bucket = new Bucket(this, 'Bucket', {
encryption: BucketEncryption.S3_MANAGED,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
versioned: props.versioned ?? true,
removalPolicy: RemovalPolicy.RETAIN,
lifecycleRules: props.expirationDays
? [{ expiration: Duration.days(props.expirationDays) }]
: undefined,
});
}
}
Then you can use the bucket like so:
const logs = new SecureBucket(this, 'Logs', { expirationDays: 30 });
logs.bucket.grantWrite(someService);
Here are the conventions you should follow when creating custom constructs:
- Mark props
readonly. Props objects should be immutable; the compiler helps enforce it. - Provide defaults with
??.props.versioned ?? truegives a default while allowing an explicitfalse. - Expose inner resources as public readonly fields (like
this.bucket) so consumers can wire up grants and integrations. Prefer exposing the interface type (IBucket) rather than the concreteBucketwhen you can — it keeps your API flexible. - Document props with JSDoc
/** */comments. These show up in consumers' IDE tooltips, which is a big usability win.
Escape hatches
Sometimes an L2 construct doesn't expose a property you need, or CDK's abstraction is behind a new AWS feature. Escape hatches let you reach the underlying L1 resource and manipulate the raw CloudFormation. You're never blocked.
NOTE
Escape hatches are legitimate and sometimes necessary, but they bypass the type safety and validation that make CDK pleasant. Prefer them as a targeted last resort. When you find yourself using one, it's often worth a comment explaining why the L2 was insufficient, so a future reader (or a future CDK version that adds the property) knows the override can be removed.
Reaching the L1 from an L2
Every L2 construct wraps an L1. Access it via node.defaultChild and cast to the L1 type:
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
const bucket = new Bucket(this, 'MyBucket');
// Get the underlying L1 CfnBucket.
const cfnBucket = bucket.node.defaultChild as CfnBucket;
// Set any raw CloudFormation property, even ones the L2 doesn't surface.
cfnBucket.accelerateConfiguration = { accelerationStatus: 'Enabled' };
addPropertyOverride for arbitrary properties
When even the L1's typed properties don't cover something (rare, but happens with brand-new features), override the raw template path:
cfnBucket.addPropertyOverride(
'ObjectLockConfiguration.ObjectLockEnabled',
'Enabled',
);
You can also override the removal policy, add metadata, or set deletion policies at the raw level:
cfnBucket.addOverride('DeletionPolicy', 'Retain');
IAM and permissions
IAM is where CDK's abstraction shines brightest, because hand-writing least-privilege policies is tedious and error-prone.
The grant* pattern
Most L2 constructs that represent a resource offer grant* methods, and most that represent an identity (Lambda functions, ECS tasks, roles) can be passed to those methods as the grantee. CDK computes the minimal policy and attaches it to the right role.
table.grantReadData(handler); // read-only on the table
table.grantWriteData(handler); // write-only
table.grantReadWriteData(handler); // both
bucket.grantRead(handler); // s3:GetObject etc. on this bucket
bucket.grantPut(handler); // s3:PutObject
queue.grantConsumeMessages(handler); // SQS receive/delete
topic.grantPublish(handler); // SNS publish
secret.grantRead(handler); // read a specific Secrets Manager secret
Custom policies
When no grant* method fits, add a policy statement directly:
import { PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam';
handler.addToRolePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['ses:SendEmail'],
resources: ['*'],
conditions: {
StringEquals: { 'ses:FromAddress': 'noreply@example.com' },
},
}),
);
Creating roles
Sometimes you need a role you control directly — for cross-account access, service roles, or when multiple resources share a role:
import { Role, ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';
const taskRole = new Role(this, 'TaskRole', {
assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMReadOnlyAccess'),
],
});
bucket.grantReadWrite(taskRole); // grant* works on roles too
The principle: prefer grant* methods, reach for addToRolePolicy for one-off custom actions, and define Role objects explicitly only when you genuinely need control over the role itself.
Assets
An asset is a local file, directory, or Docker image that CDK bundles, uploads to the bootstrap S3 bucket or ECR repo, and references from your CloudFormation template. Assets are how your application code gets from your laptop into AWS.
Lambda code assets
The simplest form points a Lambda at a directory of pre-built code:
import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda';
new Function(this, 'Handler', {
runtime: Runtime.NODEJS_20_X,
handler: 'index.handler',
code: Code.fromAsset('lambda-dist'), // a folder with your built JS
});
But as shown earlier, NodejsFunction is usually better for TypeScript because it runs esbuild for you — no separate build step, tree-shaking, and TypeScript support out of the box. You can customize bundling:
new NodejsFunction(this, 'Handler', {
entry: 'src/handler.ts',
bundling: {
minify: true,
sourceMap: true,
externalModules: ['@aws-sdk/*'], // provided by the Lambda runtime; don't bundle
},
});
Excluding the AWS SDK v3 packages via externalModules keeps your bundle small since they're already present in the Node.js runtime.
Docker image assets
For containerized Lambdas or ECS tasks, CDK can build a Docker image from a local Dockerfile, push it to ECR, and reference it:
import { DockerImageFunction, DockerImageCode } from 'aws-cdk-lib/aws-lambda';
new DockerImageFunction(this, 'ContainerFn', {
code: DockerImageCode.fromImageAsset('./image'), // dir with a Dockerfile
});
This requires Docker running locally at synth/deploy time. CDK invokes docker build, tags the result, and pushes it during deploy.
File/directory assets
You can also ship arbitrary files — for example, seeding config into S3:
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
new BucketDeployment(this, 'DeployWebsite', {
sources: [Source.asset('./website-build')],
destinationBucket: siteBucket,
});
BucketDeployment zips a local folder, uploads it, and (via a helper Lambda it creates) extracts the contents into your bucket — handy for static sites.
Contexts
Context and lookups
Context is key-value data available at synth time. Its most important use is environment lookups — querying your AWS account for existing resources. For example, importing an existing VPC:
import { Vpc } from 'aws-cdk-lib/aws-ec2';
const vpc = Vpc.fromLookup(this, 'ExistingVpc', {
vpcId: 'vpc-12345678',
});
.fromLookup calls AWS at synth time to fetch the VPC's details (subnets, AZs), then caches the result in cdk.context.json. Caching makes synth deterministic and fast, and lets CI synthesize without AWS access.
Commit cdk.context.json to version control. If the underlying resource changes, run cdk context --clear (or delete the relevant entry) to refresh.
Passing your own context
You can pass values on the command line and read them in code:
cdk deploy -c stage=prod
Then you can access the context you just created with the construct.node.tryGetContext(key) syntax:
const stage = this.node.tryGetContext('stage') ?? 'dev';
This is one way to parameterize deployments, though for anything beyond a simple flag, plain TypeScript configuration objects are usually cleaner and more type-safe.
Multi-account, multi-stage pattern
A robust pattern is to define your stages as plain data and instantiate stacks per stage:
interface StageConfig {
account: string;
region: string;
instanceCount: number;
}
const stages: Record<string, StageConfig> = {
dev: { account: '111111111111', region: 'us-east-1', instanceCount: 1 },
prod: { account: '222222222222', region: 'us-east-1', instanceCount: 3 },
};
for (const [name, cfg] of Object.entries(stages)) {
new AppStack(app, `App-${name}`, {
env: { account: cfg.account, region: cfg.region },
instanceCount: cfg.instanceCount,
});
}
Because it's just TypeScript, you get loops, type checking, and IDE support — no templating language required. This is a genuine advantage of CDK over static config formats.
Sharing resources across stacks
Real applications span multiple stacks (network, data, compute...). You share resources between them by passing construct references directly.
Passing constructs into stack props
Within a single app, pass a resource from one stack to another as a constructor argument for the props param.
// bin/app.ts
const network = new NetworkStack(app, 'Network', { env });
const app_ = new ApplicationStack(app, 'Application', {
env,
vpc: network.vpc, // pass the VPC across
});
Then you can add type safety for the props a stack is supposed to accept.
export interface ApplicationStackProps extends StackProps {
readonly vpc: IVpc;
}
export class ApplicationStack extends Stack {
constructor(scope: Construct, id: string, props: ApplicationStackProps) {
super(scope, id, props);
// use props.vpc here
}
}
NOTE
When you reference a resource from another stack this way, CDK automatically creates a CloudFormation Export in the producing stack and an Import in the consuming one, and it manages deployment ordering so the producer deploys first. You don't write the exports by hand.
the tight-coupling caveat
Cross-stack references create a dependency. Once Stack B imports an exported value from Stack A, you cannot remove or change that export in Stack A while B still uses it — CloudFormation blocks it to prevent breaking B. This can make certain refactors awkward.
There are two mitigations that work by intelligently deciding how to structure the resources in stacks for sharing.
- Put shared resources in their own stack: keep genuinely shared, stable resources (VPCs, shared data stores) in dedicated foundational stacks that change rarely, and then use that stack as the sharer of resources so only one stack is tightly coupled with the rest rather than all stacks being tightly coupled.
- Share configuration, not resources: for looser coupling, share via well-known identifiers (SSM Parameter Store) instead of direct exports.
Sharing via SSM Parameter Store
The producer writes a parameter; the consumer reads it. This decouples the stacks — they no longer have a hard CloudFormation dependency.
typescript
// Producer stack
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
new StringParameter(this, 'TableNameParam', {
parameterName: '/myapp/items-table-name',
stringValue: table.tableName,
});
// Consumer stack
const tableName = StringParameter.valueForStringParameter(
this, '/myapp/items-table-name',
);
The trade-off: you lose CDK's automatic dependency ordering, so you're responsible for deploying the producer before the consumer.
Testing
Because CDK is code, you can unit-test your infrastructure. CDK ships an assertions library (aws-cdk-lib/assertions) that lets you make claims about the synthesized template without deploying anything.
When testing, here are the basic steps to see if your cloud resources got provisioned correctly:
- Create the app
- Create the specific stack you want to test
- Create the cloudformation template in code from the stack
- You can test if specific cloudformation properties exist on the created in-memory template to see if your infra was provisioned correctly.
NOTE
Test the things that matter and could plausibly regress: that security-critical properties are set (encryption, public-access blocks), that IAM grants produce the expected policies, that your custom constructs behave correctly given various props, and that resource counts are what you expect. Don't try to assert every auto-generated property — that's brittle and low-value.
Fine-grained assertions
You synthesize the stack into a Template, then assert that it contains resources with expected properties:
import { App } from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { ServerlessApiStack } from '../lib/serverless-api-stack';
test('creates an encrypted DynamoDB table', () => {
const app = new App();
const stack = new ServerlessApiStack(app, 'TestStack');
const template = Template.fromStack(stack);
// Assert exactly one table exists with the expected key schema.
template.hasResourceProperties('AWS::DynamoDB::Table', {
KeySchema: [{ AttributeName: 'id', KeyType: 'HASH' }],
});
// Assert the Lambda has the table name in its environment.
template.hasResourceProperties('AWS::Lambda::Function', {
Environment: {
Variables: Match.objectLike({ TABLE_NAME: Match.anyValue() }),
},
});
// Count resources.
template.resourceCountIs('AWS::DynamoDB::Table', 1);
});
Match provides flexible matchers: objectLike (partial match), arrayWith, anyValue, absent, and more. Use these because CDK injects a lot of auto-generated properties you don't want to assert on exactly.
Snapshot tests
A snapshot test serializes the whole template and compares it against a stored version, flagging any change. It's a blunt but effective regression guard:
test('template matches snapshot', () => {
const app = new App();
const stack = new ServerlessApiStack(app, 'TestStack');
const template = Template.fromStack(stack);
expect(template.toJSON()).toMatchSnapshot();
});
The first run records the snapshot; later runs fail if the synthesized template changes. When a change is intentional, update snapshots with jest -u. Snapshots are great for catching unintended drift, but on their own they don't tell you whether a change is good — pair them with fine-grained assertions for the properties you care about.
CDK pipelines
For anything beyond solo experimentation, you want automated, repeatable deployments. CDK offers CDK Pipelines, a construct library that builds a self-updating CI/CD pipeline (on AWS CodePipeline) in CDK itself.
The defining feature: the pipeline is defined in your CDK app, and the pipeline can update itself.
When you push a change that modifies the pipeline (adding a stage, changing a step), the pipeline detects it and reconfigures before deploying your application changes. You bootstrap it once manually, then it maintains itself from source control.
Basic pipeline
import { Stack, StackProps, Stage, StageProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
CodePipeline,
CodePipelineSource,
ShellStep,
} from 'aws-cdk-lib/pipelines';
// A Stage groups the stacks that make up one deployable environment.
class MyAppStage extends Stage {
constructor(scope: Construct, id: string, props?: StageProps) {
super(scope, id, props);
new ServerlessApiStack(this, 'Api');
}
}
export class PipelineStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const pipeline = new CodePipeline(this, 'Pipeline', {
synth: new ShellStep('Synth', {
input: CodePipelineSource.gitHub('my-org/my-repo', 'main'),
commands: ['npm ci', 'npm run build', 'npx cdk synth'],
}),
});
// Add a deployment stage. Add more for staging/prod, with approvals.
pipeline.addStage(new MyAppStage(this, 'Prod', {
env: { account: '222222222222', region: 'us-east-1' },
}));
}
}
Best practices
Organize by lifecycle and blast radius, not by service type. Put resources that change together and share a lifecycle in the same stack. Stable, foundational resources (VPCs, shared databases) belong in their own stacks that rarely change, isolating them from the churn of application code.
Keep stateful and stateless resources separate. Databases, buckets, and tables have data you can't afford to lose. Isolating them from frequently-redeployed compute reduces the chance a bad deploy or refactor endangers them. Set explicit removalPolicy: RETAIN on anything stateful.
Prefer L2 constructs; use grant* methods for IAM. Let CDK compute least-privilege policies. Hand-writing IAM JSON is where security mistakes creep in.
Don't hardcode account IDs, ARNs, or names when you can reference constructs. Use table.tableName, bucket.bucketArn, and pass construct references between stacks. Hardcoded values break across environments and defeat CDK's dependency tracking.
Use generated names where possible. Letting CDK auto-generate physical resource names (rather than setting bucketName, tableName, etc.) avoids name-collision errors and makes resources replaceable. Set explicit names only when something external depends on a stable, known name.
Commit cdk.context.json. It caches environment lookups so synthesis is deterministic and CI doesn't need AWS access just to synth. Refresh it deliberately with cdk context --clear.
Run cdk diff before every production deploy and read the security-impact section. It's your last line of defense against an unintended IAM or network change.
Write tests for security-critical properties and custom constructs. You don't need exhaustive coverage, but assertions on encryption, public-access settings, and IAM grants catch real regressions cheaply.
Pin your aws-cdk-lib and CLI versions and upgrade intentionally. Feature flags in cdk.json mean upgrades can subtly change synthesis; upgrade, run cdk diff, and review before deploying.
Tag everything via Tags.of(app).add(...) for cost allocation and governance. It's one line and pays off in billing and compliance.
Keep constructs small and composable. A construct that does one thing well, with a clean typed props interface, is easy to test, reuse, and reason about. Thin stacks that wire together focused constructs age better than monolithic ones.
Troubleshooting
"Environment not bootstrapped" on deploy. You skipped cdk bootstrap for that account/region, or the bootstrap version is too old. Run cdk bootstrap again.
Renaming a construct destroyed my resource. Logical IDs derive from the construct path. Changing an id or moving a construct changes the logical ID, and CloudFormation treats that as replace. For stateful resources, avoid this; if you must restructure, plan a migration or use removalPolicy: RETAIN plus a re-import.
Circular dependency between stacks. Two stacks each reference something in the other. CDK can't order the deployment. Break the cycle: move the shared resource to a third foundational stack, or decouple via SSM Parameter Store.
"Cannot delete export ... in use by stack ..." You tried to change or remove a cross-stack export that another stack still imports. Deploy a version where the consumer no longer uses it first, or restructure to avoid the tight export/import coupling.
Lookups return stale data. fromLookup results are cached in cdk.context.json. If the real resource changed, clear the cache: cdk context --clear (or remove the specific key), then re-synth.
Token values printing as ${Token[...]}. Many CDK values (ARNs, names of not-yet-created resources) are tokens — placeholders resolved at deploy time, not synth time. You can't console.log them meaningfully or do string surgery on them as if they were real strings. Use CDK's helpers (Fn.join, template literals that CDK understands, or the resource's typed attribute) instead of manual string manipulation.
Docker not running. Constructs that build image assets (NodejsFunction with certain bundling options, DockerImageFunction) invoke Docker at synth/deploy time. If Docker isn't running you'll get a build error. Start Docker, or use bundling modes that don't require it.
Deploy hangs or rolls back. Read the CloudFormation events (in the console, or cdk deploy streams them). CloudFormation errors are usually specific — a missing permission, a name collision, a quota limit. The CDK error is often just the surface; the real cause is in the stack events.
CDK Examples
S3 with SQS
Here is a stack that achieves the following:
- Creates an S3 bucket
- Creates an SQS queue
- Sets up an automatic trigger for whenever an object is added to the S3 bucket, it pushes an S3-based
OBJECT_CREATEDmessage to the SQS queue.
import { Bucket, EventType } from "aws-cdk-lib/aws-s3";
import * as cdk from "aws-cdk-lib/core";
import { Construct } from "constructs";
import { constants } from "./utils/constants";
import { Queue } from "aws-cdk-lib/aws-sqs";
import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
export class CdkLearningStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// 1. create bucket
const s3Bucket = new Bucket(this, "Level2Bucket", {
versioned: true,
bucketName: constants.createResourceName("level2bucket"),
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// 2. create queue
const queue = new Queue(this, "Level2Queue", {
queueName: constants.createResourceName("level2queue"),
visibilityTimeout: cdk.Duration.seconds(30),
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// 3. create trigger on bucket to add message to queue
s3Bucket.addEventNotification(
EventType.OBJECT_CREATED,
new SqsDestination(queue),
);
}
}
DynamoDB + Lambda + API gateway
Creating the table
This is how you create a DynamoDB table on the fly using AWS CDK.
Things you should specify:
- partition key: the partition key for the table
import {
Table,
AttributeType,
BillingMode,
TableEncryption,
} from 'aws-cdk-lib/aws-dynamodb';
import { RemovalPolicy } from 'aws-cdk-lib';
export const table = new Table(this, 'ItemsTable', {
partitionKey: { name: 'id', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST, // on-demand; no capacity planning
encryption: TableEncryption.AWS_MANAGED,
pointInTimeRecovery: true,
// In production, keep RETAIN so a stack teardown never deletes your data.
// For a throwaway demo, DESTROY makes cleanup easier — choose deliberately.
removalPolicy: RemovalPolicy.DESTROY,
});
Creating the lambda with API gateway
This is how you create a lambda function construct: Create a handler object and specify these core properties for the props parameter:
runtime: the code runtime value to use. If you want to create a nodeJS function, then use one of these values:Runtime.NODEJS_20_X: runs the lambda code in a node 20 environment.Runtime.NODEJS_22_X: runs the lambda code in a node 22 environment.Runtime.NODEJS_24_X: runs the lambda code in a node 24 environment.
entry: the absolute path to the code containing your exported lambda function.handler: the function name of the exported function in the entry file.memorySize: the amount of kilobytes to provide in memory for the lambda function compute.timeout: the timeout amount to set for the lambda.environment: any environment variables to pass into the lambda function code which can be accessed viaprocess.envin the bundled handler.
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { Duration } from 'aws-cdk-lib';
import * as path from 'path';
const handler = new NodejsFunction(this, 'ItemsHandler', {
runtime: Runtime.NODEJS_20_X,
entry: path.join(__dirname, '../src/handler.ts'), // your TS source
handler: 'handler', // exported function name
memorySize: 256,
timeout: Duration.seconds(10),
environment: {
// Pass the table name to the function at runtime.
TABLE_NAME: table.tableName,
},
});
// Grant the function least-privilege access to the table.
// CDK writes the exact IAM policy for you.
table.grantReadWriteData(handler);
NOTE
That table.grantReadWriteData(handler) line is the payoff of L2 constructs. It creates an IAM policy allowing precisely the DynamoDB actions needed to read and write items on this specific table's ARN, and attaches it to the function's execution role. No hand-written JSON, no wildcard resources.
The corresponding handler code (src/handler.ts) might look like:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
} from '@aws-sdk/lib-dynamodb';
import type { APIGatewayProxyHandler } from 'aws-lambda';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.TABLE_NAME!;
export const handler: APIGatewayProxyHandler = async (event) => {
if (event.httpMethod === 'POST') {
const item = JSON.parse(event.body ?? '{}');
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: item }));
return { statusCode: 201, body: JSON.stringify(item) };
}
const id = event.pathParameters?.id;
const result = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { id } }),
);
return result.Item
? { statusCode: 200, body: JSON.stringify(result.Item) }
: { statusCode: 404, body: 'Not found' };
};
Then you can create the API gateway like so, where the LambdaRestAPI gateway is an L3 construct that creates an API gateway that automatically uses the lambda function as a route handler for all resource and HTTP method combinations you specify.
import { LambdaRestApi } from 'aws-cdk-lib/aws-apigateway';
const api = new LambdaRestApi(this, 'ItemsApi', {
handler,
proxy: false, // we'll define routes explicitly
});
const items = api.root.addResource('items');
items.addMethod('POST'); // POST /items
const singleItem = items.addResource('{id}');
singleItem.addMethod('GET'); // GET /items/{id}
NOTE
LambdaRestApi is a small L3-ish convenience over API Gateway that wires a Lambda as the backend. Setting proxy: false lets us define specific routes instead of forwarding everything.
Reading outputs
You often want to know the deployed API URL. CfnOutput prints values to the terminal after deploy and exposes them for scripts.
import { CfnOutput } from 'aws-cdk-lib';
new CfnOutput(this, 'ApiUrl', {
value: api.url,
description: 'Base URL of the Items API',
});
After cdk deploy, you'll see MyStack.ApiUrl = https://xxxx.execute-api.us-east-1.amazonaws.com/prod/.
All together
import { Stack, StackProps, Duration, RemovalPolicy, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { LambdaRestApi } from 'aws-cdk-lib/aws-apigateway';
import * as path from 'path';
export class ServerlessApiStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// 1. create a table
const table = new Table(this, 'ItemsTable', {
partitionKey: { name: 'id', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
removalPolicy: RemovalPolicy.RETAIN,
});
// 2. create the lambda function
const handler = new NodejsFunction(this, 'ItemsHandler', {
runtime: Runtime.NODEJS_20_X,
entry: path.join(__dirname, '../src/handler.ts'),
handler: 'handler',
timeout: Duration.seconds(10),
environment: { TABLE_NAME: table.tableName },
});
// 3. grant lambda function read/write perms to the DynamoDB table
table.grantReadWriteData(handler);
// 4. create the REST API gateway that send requests for resources to the lambda handler we defined.
const api = new LambdaRestApi(this, 'ItemsApi', { handler, proxy: false });
const items = api.root.addResource('items');
items.addMethod('POST');
items.addResource('{id}').addMethod('GET');
// print out the the REST API URL of the API gateway.
new CfnOutput(this, 'ApiUrl', { value: api.url });
}
}
CDK code reference
VPCs
The Vpc L2 construct is a great example of how much an L2 does for you. This one line creates subnets across availability zones, route tables, an internet gateway, and NAT gateways:
import { Vpc, IpAddresses, SubnetType } from 'aws-cdk-lib/aws-ec2';
const vpc = new Vpc(this, 'AppVpc', {
ipAddresses: IpAddresses.cidr('10.0.0.0/16'),
maxAzs: 2,
natGateways: 1, // NAT gateways cost money — tune this deliberately
subnetConfiguration: [
{ name: 'public', subnetType: SubnetType.PUBLIC, cidrMask: 24 },
{ name: 'private', subnetType: SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
],
});
WARNING
Be aware NAT gateways have an hourly cost plus data processing charges. For dev environments, natGateways: 0 (using only public subnets or VPC endpoints) can save real money.
S3 buckets
Basics
import { Bucket, BucketEncryption, BlockPublicAccess } from 'aws-cdk-lib/aws-s3';
import { Duration } from 'aws-cdk-lib';
const bucket = new Bucket(this, 'AssetsBucket', {
encryption: BucketEncryption.S3_MANAGED,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL, // safe default
versioned: true,
enforceSSL: true, // deny non-HTTPS requests
lifecycleRules: [
{
// Move old versions to cheaper storage, then expire them.
noncurrentVersionExpiration: Duration.days(90),
transitions: [],
},
],
});
Lambda functions
CDK CLI
Installation and setup
First install the AWS CDK:
npm install -g aws-cdk
And now you can use the cdk like so:
cdk init app --language typescript: creates a new CDK app
Development and deployment
bootstrapping
Before you can deploy, you must bootstrap each AWS account-and-region combination you'll deploy into. This is a one-time (per environment) setup step that provisions supporting resources CDK needs: an S3 bucket for assets (Lambda code, Docker images), an ECR repository for container images, and IAM roles used during deployment.
The first step you always have to do in a new CDK project is to bootstrap it.
cdk bootstrap: Deploys the CDK toolkit stack into an AWS environment, by default reading the current AWS login session.
You can also bootstrap a specific account by passing in an AWS account URL:
cdk bootstrap aws://ACCOUNT-NUMBER/REGION
other commands
cdk ls: lists all the stacks in the appcdk synth: outputs the cloudformation template based off the current IaC CDK code.cdk diff [STACKS]: prints out the differences between the current stack state and the deployed stack state, for either all stacks or whatever stacks you specify.
deployment commands
These are commands that perform the actual deployment of IaC and provision the cloud resources.
cdk deploy: creates a CloudFormation template from your CDK code and then deploys that template to CloudFormation to provision all yoru resources.cdk deploy --watch: deploys and then watches for changes.cdk watch: does the exact same thing ascdk deploy --watch
Commands reference
cdk synth
Synthesizes your app into CloudFormation templates and writes them to cdk.out/. It also prints the template to the terminal. This is your fastest feedback loop — you can catch errors without touching AWS at all.
bash
cdk synth # all stacks
cdk synth MyStack # a specific stack
Run this constantly. If cdk synth succeeds, your code is at least structurally valid.
cdk diff
Compares your synthesized template against what's currently deployed and shows exactly what will change: resources added, modified, or destroyed, plus IAM and security group changes highlighted separately.
bash
cdk diff
cdk diff MyStack
IMPORTANT
Always run cdk diff before deploying to production. The security-impact section (changes to IAM policies, security group rules) is especially worth reading — it flags anything that broadens permissions.
cdk deploy
Deploys one or more stacks. CDK synthesizes, uploads assets, and executes a CloudFormation change set.
cdk deploy # deploy all stacks (prompts for confirmation)
cdk deploy MyStack # one stack
cdk deploy --all # all stacks explicitly
cdk deploy MyStack --require-approval never # skip the IAM confirmation prompt
cdk deploy '**' # all stacks including nested (glob)
By default, if a deployment includes changes to IAM or security groups, CDK pauses and asks you to confirm. --require-approval never disables that (used in CI). --hotswap can do faster, in-place updates for certain resources like Lambda code during development — never use it for production because it bypasses CloudFormation.
cdk destroy
Tears down a stack and its resources.
cdk destroy MyStack
Resources with a RETAIN removal policy (the default for many stateful resources) survive destruction — this protects data but means you may have orphaned resources to clean up manually.