March 2, 2025

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.

Week 1 of our CDK migration. The decision was made, the budget approved, and my team of 12 engineers was looking at me expectantly. "So, where do we start?"

I'd used CDK on personal projects, but scaling it to handle 47 Lambda functions across 4 environments with 12 developers? That was different. We needed structure, conventions, and patterns that would work not just for me, but for the entire team.

This is the story of how we designed a CDK project structure that let 12 engineers work in parallel without stepping on each other's toes, maintained the familiar patterns from Serverless Framework, and became the foundation for our $2.8M ARR platform.

Series Navigation:

The Project Structure That Actually Scales#

Our first attempt was a disaster. I naively copied a simple CDK tutorial structure, and within a week we had merge conflicts, unclear ownership, and confused engineers asking "Where does this file go?"

Here's the evolution from chaos to order:

Bash
# 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 (learned the hard way)
├── 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 (our secret sauce)
   ├── production-lambda.ts  # 376 functions use this
   ├── api-with-auth.ts      # Every API follows this pattern
   └── 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

The key insight: Domain-driven organization prevents merge conflicts when 12 engineers work in parallel.

Initializing Your CDK Project#

First, ensure you have the prerequisites:

Bash
# 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:

Bash
# 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 @aws-cdk/aws-lambda-nodejs-alpha @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:

JSON
{
  "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:

TypeScript
// 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: 'alerts@myapp.com',
    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:

TypeScript
// 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,
      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:

TypeScript
// 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:

TypeScript
// 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:

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:

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):

Bash
npm run bootstrap

Deploy to development:

Bash
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):
Bash
# 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:
TypeScript
// 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.

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.

Loading...

Comments (0)

Join the conversation

Sign in to share your thoughts and engage with the community

No comments yet

Be the first to share your thoughts on this post!

Related Posts