2025-09-04
Migrating from Serverless Framework to AWS CDK: Part 2 - Setting Up Your CDK Environment
Learn how to structure a CDK project for serverless applications, configure TypeScript for Lambda development, and establish patterns that ease migration from Serverless Framework.
When teams decide to migrate from Serverless Framework to CDK, the immediate question becomes: where to start? Working with Lambda functions across multiple environments requires careful planning, especially when coordinating between multiple developers.
Scaling CDK from personal projects to production systems presents unique challenges. Teams need structure, conventions, and patterns that work for everyone involved.
This guide covers designing a CDK project structure that allows multiple engineers to work in parallel without conflicts, maintains familiar patterns from Serverless Framework, and serves as a foundation for production platforms.
Series Navigation:
- Part 1: Why Make the Switch?
- Part 2: Setting Up Your CDK Environment (this post)
- Part 3: Migrating Lambda Functions and API Gateway
- Part 4: Database Resources and Environment Management
- Part 5: Authentication, Authorization, and IAM
- Part 6: Migration Strategies and Best Practices
The Project Structure That Actually Scales
Initial attempts often struggle with simple tutorial structures. Common issues include merge conflicts, unclear ownership, and confusion about file organization.
Here’s the evolution from chaos to order:
# Serverless Framework Structure
my-service/
├── serverless.yml
├── package.json
├── src/
│ └── handlers/
│ ├── users.js
│ └── products.js
├── resources/
│ └── dynamodb-tables.yml
└── config/
├── dev.yml
└── prod.yml
# CDK Structure (After 3 failed attempts)
my-service/
├── cdk.json # CDK app configuration
├── package.json
├── bin/
│ └── my-service.ts # Single entry point (important for simplicity)
├── lib/
│ ├── stacks/ # Stack definitions by domain
│ │ ├── api-stack.ts # API Gateway + Lambda functions
│ │ ├── data-stack.ts # DynamoDB tables (stateful)
│ │ └── auth-stack.ts # Cognito + auth logic
│ ├── constructs/ # Reusable patterns for consistency
│ │ ├── production-lambda.ts # Standard Lambda configuration
│ │ ├── api-with-auth.ts # Common API patterns
│ │ └── monitored-table.ts # DynamoDB with alarms
│ └── config/ # Environment-specific configs
│ ├── development.ts
│ ├── staging.ts
│ └── production.ts
├── src/
│ └── handlers/ # Lambda code (familiar location)
│ ├── users/ # Grouped by domain
│ │ ├── create.ts
│ │ ├── update.ts
│ │ └── list.ts
│ └── products/
│ ├── catalog.ts
│ └── inventory.ts
└── test/
├── unit/ # Handler unit tests
├── integration/ # API integration tests
└── infrastructure/ # CDK stack tests
Key insight: Domain-driven organization prevents merge conflicts when multiple engineers work in parallel.
Initializing Your CDK Project
First, ensure you have the prerequisites:
# Install AWS CDK CLI globally
npm install -g aws-cdk@2
# Verify installation
cdk --version # Should show 2.x.x
# Configure AWS credentials (if not already done)
aws configure
Now create your project:
# Create project directory
mkdir my-serverless-api && cd my-serverless-api
# Initialize CDK with TypeScript
cdk init app --language typescript
# Install Lambda-specific dependencies
npm install @types/aws-lambda
# Install development tools
npm install --save-dev esbuild @types/node ts-node
Configuring TypeScript for Lambda Development
CDK generates a basic tsconfig.json. Let’s optimize it for serverless development:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./",
"baseUrl": "./",
"paths": {
"@handlers/*": ["src/handlers/*"],
"@libs/*": ["src/libs/*"],
"@constructs/*": ["lib/constructs/*"]
}
},
"include": [
"bin/**/*",
"lib/**/*",
"src/**/*",
"test/**/*"
],
"exclude": [
"cdk.out",
"node_modules"
]
}
Environment Configuration Management
Serverless Framework uses YAML files for environment-specific configuration. Let’s create a TypeScript-based equivalent:
// lib/config/environment.ts
export interface EnvironmentConfig {
stage: string;
region: string;
account: string;
api: {
throttling: {
rateLimit: number;
burstLimit: number;
};
cors: {
origins: string[];
credentials: boolean;
};
};
lambda: {
memorySize: number;
timeout: number;
reservedConcurrentExecutions?: number;
};
monitoring: {
alarmEmail?: string;
enableXRay: boolean;
logRetentionDays: number;
};
}
// lib/config/stages/dev.ts
export const devConfig: EnvironmentConfig = {
stage: 'dev',
region: 'us-east-1',
account: '123456789012',
api: {
throttling: {
rateLimit: 100,
burstLimit: 200,
},
cors: {
origins: ['http://localhost:3000'],
credentials: true,
},
},
lambda: {
memorySize: 512,
timeout: 30,
},
monitoring: {
enableXRay: true,
logRetentionDays: 7,
},
};
// lib/config/stages/prod.ts
export const prodConfig: EnvironmentConfig = {
stage: 'prod',
region: 'us-east-1',
account: '123456789012',
api: {
throttling: {
rateLimit: 1000,
burstLimit: 2000,
},
cors: {
origins: ['https://myapp.com'],
credentials: true,
},
},
lambda: {
memorySize: 1024,
timeout: 30,
reservedConcurrentExecutions: 100,
},
monitoring: {
alarmEmail: '[email protected]',
enableXRay: true,
logRetentionDays: 30,
},
};
// lib/config/index.ts
import { devConfig } from './stages/dev';
import { prodConfig } from './stages/prod';
export function getConfig(stage: string): EnvironmentConfig {
switch (stage) {
case 'dev':
return devConfig;
case 'prod':
return prodConfig;
default:
throw new Error(`Unknown stage: ${stage}`);
}
}
Creating Your First Construct
Constructs are CDK’s building blocks. Let’s create a reusable pattern for Lambda functions:
// lib/constructs/serverless-function.ts
import { Construct } from 'constructs';
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime, Tracing } from 'aws-cdk-lib/aws-lambda';
import { Duration } from 'aws-cdk-lib';
import { EnvironmentConfig } from '../config/environment';
export interface ServerlessFunctionProps {
entry: string;
handler?: string;
environment?: Record<string, string>;
config: EnvironmentConfig;
memorySize?: number;
timeout?: number;
}
export class ServerlessFunction extends NodejsFunction {
constructor(scope: Construct, id: string, props: ServerlessFunctionProps) {
const { config, ...functionProps } = props;
super(scope, id, {
runtime: Runtime.NODEJS_20_X, // Consider NODEJS_22_X for latest features
handler: props.handler || 'handler',
entry: props.entry,
memorySize: props.memorySize || config.lambda.memorySize,
timeout: Duration.seconds(props.timeout || config.lambda.timeout),
tracing: config.monitoring.enableXRay ? Tracing.ACTIVE : Tracing.DISABLED,
environment: {
NODE_OPTIONS: '--enable-source-maps',
STAGE: config.stage,
...props.environment,
},
bundling: {
minify: config.stage === 'prod',
sourceMap: true,
sourcesContent: false,
target: 'es2022',
keepNames: true,
// Exclude AWS SDK v3 (provided in Lambda runtime)
externalModules: [
'@aws-sdk/*',
],
},
reservedConcurrentExecutions: config.lambda.reservedConcurrentExecutions,
});
}
}
Setting Up Your First Stack
Now let’s create a stack that uses our construct:
// lib/stacks/api-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { RestApi, LambdaIntegration, Cors } from 'aws-cdk-lib/aws-apigateway';
import { ServerlessFunction } from '../constructs/serverless-function';
import { EnvironmentConfig } from '../config/environment';
export interface ApiStackProps extends StackProps {
config: EnvironmentConfig;
}
export class ApiStack extends Stack {
public readonly api: RestApi;
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
const { config } = props;
// Create API Gateway
this.api = new RestApi(this, 'ServerlessApi', {
restApiName: `my-service-${config.stage}`,
deployOptions: {
stageName: config.stage,
throttlingRateLimit: config.api.throttling.rateLimit,
throttlingBurstLimit: config.api.throttling.burstLimit,
},
defaultCorsPreflightOptions: {
allowOrigins: config.api.cors.origins,
allowCredentials: config.api.cors.credentials,
allowMethods: Cors.ALL_METHODS,
allowHeaders: [
'Content-Type',
'Authorization',
'X-Api-Key',
],
},
});
// Create Lambda functions
const createUserFn = new ServerlessFunction(this, 'CreateUserFunction', {
entry: 'src/handlers/users.ts',
handler: 'create',
config,
environment: {
// Environment variables will be added in Part 4
},
});
// Set up routes
const users = this.api.root.addResource('users');
users.addMethod('POST', new LambdaIntegration(createUserFn));
}
}
CDK App Entry Point
Update the CDK app entry point to use our configuration system:
// bin/my-service.ts
#!/usr/bin/env node
import 'source-map-support/register';
import { App } from 'aws-cdk-lib';
import { ApiStack } from '../lib/stacks/api-stack';
import { getConfig } from '../lib/config';
const app = new App();
// Get stage from context or environment
const stage = app.node.tryGetContext('stage') || process.env.STAGE || 'dev';
const config = getConfig(stage);
new ApiStack(app, `MyServiceApiStack-${stage}`, {
config,
env: {
account: config.account,
region: config.region,
},
tags: {
Stage: stage,
Service: 'my-service',
ManagedBy: 'cdk',
},
});
Your First Lambda Handler
Create a Lambda handler using TypeScript:
// src/handlers/users.ts
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
export const create = async (
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
console.log('Event:', JSON.stringify(event, null, 2));
try {
const body = JSON.parse(event.body || '{}');
// Handler logic here (to be expanded in Part 3)
return {
statusCode: 201,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: 'User created successfully',
stage: process.env.STAGE,
}),
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
error: 'Internal server error',
}),
};
}
};
Deployment Commands
Add these scripts to your package.json:
{
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"cdk": "cdk",
"bootstrap": "cdk bootstrap",
"deploy:dev": "cdk deploy --context stage=dev",
"deploy:prod": "cdk deploy --context stage=prod",
"diff:dev": "cdk diff --context stage=dev",
"diff:prod": "cdk diff --context stage=prod",
"synth": "cdk synth",
"test": "jest",
"test:watch": "jest --watch"
}
}
First Deployment
Bootstrap your AWS environment (one-time setup that prepares your AWS account for CDK deployments by creating necessary S3 buckets and IAM roles):
npm run bootstrap
Deploy to development:
npm run deploy:dev
CDK will show you what resources it plans to create. Review and confirm.
Local Development Setup
Unlike Serverless Framework’s serverless-offline, CDK doesn’t provide built-in local API Gateway emulation. For local development, you have several options:
- SAM CLI Integration (Recommended):
# Install SAM CLI
brew install aws-sam-cli # macOS
# or follow: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html
# Generate CloudFormation template
cdk synth --no-staging > template.yaml
# Start local API
sam local start-api -t template.yaml
- Direct Handler Testing:
// test/handlers/users.test.ts
import { create } from '../../src/handlers/users';
import { APIGatewayProxyEventV2 } from 'aws-lambda';
describe('Users Handler', () => {
it('should create a user', async () => {
const event: Partial<APIGatewayProxyEventV2> = {
body: JSON.stringify({ name: 'John Doe' }),
};
const result = await create(event as APIGatewayProxyEventV2);
expect(result.statusCode).toBe(201);
expect(JSON.parse(result.body!)).toHaveProperty('message');
});
});
Key Differences to Remember
| Aspect | Serverless Framework | CDK |
|---|---|---|
| Configuration | YAML files | TypeScript code |
| Environment Variables | ${self:provider.stage} | Config objects |
| Local Development | serverless-offline | SAM CLI or testing |
| Deployment | serverless deploy | cdk deploy |
| Resource References | !Ref or ${cf:stackName.output} | Direct object references |
What’s Next
You now have a solid CDK foundation that mirrors Serverless Framework conventions while embracing CDK’s type safety and composability. Your Lambda functions live in familiar locations, but your infrastructure is now code - real, testable TypeScript code.
Related reading: For a comprehensive comparison of different CDK organization patterns (service-based vs domain-based vs feature-based), see AWS CDK Code Organization: Service-Based vs Domain-Based Architecture Patterns.
In Part 3, we’ll migrate Lambda functions and API Gateway configurations, including:
- Request/response transformations
- API Gateway models and validators
- Lambda layers and dependencies
- Error handling patterns
- API versioning strategies
The foundation is set. Let’s build your serverless API.
Migrating from Serverless Framework to AWS CDK
A comprehensive 6-part guide covering the complete migration process from Serverless Framework to AWS CDK, including setup, implementation patterns, and best practices.
All posts in this series
Related posts
A comprehensive technical guide comparing AWS Secrets Manager and Systems Manager Parameter Store, demonstrating when to use each service with real-world implementation patterns.
Setting up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda. Real architecture decisions, initial setup, and lessons learned from building URL shorteners at scale.
Building a RAG agent on AWS Bedrock + Knowledge Bases + OpenSearch Serverless with CDK in TypeScript — architecture, IAM wiring, automated ingestion, and the chat UI.
A CDK guide for deploying a minimal Strands agent on AgentCore Runtime — parameterized stack, arm64 build, deploy and invoke, and the IAM and Marketplace prerequisites you need before the first call.
Step-by-step guide to integrating Sentry error monitoring into a React Native Expo app. Covers SDK initialization, Expo Router instrumentation, session replay, source map uploads for EAS Build and EAS Update, and common pitfalls to avoid.