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',
});
  1. Dead letter queues — failed invocations need somewhere to go
  2. CloudWatch alarms — errors, duration, throttles
  3. X-Ray tracingtracing: lambda.Tracing.ACTIVE
  4. RemovalPolicy.RETAIN — production data survives stack deletion
  5. Separate stacks — data stack (RETAIN) vs compute stack (DESTROY)
  6. Environment contextcdk 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.