Skip to content
Codeloom
AWS

AWS CDK: Infrastructure in TypeScript Getting Started

Get started with AWS CDK using TypeScript. Define cloud infrastructure with constructs, stacks, and deploy real AWS resources with familiar programming patterns.

·10 min read · By Codeloom
Intermediate 18 min read

What you'll learn

  • Set up an AWS CDK project with TypeScript from scratch
  • Define infrastructure using L1, L2, and L3 constructs
  • Deploy VPCs, Lambda functions, and API Gateway with CDK
  • Test infrastructure code with CDK assertions

Prerequisites

  • Node.js 18+ and npm installed
  • AWS account with CLI configured
  • Basic TypeScript familiarity

AWS Cloud Development Kit (CDK) lets you define cloud infrastructure using real programming languages instead of YAML or JSON templates. You write TypeScript (or Python, Java, Go, C#), and CDK synthesizes it into CloudFormation templates. This means you get loops, conditionals, type checking, IDE autocomplete, and the ability to create reusable abstractions, things that are painful or impossible in raw CloudFormation.

This guide takes you from zero to deploying real infrastructure with CDK in TypeScript.

Setting Up Your First CDK Project

Install the CDK CLI globally and create a new project:

# Install CDK CLI
npm install -g aws-cdk

# Verify installation
cdk --version

# Create a new CDK project
mkdir my-infrastructure && cd my-infrastructure
cdk init app --language typescript

# Bootstrap your AWS account (one-time setup per account/region)
cdk bootstrap aws://123456789012/us-east-1

Bootstrapping creates the resources CDK needs to deploy, including an S3 bucket for assets and IAM roles for deployment.

The generated project structure:

my-infrastructure/
  bin/
    my-infrastructure.ts      # App entry point
  lib/
    my-infrastructure-stack.ts # Your infrastructure definition
  test/
    my-infrastructure.test.ts  # Infrastructure tests
  cdk.json                     # CDK configuration
  tsconfig.json                # TypeScript configuration
  package.json                 # Dependencies

Understanding Constructs

Constructs are the building blocks of CDK. They come in three levels:

L1 Constructs (Cfn) are direct CloudFormation mappings. They start with Cfn and require you to configure every property manually, just like writing CloudFormation YAML.

L2 Constructs are the sweet spot. They provide sensible defaults, helper methods, and a higher-level API. Most of your code will use L2 constructs.

L3 Constructs (Patterns) combine multiple resources into common architectures. For example, LambdaRestApi creates an API Gateway, Lambda function, and all the plumbing between them.

import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cdk from 'aws-cdk-lib';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';

// L1: Low level, verbose, full control
const cfnBucket = new CfnBucket(this, 'L1Bucket', {
  bucketName: 'my-l1-bucket',
  versioningConfiguration: {
    status: 'Enabled',
  },
  bucketEncryption: {
    serverSideEncryptionConfiguration: [{
      serverSideEncryptionByDefault: {
        sseAlgorithm: 'AES256',
      },
    }],
  },
});

// L2: Higher level, sensible defaults, much less code
const bucket = new s3.Bucket(this, 'L2Bucket', {
  versioned: true,
  encryption: s3.BucketEncryption.S3_MANAGED,
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});

// L3: Pre-built patterns (shown later with LambdaRestApi)

Your First Real Stack

Replace the generated stack with a practical example that creates a VPC, Lambda function, and DynamoDB table:

// lib/my-infrastructure-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

export class MyInfrastructureStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC with public and private subnets
    const vpc = new ec2.Vpc(this, 'AppVpc', {
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        {
          name: 'Public',
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 24,
        },
        {
          name: 'Private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
          cidrMask: 24,
        },
      ],
    });

    // DynamoDB table
    const table = new dynamodb.Table(this, 'ItemsTable', {
      tableName: 'items',
      partitionKey: {
        name: 'pk',
        type: dynamodb.AttributeType.STRING,
      },
      sortKey: {
        name: 'sk',
        type: dynamodb.AttributeType.STRING,
      },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      pointInTimeRecovery: true,
    });

    // Lambda function
    const handler = new lambda.Function(this, 'ItemsHandler', {
      runtime: lambda.Runtime.PYTHON_3_12,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda'),
      memorySize: 256,
      timeout: cdk.Duration.seconds(30),
      environment: {
        TABLE_NAME: table.tableName,
      },
      logRetention: logs.RetentionDays.TWO_WEEKS,
      tracing: lambda.Tracing.ACTIVE,
    });

    // Grant the Lambda function read/write access to the table
    table.grantReadWriteData(handler);

    // API Gateway
    const api = new apigateway.RestApi(this, 'ItemsApi', {
      restApiName: 'Items Service',
      deployOptions: {
        stageName: 'prod',
        throttlingBurstLimit: 100,
        throttlingRateLimit: 50,
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
      },
    });

    const items = api.root.addResource('items');
    const lambdaIntegration = new apigateway.LambdaIntegration(handler);
    items.addMethod('GET', lambdaIntegration);
    items.addMethod('POST', lambdaIntegration);

    const item = items.addResource('{id}');
    item.addMethod('GET', lambdaIntegration);
    item.addMethod('PUT', lambdaIntegration);
    item.addMethod('DELETE', lambdaIntegration);

    // Outputs
    new cdk.CfnOutput(this, 'ApiUrl', {
      value: api.url,
      description: 'API Gateway URL',
    });

    new cdk.CfnOutput(this, 'TableName', {
      value: table.tableName,
      description: 'DynamoDB table name',
    });
  }
}

