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.
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.
Related articles
- AWS AWS CloudFormation: Infrastructure as Code Guide
Learn AWS CloudFormation from scratch. Build, update, and manage AWS infrastructure with YAML templates, parameters, outputs, and nested stacks.
- AWS AWS CDK: Infrastructure as TypeScript
Use the AWS Cloud Development Kit to define cloud infrastructure in TypeScript with constructs, stacks, and CloudFormation as the deploy engine.
- DevOps Ansible vs Terraform: Configuration vs Infrastructure
Compare Ansible and Terraform for DevOps automation. Learn when to use each tool, how they differ in approach, and how to combine them effectively.
- DevOps Infrastructure as Code: Terraform Getting Started Guide
Learn Infrastructure as Code with Terraform. This beginner guide covers HCL syntax, providers, state management, modules, and deploying your first cloud resources step by step.