Skip to content

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:

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:

  1. 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
  1. 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

AspectServerless FrameworkCDK
ConfigurationYAML filesTypeScript code
Environment Variables${self:provider.stage}Config objects
Local Developmentserverless-offlineSAM CLI or testing
Deploymentserverless deploycdk 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.

Progress 2 of 6 posts

Related posts