The table.grantReadWriteData(handler) line is where CDK shines. It automatically creates the correct IAM policy with the right permissions scoped to the specific table and attaches it to the Lambda function’s execution role. In CloudFormation, this would be 15-20 lines of IAM policy YAML.

The Lambda Function Code

Create the Lambda function in a lambda directory:

mkdir lambda
# lambda/index.py
import json
import os
import boto3
from datetime import datetime

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])

def handler(event, context):
    method = event['httpMethod']
    path_params = event.get('pathParameters') or {}

    try:
        if method == 'GET' and 'id' not in path_params:
            return list_items()
        elif method == 'GET' and 'id' in path_params:
            return get_item(path_params['id'])
        elif method == 'POST':
            body = json.loads(event['body'])
            return create_item(body)
        elif method == 'PUT':
            body = json.loads(event['body'])
            return update_item(path_params['id'], body)
        elif method == 'DELETE':
            return delete_item(path_params['id'])
        else:
            return response(404, {'message': 'Not found'})
    except Exception as e:
        return response(500, {'message': str(e)})

def list_items():
    result = table.scan(Limit=100)
    return response(200, {'items': result['Items']})

def get_item(item_id):
    result = table.get_item(Key={'pk': f'ITEM#{item_id}', 'sk': 'METADATA'})
    if 'Item' not in result:
        return response(404, {'message': 'Item not found'})
    return response(200, result['Item'])

def create_item(body):
    import uuid
    item_id = str(uuid.uuid4())
    item = {
        'pk': f'ITEM#{item_id}',
        'sk': 'METADATA',
        'id': item_id,
        'name': body['name'],
        'createdAt': datetime.utcnow().isoformat(),
    }
    table.put_item(Item=item)
    return response(201, item)

def update_item(item_id, body):
    table.update_item(
        Key={'pk': f'ITEM#{item_id}', 'sk': 'METADATA'},
        UpdateExpression='SET #n = :name, updatedAt = :ts',
        ExpressionAttributeNames={'#n': 'name'},
        ExpressionAttributeValues={
            ':name': body['name'],
            ':ts': datetime.utcnow().isoformat(),
        },
    )
    return response(200, {'id': item_id, **body})

def delete_item(item_id):
    table.delete_item(Key={'pk': f'ITEM#{item_id}', 'sk': 'METADATA'})
    return response(204, None)

def response(status_code, body):
    return {
        'statusCode': status_code,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
        },
        'body': json.dumps(body, default=str) if body else '',
    }

Multi-Environment Deployments

Use CDK context and stack props to deploy to different environments:

// bin/my-infrastructure.ts
import * as cdk from 'aws-cdk-lib';
import { MyInfrastructureStack } from '../lib/my-infrastructure-stack';

const app = new cdk.App();

const environment = app.node.tryGetContext('environment') || 'dev';

const envConfig: Record<string, { account: string; region: string }> = {
  dev: { account: '123456789012', region: 'us-east-1' },
  prod: { account: '987654321098', region: 'us-east-1' },
};

new MyInfrastructureStack(app, `MyApp-${environment}`, {
  env: envConfig[environment],
  environment: environment,
});

Update the stack to accept environment configuration:

// lib/my-infrastructure-stack.ts
interface MyStackProps extends cdk.StackProps {
  environment: string;
}

export class MyInfrastructureStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    const isProd = props.environment === 'prod';

    const vpc = new ec2.Vpc(this, 'AppVpc', {
      maxAzs: isProd ? 3 : 2,
      natGateways: isProd ? 3 : 1,
    });

    const table = new dynamodb.Table(this, 'ItemsTable', {
      tableName: `items-${props.environment}`,
      partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
      billingMode: isProd
        ? dynamodb.BillingMode.PROVISIONED
        : dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: isProd
        ? cdk.RemovalPolicy.RETAIN
        : cdk.RemovalPolicy.DESTROY,
    });

    if (isProd) {
      table.autoScaleReadCapacity({ minCapacity: 5, maxCapacity: 100 });
      table.autoScaleWriteCapacity({ minCapacity: 5, maxCapacity: 100 });
    }
  }
}

Deploy to different environments:

# Deploy to dev
cdk deploy --context environment=dev

# Deploy to production
cdk deploy --context environment=prod

Creating Reusable Constructs

Encapsulate common patterns into reusable constructs:

// lib/constructs/secure-bucket.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

interface SecureBucketProps {
  bucketName?: string;
  expirationDays?: number;
}

export class SecureBucket extends Construct {
  public readonly bucket: s3.Bucket;

