AWS CDK with JavaScript
Build AWS infrastructure with CDK in JavaScript using L2 constructs for Lambda, API Gateway, DynamoDB, and CI/CD pipelines
AWS CDK with JavaScript
Overview
The AWS Cloud Development Kit (CDK) lets you define cloud infrastructure in real programming languages instead of YAML or JSON templates. With JavaScript, you get the full power of Node.js — loops, conditionals, functions, npm packages — to compose AWS resources in a way that CloudFormation templates never could. If you are building serverless applications on AWS and want infrastructure code that is readable, testable, and reusable, CDK with JavaScript is the most productive path I have found.
Prerequisites
- Node.js 18+ installed
- An AWS account with programmatic access configured (
aws configure) - Basic familiarity with AWS services (Lambda, API Gateway, S3, DynamoDB)
- AWS CLI installed and configured
- npm or yarn package manager
Install the CDK CLI globally:
npm install -g aws-cdk
Verify the installation:
cdk --version
CDK vs CloudFormation vs Terraform
Before diving in, it is worth understanding where CDK fits in the infrastructure-as-code landscape.
CloudFormation is the native AWS provisioning engine. You write JSON or YAML templates that describe resources. It works, but templates get verbose fast. A simple Lambda with an API Gateway can easily hit 200+ lines of YAML. You cannot use variables, loops, or conditionals natively — you are stuck with intrinsic functions like Fn::If and Fn::Sub that are painful to read and debug.
Terraform uses HCL (HashiCorp Configuration Language) and supports multiple cloud providers. It has a strong ecosystem and excellent state management. The downside is that HCL is still a declarative DSL — you cannot write arbitrary logic without resorting to workarounds. Multi-cloud support sounds good in theory, but in practice most teams are deeply committed to one provider.
CDK compiles down to CloudFormation templates. You write JavaScript (or TypeScript, Python, Java, Go, C#), and CDK synthesizes it into CloudFormation JSON. This means you get CloudFormation's reliability and rollback capabilities with the ergonomics of a real programming language. The L2 constructs handle 80% of the boilerplate — IAM policies, security groups, log groups — that you would normally write by hand.
My take: if you are all-in on AWS, CDK is the best choice. You get the safety of CloudFormation with dramatically less code and real programming constructs. If you genuinely need multi-cloud, Terraform is the way to go. Raw CloudFormation is almost never the right answer anymore.
Project Setup and Bootstrapping
Create a new CDK project:
mkdir my-cdk-app
cd my-cdk-app
cdk init app --language javascript
This generates a project structure:
my-cdk-app/
├── bin/
│ └── my-cdk-app.js # App entry point
├── lib/
│ └── my-cdk-app-stack.js # Stack definition
├── test/
│ └── my-cdk-app.test.js # Stack tests
├── cdk.json # CDK configuration
├── package.json
└── node_modules/
Before you can deploy anything, you need to bootstrap the AWS environment. Bootstrapping creates an S3 bucket and some IAM roles that CDK uses to deploy assets:
cdk bootstrap aws://123456789012/us-east-1
Replace the account ID and region with your own. You only need to do this once per account/region combination.
The cdk.json file configures the CDK toolkit:
{
"app": "node bin/my-cdk-app.js",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"jest.config.js",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:stackRelativeExports": true
}
}
Constructs: L1, L2, and L3
Constructs are the building blocks of CDK applications. They come in three levels.
L1 Constructs (CFN Resources)
L1 constructs are direct one-to-one mappings to CloudFormation resources. They are prefixed with Cfn and require you to specify every property manually. Use them only when L2 constructs do not exist or do not expose the property you need.
var cdk = require("aws-cdk-lib");
var s3 = require("aws-cdk-lib/aws-s3");
// L1 - verbose and low-level
var bucket = new s3.CfnBucket(this, "MyBucketL1", {
bucketName: "my-raw-bucket",
versioningConfiguration: {
status: "Enabled"
},
publicAccessBlockConfiguration: {
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true
}
});
L2 Constructs (Curated)
L2 constructs are the sweet spot. They provide sensible defaults, helper methods, and grant-based IAM permissions. Most of your CDK code should use L2 constructs.
var s3 = require("aws-cdk-lib/aws-s3");
// L2 - concise with smart defaults
var bucket = new s3.Bucket(this, "MyBucket", {
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true
});
Notice how much cleaner the L2 version is. It also automatically creates a Lambda function for autoDeleteObjects to clean up the bucket on stack deletion — something you would need to wire up manually with L1.
L3 Constructs (Patterns)
L3 constructs combine multiple resources into high-level patterns. AWS provides some, and you can build your own.
var apigateway = require("aws-cdk-lib/aws-apigateway");
// L3 - LambdaRestApi wires up API Gateway + Lambda + permissions
var api = new apigateway.LambdaRestApi(this, "MyApi", {
handler: myLambdaFunction,
proxy: false
});
LambdaRestApi creates the REST API, deploys it, creates a stage, and grants API Gateway permission to invoke the Lambda. That is four or five CloudFormation resources in a single construct.
Stacks and Apps
A CDK App is the root of your construct tree. It contains one or more Stacks, and each stack maps to a CloudFormation stack.
The app entry point in bin/my-cdk-app.js:
#!/usr/bin/env node
var cdk = require("aws-cdk-lib");
var ApiStack = require("../lib/api-stack");
var DatabaseStack = require("../lib/database-stack");
var StorageStack = require("../lib/storage-stack");
var app = new cdk.App();
var env = {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION
};
var storageStack = new StorageStack(app, "StorageStack", { env: env });
var databaseStack = new DatabaseStack(app, "DatabaseStack", { env: env });
var apiStack = new ApiStack(app, "ApiStack", {
env: env,
table: databaseStack.table,
bucket: storageStack.bucket
});
Splitting resources into multiple stacks is a good practice for large applications. It limits blast radius and allows independent deployment. You pass resources between stacks using construct properties.
Defining Lambda Functions
CDK makes Lambda definitions concise:
var cdk = require("aws-cdk-lib");
var lambda = require("aws-cdk-lib/aws-lambda");
var path = require("path");
var Construct = require("constructs").Construct;
function ApiStack(scope, id, props) {
cdk.Stack.call(this, scope, id, props);
var itemsHandler = new lambda.Function(this, "ItemsHandler", {
runtime: lambda.Runtime.NODEJS_20_X,
handler: "index.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/items")),
memorySize: 256,
timeout: cdk.Duration.seconds(30),
environment: {
TABLE_NAME: props.table.tableName,
BUCKET_NAME: props.bucket.bucketName,
NODE_OPTIONS: "--enable-source-maps"
},
tracing: lambda.Tracing.ACTIVE
});
}
ApiStack.prototype = Object.create(cdk.Stack.prototype);
ApiStack.prototype.constructor = ApiStack;
module.exports = ApiStack;
The code: lambda.Code.fromAsset() call tells CDK to bundle the directory contents and upload them as a deployment package. CDK handles zipping, uploading to the bootstrap bucket, and referencing the S3 key in the CloudFormation template.
API Gateway Integration
Wire up API Gateway with route definitions:
var apigateway = require("aws-cdk-lib/aws-apigateway");
// Create the REST API
var api = new apigateway.RestApi(this, "ItemsApi", {
restApiName: "Items Service",
description: "CRUD API for items",
deployOptions: {
stageName: "prod",
throttlingRateLimit: 100,
throttlingBurstLimit: 200
},
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
allowHeaders: ["Content-Type", "Authorization"]
}
});
// Define resources and methods
var items = api.root.addResource("items");
items.addMethod("GET", new apigateway.LambdaIntegration(itemsHandler));
items.addMethod("POST", new apigateway.LambdaIntegration(itemsHandler));
var singleItem = items.addResource("{id}");
singleItem.addMethod("GET", new apigateway.LambdaIntegration(itemsHandler));
singleItem.addMethod("PUT", new apigateway.LambdaIntegration(itemsHandler));
singleItem.addMethod("DELETE", new apigateway.LambdaIntegration(itemsHandler));
// Output the API URL
new cdk.CfnOutput(this, "ApiUrl", {
value: api.url,
description: "API Gateway endpoint URL"
});
Notice that LambdaIntegration automatically creates the AWS::Lambda::Permission that allows API Gateway to invoke the function. This is one of those things that takes 10 lines of CloudFormation YAML and zero lines of extra CDK code.
DynamoDB Tables
var dynamodb = require("aws-cdk-lib/aws-dynamodb");
var table = new dynamodb.Table(this, "ItemsTable", {
partitionKey: {
name: "pk",
type: dynamodb.AttributeType.STRING
},
sortKey: {
name: "sk",
type: dynamodb.AttributeType.STRING
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecovery: true,
timeToLiveAttribute: "ttl"
});
// Add a GSI
table.addGlobalSecondaryIndex({
indexName: "gsi1",
partitionKey: {
name: "gsi1pk",
type: dynamodb.AttributeType.STRING
},
sortKey: {
name: "gsi1sk",
type: dynamodb.AttributeType.STRING
},
projectionType: dynamodb.ProjectionType.ALL
});
S3 Buckets
var s3 = require("aws-cdk-lib/aws-s3");
var bucket = new s3.Bucket(this, "AssetsBucket", {
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
lifecycleRules: [
{
id: "delete-old-versions",
noncurrentVersionExpiration: cdk.Duration.days(30),
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7)
}
],
cors: [
{
allowedMethods: [s3.HttpMethods.GET, s3.HttpMethods.PUT],
allowedOrigins: ["*"],
allowedHeaders: ["*"],
maxAge: 3600
}
],
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true
});
IAM Grants
One of CDK's best features is the grant system. Instead of writing IAM policy documents, you use methods on the resource:
// Grant the Lambda read/write access to the DynamoDB table
props.table.grantReadWriteData(itemsHandler);
// Grant the Lambda read/write access to the S3 bucket
props.bucket.grantReadWrite(itemsHandler);
// Grant the Lambda permission to publish to an SNS topic
topic.grantPublish(itemsHandler);
// Grant the Lambda permission to send messages to an SQS queue
queue.grantSendMessages(itemsHandler);
Each grant* method creates a precisely scoped IAM policy and attaches it to the Lambda's execution role. No more copy-pasting ARN patterns or forgetting DynamoDB index permissions. The generated policies follow least-privilege automatically.
If you need custom policies:
var iam = require("aws-cdk-lib/aws-iam");
itemsHandler.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"ses:SendEmail",
"ses:SendRawEmail"
],
resources: ["arn:aws:ses:us-east-1:123456789012:identity/example.com"]
}));
Environment-Specific Deployments
Use CDK context or environment variables to handle multiple environments:
#!/usr/bin/env node
var cdk = require("aws-cdk-lib");
var ApiStack = require("../lib/api-stack");
var app = new cdk.App();
var envName = app.node.tryGetContext("env") || "dev";
var envConfig = {
dev: {
account: "111111111111",
region: "us-east-1",
tableBillingMode: "PAY_PER_REQUEST",
lambdaMemory: 128,
logRetention: 7
},
staging: {
account: "222222222222",
region: "us-east-1",
tableBillingMode: "PAY_PER_REQUEST",
lambdaMemory: 256,
logRetention: 14
},
prod: {
account: "333333333333",
region: "us-east-1",
tableBillingMode: "PAY_PER_REQUEST",
lambdaMemory: 512,
logRetention: 90
}
};
var config = envConfig[envName];
new ApiStack(app, envName + "-ApiStack", {
env: {
account: config.account,
region: config.region
},
envName: envName,
config: config
});
Deploy to a specific environment:
cdk deploy -c env=staging
CDK Context and Parameters
Context values are key-value pairs available at synthesis time. You can set them in cdk.json, on the command line, or programmatically:
{
"context": {
"vpc-id": "vpc-0123456789abcdef0",
"domain-name": "api.example.com",
"alert-email": "[email protected]"
}
}
Read them in your stack:
var vpcId = this.node.tryGetContext("vpc-id");
var domainName = this.node.tryGetContext("domain-name");
var alertEmail = this.node.tryGetContext("alert-email");
Avoid using CloudFormation Parameters (CfnParameter) in CDK. They defer values to deploy time, which means CDK cannot reason about them during synthesis. This breaks constructs that need concrete values to generate correct templates. Use context values or environment variables instead.
Testing CDK Stacks with Assertions
CDK provides a testing library that lets you assert on the synthesized CloudFormation template:
var cdk = require("aws-cdk-lib");
var assertions = require("aws-cdk-lib/assertions");
var ApiStack = require("../lib/api-stack");
var DatabaseStack = require("../lib/database-stack");
var StorageStack = require("../lib/storage-stack");
// Test helper to create the full stack
function createTestStacks() {
var app = new cdk.App();
var env = { account: "123456789012", region: "us-east-1" };
var storageStack = new StorageStack(app, "TestStorage", { env: env });
var databaseStack = new DatabaseStack(app, "TestDatabase", { env: env });
var apiStack = new ApiStack(app, "TestApi", {
env: env,
table: databaseStack.table,
bucket: storageStack.bucket,
envName: "test",
config: { lambdaMemory: 256, logRetention: 7 }
});
return {
apiTemplate: assertions.Template.fromStack(apiStack),
dbTemplate: assertions.Template.fromStack(databaseStack),
storageTemplate: assertions.Template.fromStack(storageStack)
};
}
// Verify Lambda function exists with correct configuration
test("Lambda function created with correct runtime", function () {
var templates = createTestStacks();
templates.apiTemplate.hasResourceProperties("AWS::Lambda::Function", {
Runtime: "nodejs20.x",
MemorySize: 256,
Timeout: 30
});
});
// Verify DynamoDB table configuration
test("DynamoDB table has correct key schema", function () {
var templates = createTestStacks();
templates.dbTemplate.hasResourceProperties("AWS::DynamoDB::Table", {
KeySchema: [
{ AttributeName: "pk", KeyType: "HASH" },
{ AttributeName: "sk", KeyType: "RANGE" }
],
BillingMode: "PAY_PER_REQUEST"
});
});
// Verify API Gateway has CORS configured
test("API Gateway has CORS enabled", function () {
var templates = createTestStacks();
templates.apiTemplate.hasResourceProperties("AWS::ApiGateway::Method", {
HttpMethod: "OPTIONS"
});
});
// Count resources
test("Stack creates exactly one DynamoDB table", function () {
var templates = createTestStacks();
templates.dbTemplate.resourceCountIs("AWS::DynamoDB::Table", 1);
});
// Snapshot testing
test("Stack matches snapshot", function () {
var templates = createTestStacks();
var template = templates.apiTemplate.toJSON();
expect(template).toMatchSnapshot();
});
Run the tests:
npx jest
These tests run in milliseconds because they only synthesize the template — they never touch AWS. This makes them ideal for CI pipelines and pre-commit hooks.
CDK Pipelines for CI/CD
CDK Pipelines is a construct library that creates a self-mutating CI/CD pipeline. The pipeline deploys your CDK app and updates itself when you change the pipeline definition.
var cdk = require("aws-cdk-lib");
var pipelines = require("aws-cdk-lib/pipelines");
var Construct = require("constructs").Construct;
function PipelineStack(scope, id, props) {
cdk.Stack.call(this, scope, id, props);
var pipeline = new pipelines.CodePipeline(this, "Pipeline", {
pipelineName: "MyAppPipeline",
synth: new pipelines.ShellStep("Synth", {
input: pipelines.CodePipelineSource.gitHub(
"myorg/my-cdk-app",
"main",
{
authentication: cdk.SecretValue.secretsManager("github-token")
}
),
commands: [
"npm ci",
"npm run build",
"npx cdk synth"
]
})
});
// Add a staging environment
var staging = new MyApplicationStage(this, "Staging", {
env: { account: "222222222222", region: "us-east-1" }
});
pipeline.addStage(staging, {
pre: [
new pipelines.ShellStep("RunTests", {
commands: ["npm ci", "npm test"]
})
]
});
// Add production with manual approval
var production = new MyApplicationStage(this, "Production", {
env: { account: "333333333333", region: "us-east-1" }
});
pipeline.addStage(production, {
pre: [
new pipelines.ManualApprovalStep("PromoteToProd", {
comment: "Review staging deployment before promoting to production"
})
]
});
}
PipelineStack.prototype = Object.create(cdk.Stack.prototype);
PipelineStack.prototype.constructor = PipelineStack;
// Application stage groups your stacks
function MyApplicationStage(scope, id, props) {
cdk.Stage.call(this, scope, id, props);
var storageStack = new StorageStack(this, "Storage");
var databaseStack = new DatabaseStack(this, "Database");
new ApiStack(this, "Api", {
table: databaseStack.table,
bucket: storageStack.bucket
});
}
MyApplicationStage.prototype = Object.create(cdk.Stage.prototype);
MyApplicationStage.prototype.constructor = MyApplicationStage;
module.exports = { PipelineStack: PipelineStack };
The self-mutating aspect is powerful. When you change the pipeline definition and push to main, the pipeline first updates itself, then deploys your application stacks. You never have to manually update the pipeline.
Custom Constructs
Building custom constructs is where CDK truly shines. Encapsulate patterns your team uses repeatedly:
var cdk = require("aws-cdk-lib");
var lambda = require("aws-cdk-lib/aws-lambda");
var logs = require("aws-cdk-lib/aws-logs");
var apigateway = require("aws-cdk-lib/aws-apigateway");
var Construct = require("constructs").Construct;
function MonitoredLambdaApi(scope, id, props) {
Construct.call(this, scope, id);
// Create the Lambda with standard defaults
this.handler = new lambda.Function(this, "Handler", {
runtime: lambda.Runtime.NODEJS_20_X,
handler: props.handler || "index.handler",
code: props.code,
memorySize: props.memorySize || 256,
timeout: props.timeout || cdk.Duration.seconds(30),
environment: props.environment || {},
tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_WEEKS
});
// Create API Gateway if requested
if (props.apiPath) {
this.api = new apigateway.RestApi(this, "Api", {
restApiName: id + "-api",
deployOptions: {
stageName: props.stageName || "prod"
}
});
var resource = this.api.root.addResource(props.apiPath);
var methods = props.methods || ["GET"];
var self = this;
methods.forEach(function (method) {
resource.addMethod(method, new apigateway.LambdaIntegration(self.handler));
});
}
}
MonitoredLambdaApi.prototype = Object.create(Construct.prototype);
MonitoredLambdaApi.prototype.constructor = MonitoredLambdaApi;
module.exports = MonitoredLambdaApi;
Use it in your stacks:
var MonitoredLambdaApi = require("./constructs/monitored-lambda-api");
var itemsApi = new MonitoredLambdaApi(this, "ItemsApi", {
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/items")),
apiPath: "items",
methods: ["GET", "POST", "PUT", "DELETE"],
environment: {
TABLE_NAME: props.table.tableName
}
});
props.table.grantReadWriteData(itemsApi.handler);
Custom constructs are the key to scaling CDK across a team. Define your organization's patterns once — logging, monitoring, security defaults, tagging — and every developer gets them for free.
Complete Working Example
Here is a full CDK application that deploys a serverless items API. This is the complete project structure:
serverless-api/
├── bin/
│ └── app.js
├── lib/
│ ├── api-stack.js
│ ├── database-stack.js
│ └── storage-stack.js
├── lambda/
│ └── items/
│ └── index.js
├── test/
│ └── stacks.test.js
├── cdk.json
└── package.json
package.json
{
"name": "serverless-api",
"version": "1.0.0",
"scripts": {
"test": "jest",
"cdk": "cdk"
},
"dependencies": {
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
bin/app.js
#!/usr/bin/env node
var cdk = require("aws-cdk-lib");
var DatabaseStack = require("../lib/database-stack");
var StorageStack = require("../lib/storage-stack");
var ApiStack = require("../lib/api-stack");
var app = new cdk.App();
var envName = app.node.tryGetContext("env") || "dev";
var env = {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION
};
var databaseStack = new DatabaseStack(app, envName + "-DatabaseStack", {
env: env,
envName: envName
});
var storageStack = new StorageStack(app, envName + "-StorageStack", {
env: env,
envName: envName
});
var apiStack = new ApiStack(app, envName + "-ApiStack", {
env: env,
envName: envName,
table: databaseStack.table,
bucket: storageStack.bucket
});
lib/database-stack.js
var cdk = require("aws-cdk-lib");
var dynamodb = require("aws-cdk-lib/aws-dynamodb");
function DatabaseStack(scope, id, props) {
cdk.Stack.call(this, scope, id, props);
this.table = new dynamodb.Table(this, "ItemsTable", {
tableName: props.envName + "-items",
partitionKey: {
name: "pk",
type: dynamodb.AttributeType.STRING
},
sortKey: {
name: "sk",
type: dynamodb.AttributeType.STRING
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: props.envName === "prod"
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY
});
this.table.addGlobalSecondaryIndex({
indexName: "gsi1",
partitionKey: {
name: "gsi1pk",
type: dynamodb.AttributeType.STRING
},
sortKey: {
name: "gsi1sk",
type: dynamodb.AttributeType.STRING
},
projectionType: dynamodb.ProjectionType.ALL
});
new cdk.CfnOutput(this, "TableName", {
value: this.table.tableName
});
}
DatabaseStack.prototype = Object.create(cdk.Stack.prototype);
DatabaseStack.prototype.constructor = DatabaseStack;
module.exports = DatabaseStack;
lib/storage-stack.js
var cdk = require("aws-cdk-lib");
var s3 = require("aws-cdk-lib/aws-s3");
function StorageStack(scope, id, props) {
cdk.Stack.call(this, scope, id, props);
this.bucket = new s3.Bucket(this, "AssetsBucket", {
bucketName: props.envName + "-items-assets-" + cdk.Aws.ACCOUNT_ID,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
lifecycleRules: [
{
noncurrentVersionExpiration: cdk.Duration.days(30)
}
],
removalPolicy: props.envName === "prod"
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: props.envName !== "prod"
});
new cdk.CfnOutput(this, "BucketName", {
value: this.bucket.bucketName
});
}
StorageStack.prototype = Object.create(cdk.Stack.prototype);
StorageStack.prototype.constructor = StorageStack;
module.exports = StorageStack;
lib/api-stack.js
var cdk = require("aws-cdk-lib");
var lambda = require("aws-cdk-lib/aws-lambda");
var apigateway = require("aws-cdk-lib/aws-apigateway");
var logs = require("aws-cdk-lib/aws-logs");
var path = require("path");
function ApiStack(scope, id, props) {
cdk.Stack.call(this, scope, id, props);
// Lambda function
var itemsHandler = new lambda.Function(this, "ItemsHandler", {
functionName: props.envName + "-items-handler",
runtime: lambda.Runtime.NODEJS_20_X,
handler: "index.handler",
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/items")),
memorySize: 256,
timeout: cdk.Duration.seconds(30),
environment: {
TABLE_NAME: props.table.tableName,
BUCKET_NAME: props.bucket.bucketName,
ENV_NAME: props.envName
},
tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_WEEKS
});
// Grant permissions
props.table.grantReadWriteData(itemsHandler);
props.bucket.grantReadWrite(itemsHandler);
// API Gateway
var api = new apigateway.RestApi(this, "ItemsApi", {
restApiName: props.envName + "-items-api",
description: "Items CRUD API",
deployOptions: {
stageName: props.envName,
throttlingRateLimit: 100,
throttlingBurstLimit: 200
},
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
allowHeaders: ["Content-Type", "Authorization"]
}
});
var itemsResource = api.root.addResource("items");
var integration = new apigateway.LambdaIntegration(itemsHandler);
itemsResource.addMethod("GET", integration);
itemsResource.addMethod("POST", integration);
var singleItem = itemsResource.addResource("{id}");
singleItem.addMethod("GET", integration);
singleItem.addMethod("PUT", integration);
singleItem.addMethod("DELETE", integration);
new cdk.CfnOutput(this, "ApiUrl", {
value: api.url,
description: "API Gateway endpoint URL"
});
}
ApiStack.prototype = Object.create(cdk.Stack.prototype);
ApiStack.prototype.constructor = ApiStack;
module.exports = ApiStack;
lambda/items/index.js
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();
var s3 = new AWS.S3();
var TABLE_NAME = process.env.TABLE_NAME;
var BUCKET_NAME = process.env.BUCKET_NAME;
exports.handler = function (event, context, callback) {
var method = event.httpMethod;
var pathParams = event.pathParameters;
var body = event.body ? JSON.parse(event.body) : null;
console.log("Received event:", JSON.stringify(event, null, 2));
if (method === "GET" && !pathParams) {
return listItems(callback);
} else if (method === "GET" && pathParams && pathParams.id) {
return getItem(pathParams.id, callback);
} else if (method === "POST") {
return createItem(body, callback);
} else if (method === "PUT" && pathParams && pathParams.id) {
return updateItem(pathParams.id, body, callback);
} else if (method === "DELETE" && pathParams && pathParams.id) {
return deleteItem(pathParams.id, callback);
}
return respond(400, { error: "Unsupported route" }, callback);
};
function listItems(callback) {
var params = {
TableName: TABLE_NAME,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": "ITEM" }
};
dynamodb.query(params, function (err, data) {
if (err) {
console.error("Query failed:", err);
return respond(500, { error: "Failed to list items" }, callback);
}
return respond(200, { items: data.Items }, callback);
});
}
function getItem(id, callback) {
var params = {
TableName: TABLE_NAME,
Key: { pk: "ITEM", sk: id }
};
dynamodb.get(params, function (err, data) {
if (err) {
console.error("Get failed:", err);
return respond(500, { error: "Failed to get item" }, callback);
}
if (!data.Item) {
return respond(404, { error: "Item not found" }, callback);
}
return respond(200, data.Item, callback);
});
}
function createItem(body, callback) {
var id = Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
var item = {
pk: "ITEM",
sk: id,
id: id,
name: body.name,
description: body.description,
createdAt: new Date().toISOString()
};
var params = {
TableName: TABLE_NAME,
Item: item
};
dynamodb.put(params, function (err) {
if (err) {
console.error("Put failed:", err);
return respond(500, { error: "Failed to create item" }, callback);
}
return respond(201, item, callback);
});
}
function updateItem(id, body, callback) {
var params = {
TableName: TABLE_NAME,
Key: { pk: "ITEM", sk: id },
UpdateExpression: "SET #n = :name, description = :desc, updatedAt = :ts",
ExpressionAttributeNames: { "#n": "name" },
ExpressionAttributeValues: {
":name": body.name,
":desc": body.description,
":ts": new Date().toISOString()
},
ReturnValues: "ALL_NEW"
};
dynamodb.update(params, function (err, data) {
if (err) {
console.error("Update failed:", err);
return respond(500, { error: "Failed to update item" }, callback);
}
return respond(200, data.Attributes, callback);
});
}
function deleteItem(id, callback) {
var params = {
TableName: TABLE_NAME,
Key: { pk: "ITEM", sk: id }
};
dynamodb.delete(params, function (err) {
if (err) {
console.error("Delete failed:", err);
return respond(500, { error: "Failed to delete item" }, callback);
}
return respond(200, { message: "Item deleted" }, callback);
});
}
function respond(statusCode, body, callback) {
callback(null, {
statusCode: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
body: JSON.stringify(body)
});
}
Deploy the full application:
# Synthesize to see the CloudFormation output
cdk synth
# Review what will be deployed
cdk diff
# Deploy all stacks
cdk deploy --all -c env=dev
# Deploy a specific stack
cdk deploy dev-ApiStack -c env=dev
Common Issues and Troubleshooting
1. Bootstrap Version Mismatch
Error: This CDK deployment requires bootstrap stack version '21', found '14'.
Please run 'cdk bootstrap' with a newer version of the CDK CLI.
This happens when your CDK CLI version is newer than the bootstrap stack in your account. Fix it by re-running bootstrap:
cdk bootstrap aws://123456789012/us-east-1
If you are in a locked-down environment where bootstrap updates are controlled, you can pin the required bootstrap version in cdk.json:
{
"context": {
"@aws-cdk/core:bootstrapQualifier": "hnb659fds"
}
}
2. Cross-Stack Reference Cycles
Error: 'DatabaseStack' depends on 'ApiStack' (DatabaseStack ->
ApiStack/ItemsHandler/Resource.Arn). Adding this dependency
(ApiStack -> DatabaseStack/ItemsTable/Resource.Ref) would create a
cyclic reference.
This occurs when two stacks reference each other. The fix is to restructure so dependencies flow in one direction. Pass the resource from the upstream stack to the downstream stack via props — never reach back into the upstream stack from the downstream one.
3. Lambda Asset Bundling Failures
Error: Cannot find asset at /home/user/my-cdk-app/lambda/items
CDK expects the path passed to lambda.Code.fromAsset() to exist at synthesis time. Make sure the directory exists and contains your handler file. Use path.join(__dirname, "../lambda/items") with a relative path from the stack file, not from the project root.
4. Resource Already Exists
Error: my-table already exists in stack arn:aws:cloudformation:us-east-1:...
This happens when you assign a physical name (like tableName) and a resource with that name already exists. Either import the existing resource, remove the explicit name and let CloudFormation generate one, or delete the existing resource first. My recommendation: avoid hardcoding physical names unless you have a specific reason (like cross-account references). Let CDK generate unique names.
5. Permissions Errors During Deploy
User: arn:aws:iam::123456789012:user/developer is not authorized to
perform: cloudformation:CreateStack on resource: arn:aws:cloudformation:...
The deploying IAM user needs permissions to assume the CDK bootstrap roles. The easiest fix is to attach the AdministratorAccess policy for development accounts. For production, create a deployment role with the specific permissions CDK needs:
cdk deploy --role-arn arn:aws:iam::123456789012:role/cdk-deploy-role
Best Practices
Use L2 constructs whenever possible. They encode AWS best practices, handle IAM automatically, and reduce your code by 60-80% compared to L1. Only drop to L1 when you need a property that L2 does not expose.
Split stacks by lifecycle, not by service. Group resources that change together. A database stack rarely changes, while an API stack changes frequently. Separate them so you can deploy the API without risking the database.
Never hardcode physical resource names. Let CDK generate unique names. Hardcoded names cause deployment failures when names collide, prevent you from deploying multiple copies of a stack, and make stack replacement impossible without downtime.
Use
cdk diffbefore every deploy. It shows you exactly what CloudFormation will change. Review it carefully — especially for database tables, where a replacement means data loss. Makecdk diffpart of your CI pipeline as a mandatory check.Write tests for your infrastructure. CDK assertion tests are fast and cheap. Test that critical resources exist, have correct configurations, and follow your security policies. Snapshot tests catch unintended changes.
Tag everything. Use the
Tags.of()API to apply consistent tags across all resources in a stack. Tags are essential for cost allocation, access control, and operational visibility:
cdk.Tags.of(this).add("Environment", props.envName);
cdk.Tags.of(this).add("Project", "items-api");
cdk.Tags.of(this).add("ManagedBy", "cdk");
Set removal policies explicitly. The default
RemovalPolicy.RETAINfor stateful resources (databases, buckets) is safe but can leave orphaned resources. For dev environments, useDESTROY. For production, keepRETAINand clean up manually.Pin your CDK version. All
aws-cdk-libandconstructsversions across your stacks must match. Use exact versions inpackage.jsonand update deliberately. Version mismatches between stacks cause synthesis failures.Use
CfnOutputfor important values. Output your API URLs, bucket names, and table names. They appear in the CloudFormation console and thecdk deployoutput, making it easy to find endpoints without digging through the AWS console.Leverage
cdk watchduring development. It monitors your source files and automatically deploys changes. This is especially useful for Lambda code iteration where the feedback loop matters:
cdk watch -c env=dev
References
- AWS CDK Developer Guide
- AWS CDK API Reference (JavaScript)
- CDK Patterns - Open source collection of CDK architecture patterns
- AWS CDK GitHub Repository
- CDK Workshop - Official hands-on CDK tutorial
- Best Practices for Developing Cloud Applications with AWS CDK