Building Serverless Applications with AWS CDK
I once spent three days debugging a CloudFormation deployment that failed because of a YAML indentation error on line 847. The template was generated by copying snippets from Stack Overflow and praying. No type checking. No IDE support. Deploy to find out if it works.
AWS CDK (Cloud Development Kit) was the escape hatch: define infrastructure in TypeScript (or Python, Go, Java), synthesize to CloudFormation, deploy with the same AWS pipeline. Real programming language features—loops, conditionals, functions, type safety—for infrastructure that previously required copy-paste YAML archaeology.
I wouldn’t go back to raw CloudFormation for anything non-trivial. Here’s how I build serverless applications with CDK.
Why CDK Over CloudFormation/Terraform
| CloudFormation | Terraform | CDK | |
|---|---|---|---|
| Language | YAML/JSON | HCL | TypeScript/Python/Go/Java |
| Type safety | None | Limited | Full (TypeScript) |
| Abstraction | Macros | Modules | Constructs (L2, L3) |
| AWS integration | Native | Provider | Native, same-day features |
| State | AWS-managed | Self-managed | AWS-managed |
CDK synthesizes to CloudFormation—you get AWS-native deployment with programming language ergonomics. For AWS-heavy serverless, it’s my default.
Getting Started
npm install -g aws-cdk
cdk --version
mkdir my-serverless-app && cd my-serverless-app
cdk init app --language typescript
npm install
cdk synth # Generate CloudFormation template
cdk deploy # Deploy to AWS
Project structure:
├── bin/app.ts # CDK app entry
├── lib/
│ └── serverless-stack.ts # Stack definition
├── lambda/ # Lambda function code
├── cdk.json
└── package.json
Basic Stack: Lambda + API Gateway
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { Construct } from 'constructs';
export class ServerlessStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const apiHandler = new lambda.Function(this, 'ApiHandler', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda'),
timeout: cdk.Duration.seconds(30),
memorySize: 256,
environment: {
NODE_ENV: 'production',
},
});
const api = new apigateway.RestApi(this, 'Api', {
restApiName: 'My Serverless API',
description: 'API Gateway + Lambda',
});
api.root.addResource('hello')
.addMethod('GET', new apigateway.LambdaIntegration(apiHandler));
new cdk.CfnOutput(this, 'ApiUrl', {
value: api.url,
});
}
}
cdk deploy creates Lambda, API Gateway, IAM roles, permissions—everything wired correctly. No manual console clicking.
Lambda Patterns
Function with Environment and Layers
const processingLayer = new lambda.LayerVersion(this, 'ProcessingLayer', {
code: lambda.Code.fromAsset('layers/processing'),
compatibleRuntimes: [lambda.Runtime.NODEJS_18_X],
description: 'Shared processing utilities',
});
const processor = new lambda.Function(this, 'Processor', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'processor.handler',
code: lambda.Code.fromAsset('lambda/processor'),
layers: [processingLayer],
timeout: cdk.Duration.minutes(5),
memorySize: 512,
environment: {
TABLE_NAME: table.tableName,
LOG_LEVEL: 'info',
},
});
Bundling TypeScript Lambda Code
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
const apiHandler = new NodejsFunction(this, 'ApiHandler', {
entry: 'lambda/api.ts',
handler: 'handler',
runtime: lambda.Runtime.NODEJS_18_X,
bundling: {
minify: true,
sourceMap: true,
externalModules: ['aws-sdk'],
},
});
NodejsFunction bundles TypeScript with esbuild automatically. No separate build step.
API Gateway: REST vs HTTP
// REST API — full features, higher cost
const restApi = new apigateway.RestApi(this, 'RestApi', {
restApiName: 'Full Featured API',
});
// HTTP API — cheaper, faster, fewer features
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi');
httpApi.addRoutes({
path: '/hello',
methods: [apigatewayv2.HttpMethod.GET],
integration: new HttpLambdaIntegration('HelloIntegration', apiHandler),
});
HTTP API for simple Lambda proxies (70% cheaper). REST API when you need API keys, request validation, WAF integration.
DynamoDB: Serverless Database
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
const table = new dynamodb.Table(this, 'DataTable', {
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY, // RETAIN for production!
pointInTimeRecovery: true,
});
// GSI for alternate access patterns
table.addGlobalSecondaryIndex({
indexName: 'by-status',
partitionKey: { name: 'status', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'createdAt', type: dynamodb.AttributeType.STRING },
});
// Grant Lambda read/write — creates IAM policies automatically
table.grantReadWriteData(apiHandler);
grantReadWriteData creates least-privilege IAM policies. No manual policy JSON.
S3 + Lambda Triggers
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';
const bucket = new s3.Bucket(this, 'Uploads', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true, // Only for dev!
encryption: s3.BucketEncryption.S3_MANAGED,
});
const imageProcessor = new lambda.Function(this, 'ImageProcessor', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'processor.handler',
code: lambda.Code.fromAsset('lambda/processor'),
memorySize: 1024, // Image processing needs memory
});
bucket.addEventNotification(
s3.EventType.OBJECT_CREATED,
new s3n.LambdaDestination(imageProcessor),
{ prefix: 'uploads/', suffix: '.jpg' }
);
Upload a JPG to uploads/ → Lambda processes automatically. Event-driven without SQS.
EventBridge: Scheduled Tasks
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
new events.Rule(this, 'DailyCleanup', {
schedule: events.Schedule.cron({ hour: '3', minute: '0' }),
targets: [new targets.LambdaFunction(cleanupFunction)],
});
Cron jobs without maintaining a server. Pay per invocation.
Reusable Constructs: DRY Infrastructure
// lib/constructs/api-lambda.ts
export class ApiLambda extends Construct {
public readonly function: lambda.Function;
public readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props: ApiLambdaProps) {
super(scope, id);
this.function = new lambda.NodejsFunction(this, 'Handler', {
entry: props.entry,
runtime: lambda.Runtime.NODEJS_18_X,
environment: props.environment,
});
this.api = new apigateway.RestApi(this, 'Api');
this.api.root.addResource(props.path)
.addMethod(props.method, new apigateway.LambdaIntegration(this.function));
}
}
// Usage in stack
new ApiLambda(this, 'UsersApi', {
entry: 'lambda/users.ts',
path: 'users',
method: 'GET',
environment: { TABLE_NAME: table.tableName },
});
Constructs are CDK’s superpower—reusable infrastructure components with typed props.
Production Essentials
// Dead letter queue for failed Lambda invocations
const dlq = new sqs.Queue(this, 'DLQ');
const fn = new lambda.Function(this, 'Fn', {
// ...
deadLetterQueue: dlq,
retryAttempts: 2,
});
// CloudWatch alarms
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
new cloudwatch.Alarm(this, 'ErrorAlarm', {
metric: fn.metricErrors(),
threshold: 5,
evaluationPeriods: 1,
alarmDescription: 'Lambda errors exceeded threshold',
});
- Dead letter queues — failed invocations need somewhere to go
- CloudWatch alarms — errors, duration, throttles
- X-Ray tracing —
tracing: lambda.Tracing.ACTIVE - RemovalPolicy.RETAIN — production data survives stack deletion
- Separate stacks — data stack (RETAIN) vs compute stack (DESTROY)
- Environment context —
cdk deploy -c env=production
Testing
import { Template } from 'aws-cdk-lib/assertions';
test('creates Lambda function', () => {
const app = new cdk.App();
const stack = new ServerlessStack(app, 'TestStack');
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::Lambda::Function', {
Runtime: 'nodejs18.x',
Timeout: 30,
});
});
Test infrastructure in CI before deploy. Catch missing resources, wrong configurations.
Conclusion
AWS CDK turned infrastructure from YAML copy-paste into typed, testable, reusable code. The CloudFormation template that took three days to debug became a 50-line TypeScript stack that failed at synth time with a clear type error.
For serverless—Lambda, API Gateway, DynamoDB, S3, EventBridge—CDK is the fastest path from idea to deployed. Constructs handle the wiring. Type safety catches mistakes. cdk deploy does the rest.
Start with one stack: Lambda + API Gateway. Add DynamoDB when you need state. Add S3 when you need files. Extract constructs when you repeat patterns. Test with assertions.
CloudFormation still runs underneath. You just don’t have to write it by hand anymore. That alone is worth the switch.
Building serverless applications with AWS CDK from September 2021, covering Lambda, API Gateway, and DynamoDB.