  constructor(scope: Construct, id: string, props: SecureBucketProps = {}) {
    super(scope, id);

    this.bucket = new s3.Bucket(this, 'Bucket', {
      bucketName: props.bucketName,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: true,
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
      lifecycleRules: props.expirationDays
        ? [{ expiration: cdk.Duration.days(props.expirationDays) }]
        : [],
      serverAccessLogsPrefix: 'access-logs/',
    });
  }
}

// Usage in a stack:
const logsBucket = new SecureBucket(this, 'LogsBucket', {
  expirationDays: 90,
});

This ensures every bucket in your organization has encryption, public access blocking, SSL enforcement, and versioning by default.

CDK Diff and Deploy

Always review changes before deploying:

# Synthesize CloudFormation template (see what CDK generates)
cdk synth

# Compare against deployed stack
cdk diff

# Deploy (with approval prompt for security-sensitive changes)
cdk deploy

# Deploy without approval prompt (CI/CD)
cdk deploy --require-approval never

# Deploy specific stacks
cdk deploy MyApp-dev MyApp-staging

# Destroy a stack
cdk destroy MyApp-dev

The cdk diff output shows exactly which resources will be added, modified, or deleted, including whether changes require replacement.

Testing Infrastructure Code

CDK supports unit testing with the assertions library:

// test/my-infrastructure.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { MyInfrastructureStack } from '../lib/my-infrastructure-stack';

describe('MyInfrastructureStack', () => {
  let template: Template;

  beforeAll(() => {
    const app = new cdk.App();
    const stack = new MyInfrastructureStack(app, 'TestStack', {
      environment: 'dev',
    });
    template = Template.fromStack(stack);
  });

  test('creates a DynamoDB table with PAY_PER_REQUEST billing', () => {
    template.hasResourceProperties('AWS::DynamoDB::Table', {
      BillingMode: 'PAY_PER_REQUEST',
      KeySchema: [
        { AttributeName: 'pk', KeyType: 'HASH' },
        { AttributeName: 'sk', KeyType: 'RANGE' },
      ],
    });
  });

  test('Lambda function has correct runtime and timeout', () => {
    template.hasResourceProperties('AWS::Lambda::Function', {
      Runtime: 'python3.12',
      Timeout: 30,
      MemorySize: 256,
      TracingConfig: {
        Mode: 'Active',
      },
    });
  });

  test('VPC has correct number of subnets', () => {
    template.resourceCountIs('AWS::EC2::Subnet', 4); // 2 AZs x 2 types
  });

  test('Lambda has permission to access DynamoDB table', () => {
    template.hasResourceProperties('AWS::IAM::Policy', {
      PolicyDocument: Match.objectLike({
        Statement: Match.arrayWith([
          Match.objectLike({
            Action: Match.arrayWith([
              'dynamodb:BatchGetItem',
              'dynamodb:GetItem',
              'dynamodb:PutItem',
            ]),
            Effect: 'Allow',
          }),
        ]),
      }),
    });
  });

  test('API Gateway has throttling configured', () => {
    template.hasResourceProperties('AWS::ApiGateway::Stage', {
      MethodSettings: Match.arrayWith([
        Match.objectLike({
          ThrottlingBurstLimit: 100,
          ThrottlingRateLimit: 50,
        }),
      ]),
    });
  });
});

Run tests:

npm test

Testing infrastructure code catches misconfigurations before they reach AWS. Add these tests to your CI/CD pipeline alongside your application tests.

CDK Pipelines: CI/CD for Infrastructure

CDK Pipelines automates the deployment of CDK apps through a self-mutating CI/CD pipeline:

import * as cdk from 'aws-cdk-lib';
import { CodePipeline, ShellStep, CodePipelineSource } from 'aws-cdk-lib/pipelines';

export class PipelineStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const pipeline = new CodePipeline(this, 'Pipeline', {
      pipelineName: 'MyAppPipeline',
      synth: new ShellStep('Synth', {
        input: CodePipelineSource.gitHub('myorg/myrepo', 'main'),
        commands: [
          'npm ci',
          'npm run build',
          'npm test',
          'npx cdk synth',
        ],
      }),
    });

    // Deploy to dev first
    pipeline.addStage(new MyAppStage(this, 'Dev', {
      env: { account: '123456789012', region: 'us-east-1' },
      environment: 'dev',
    }));

    // Deploy to prod with manual approval
    pipeline.addStage(new MyAppStage(this, 'Prod', {
      env: { account: '987654321098', region: 'us-east-1' },
      environment: 'prod',
    }), {
      pre: [new cdk.pipelines.ManualApprovalStep('PromoteToProd')],
    });
  }
}

Wrapping Up

AWS CDK transforms infrastructure management from writing YAML templates to writing real code with type safety, IDE support, and reusable abstractions. Start with L2 constructs for the right balance of simplicity and control. Use CDK’s grant methods to handle IAM automatically. Create custom constructs to encode your organization’s best practices. Test your infrastructure with the assertions library. And use cdk diff before every deployment to understand exactly what will change. The learning curve is minimal if you already know TypeScript, and the productivity gains over raw CloudFormation are significant from day one.