AWS CDK: Infrastructure as Code with TypeScript/Python (2026)
The AWS Cloud Development Kit (CDK) lets you define cloud infrastructure using real programming languages — TypeScript, Python, Java, Go, or C#. Instead of writing hundreds of lines of YAML or JSON, you write type-safe, composable code with IDE autocomplete, unit tests, and reusable abstractions. This guide covers everything from first cdk init to production-ready CDK Pipelines.
CDK vs CloudFormation vs Terraform
Choosing an IaC tool is a real decision with real tradeoffs. Here's how CDK compares to the two most common alternatives:
| Feature | AWS CDK | CloudFormation (raw) | Terraform |
|---|---|---|---|
| Language | TypeScript, Python, Java, Go, C# | YAML / JSON | HCL (HashiCorp Config Language) |
| IDE support | Full autocomplete, type checking | Schema-based, limited | Good via Terraform LSP |
| Abstraction level | High (L2/L3 hide boilerplate) | Low (every property explicit) | Medium (resources map 1:1) |
| AWS coverage | 100% (L1 maps all of CloudFormation) | 100% | ~95%, lags new services by weeks/months |
| Multi-cloud | AWS only (CDK for Terraform exists separately) | AWS only | Yes — AWS, GCP, Azure, 1000+ providers |
| State management | CloudFormation manages state | CloudFormation manages state | State file (S3 + DynamoDB lock) |
| Unit testing | Native (Jest/pytest assertions on synth output) | cfn-lint, limited | Terratest (Go-based) |
| Community constructs | Construct Hub (constructs.dev) | CloudFormation registry | Terraform Registry (massive) |
| Learning curve | Low for developers, high for ops-first teams | Medium (YAML verbosity) | Medium (HCL is learnable) |
| Best for | AWS-first, dev teams who know TypeScript/Python | Simple stacks, existing CFN templates | Multi-cloud, mixed infra teams |
L1 / L2 / L3 Constructs Explained
CDK's abstraction system has three levels, each building on the last:
L1 Constructs (Cfn* classes) are auto-generated, 1:1 mappings to CloudFormation resource types. Every property from CloudFormation is available. They're verbose but give you complete control:
import { aws_s3 as s3 } from 'aws-cdk-lib';
// L1: CfnBucket — every CFN property is explicit
const bucket = new s3.CfnBucket(this, 'MyBucket', {
bucketName: 'my-app-bucket',
versioningConfiguration: {
status: 'Enabled'
},
lifecycleConfiguration: {
rules: [{
id: 'move-to-ia',
status: 'Enabled',
transitions: [{
storageClass: 'STANDARD_IA',
transitionInDays: 30
}]
}]
}
});
L2 Constructs are hand-crafted, opinionated wrappers around L1 constructs. They apply AWS best practices by default, generate IAM policies automatically, and expose intent-based methods like .grantRead():
// L2: Bucket — sensible defaults, helper methods
const bucket = new s3.Bucket(this, 'MyBucket', {
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: RemovalPolicy.DESTROY, // for dev only
});
// Grant read access — L2 automatically creates the right IAM policy
bucket.grantRead(myLambdaFunction);
bucket.grantReadWrite(myEcsTaskRole);
L3 Constructs (Patterns) combine multiple L2 constructs into complete, opinionated architectures. The aws_ecs_patterns module is a good example — it creates an ECS Service, Application Load Balancer, security groups, and IAM roles with a single construct:
import { aws_ecs_patterns as ecsPatterns } from 'aws-cdk-lib';
// L3 Pattern: creates ALB + ECS Service + security groups + IAM roles
const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(
this, 'MyService', {
cluster,
cpu: 256,
memoryLimitMiB: 512,
taskImageOptions: {
image: ecs.ContainerImage.fromRegistry('nginx:latest'),
},
desiredCount: 2,
publicLoadBalancer: true,
}
);
Getting Started: Init, Bootstrap, Synth, Deploy
Install the CDK CLI and initialize a new project:
# Install CDK CLI globally
npm install -g aws-cdk
# Verify installation
cdk --version
# Initialize a TypeScript project
mkdir my-infra && cd my-infra
cdk init app --language typescript
# For Python
cdk init app --language python
source .venv/bin/activate # Linux/Mac
pip install -r requirements.txt
Before first deploy, bootstrap your account. Bootstrapping creates an S3 bucket (for assets), ECR repository (for Docker images), and IAM roles that CDK uses during deployment:
# Bootstrap a single account/region
cdk bootstrap aws://123456789012/us-east-1
# Bootstrap multiple regions
cdk bootstrap aws://123456789012/us-east-1 aws://123456789012/eu-west-1
# Bootstrap with a custom qualifier (useful for multiple CDK deployments in same account)
cdk bootstrap --qualifier myapp aws://123456789012/us-east-1
Core CDK commands you'll use daily:
# List all stacks in the app
cdk ls
# Synthesize CloudFormation template (no AWS calls)
cdk synth
# Show diff between deployed stack and local code
cdk diff
# Deploy a specific stack
cdk deploy MyStack
# Deploy all stacks
cdk deploy --all
# Deploy with auto-approval (for CI)
cdk deploy --require-approval never --all
# Destroy a stack
cdk destroy MyStack
# Watch mode: auto-redeploy on code changes (dev only)
cdk watch MyStack
cdk synth in CI before cdk deploy — it catches synthesis errors without making any AWS API calls. Store the synthesized CloudFormation template as a CI artifact for audit trails.Real Example: VPC with Public and Private Subnets
This creates a production-ready VPC with public/private subnets across 3 AZs, NAT Gateways, and VPC Flow Logs:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
aws_ec2 as ec2,
aws_logs as logs,
} from 'aws-cdk-lib';
export class NetworkStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'AppVpc', {
maxAzs: 3,
natGateways: 1, // Set to 3 for HA, 1 for cost savings in dev
ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
subnetConfiguration: [
{
cidrMask: 24,
name: 'public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 24,
name: 'private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
{
cidrMask: 28,
name: 'isolated',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
],
});
// VPC Flow Logs — logs all network traffic to CloudWatch
const flowLogGroup = new logs.LogGroup(this, 'VpcFlowLogs', {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
this.vpc.addFlowLog('FlowLog', {
destination: ec2.FlowLogDestination.toCloudWatchLogs(flowLogGroup),
trafficType: ec2.FlowLogTrafficType.REJECT, // Only log rejected traffic to save cost
});
// VPC Endpoints — free S3 and DynamoDB access from private subnets
this.vpc.addGatewayEndpoint('S3Endpoint', {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
this.vpc.addGatewayEndpoint('DynamoDbEndpoint', {
service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
});
// Output VPC ID
new cdk.CfnOutput(this, 'VpcId', { value: this.vpc.vpcId });
}
}
Real Example: ECS Fargate Service with ALB
This stack creates an ECS Fargate service with an Application Load Balancer, auto-scaling, and proper security group isolation:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
aws_ec2 as ec2,
aws_ecs as ecs,
aws_ecs_patterns as ecsPatterns,
aws_ecr as ecr,
aws_logs as logs,
} from 'aws-cdk-lib';
interface AppServiceStackProps extends cdk.StackProps {
vpc: ec2.Vpc;
environment: 'prod' | 'staging' | 'dev';
}
export class AppServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: AppServiceStackProps) {
super(scope, id, props);
const { vpc, environment } = props;
const isProd = environment === 'prod';
// ECS Cluster
const cluster = new ecs.Cluster(this, 'Cluster', {
vpc,
clusterName: `app-cluster-${environment}`,
containerInsights: isProd, // Extra cost, worth it in prod
});
// ECR Repository
const repo = ecr.Repository.fromRepositoryName(
this, 'AppRepo', 'my-app'
);
// Log group for container logs
const logGroup = new logs.LogGroup(this, 'AppLogs', {
logGroupName: `/ecs/app-${environment}`,
retention: isProd ? logs.RetentionDays.ONE_MONTH : logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// ALB + Fargate Service (L3 Pattern)
const service = new ecsPatterns.ApplicationLoadBalancedFargateService(
this, 'AppService', {
cluster,
cpu: isProd ? 512 : 256,
memoryLimitMiB: isProd ? 1024 : 512,
desiredCount: isProd ? 2 : 1,
taskImageOptions: {
image: ecs.ContainerImage.fromEcrRepository(repo, 'latest'),
containerPort: 8080,
logDriver: ecs.LogDrivers.awsLogs({
streamPrefix: 'app',
logGroup,
}),
environment: {
ENVIRONMENT: environment,
LOG_LEVEL: isProd ? 'INFO' : 'DEBUG',
},
},
taskSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
publicLoadBalancer: true,
listenerPort: 443,
}
);
// Auto-scaling: scale on CPU utilization
const scaling = service.service.autoScaleTaskCount({
minCapacity: isProd ? 2 : 1,
maxCapacity: isProd ? 10 : 2,
});
scaling.scaleOnCpuUtilization('CpuScaling', {
targetUtilizationPercent: 70,
scaleInCooldown: cdk.Duration.seconds(60),
scaleOutCooldown: cdk.Duration.seconds(60),
});
// Health check configuration
service.targetGroup.configureHealthCheck({
path: '/health',
healthyHttpCodes: '200',
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
});
new cdk.CfnOutput(this, 'ServiceUrl', {
value: service.loadBalancer.loadBalancerDnsName,
});
}
}
Real Example: S3 Bucket with Lifecycle Rules
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { aws_s3 as s3, aws_kms as kms } from 'aws-cdk-lib';
export class StorageStack extends cdk.Stack {
public readonly dataBucket: s3.Bucket;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// KMS key for server-side encryption
const encryptionKey = new kms.Key(this, 'BucketKey', {
alias: 'app/s3-data',
enableKeyRotation: true,
});
this.dataBucket = new s3.Bucket(this, 'DataBucket', {
encryption: s3.BucketEncryption.KMS,
encryptionKey,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: true,
enforceSSL: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
lifecycleRules: [
{
id: 'transition-old-versions',
enabled: true,
noncurrentVersionTransitions: [
{
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
transitionAfter: cdk.Duration.days(30),
},
{
storageClass: s3.StorageClass.GLACIER,
transitionAfter: cdk.Duration.days(90),
},
],
noncurrentVersionExpiration: cdk.Duration.days(365),
},
{
id: 'abort-incomplete-mpu',
enabled: true,
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
},
{
id: 'transition-logs',
enabled: true,
prefix: 'logs/',
transitions: [
{
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
transitionAfter: cdk.Duration.days(30),
},
{
storageClass: s3.StorageClass.GLACIER_INSTANT_RETRIEVAL,
transitionAfter: cdk.Duration.days(90),
},
],
expiration: cdk.Duration.days(730),
},
],
});
}
}
Writing a Custom L2 Construct
Custom constructs are where CDK shines. You can build reusable, organization-wide infrastructure patterns that encode your standards:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
aws_lambda as lambda,
aws_logs as logs,
aws_iam as iam,
} from 'aws-cdk-lib';
export interface SecureLambdaProps {
readonly code: lambda.Code;
readonly handler: string;
readonly runtime: lambda.Runtime;
readonly description: string;
readonly environment?: Record;
readonly timeout?: cdk.Duration;
readonly memorySize?: number;
}
/**
* Custom L2 construct: Lambda function with opinionated security defaults.
* Enforces: dead-letter queue, X-Ray tracing, reserved concurrency baseline,
* structured log group with 30-day retention.
*/
export class SecureLambda extends Construct {
public readonly function: lambda.Function;
constructor(scope: Construct, id: string, props: SecureLambdaProps) {
super(scope, id);
const logGroup = new logs.LogGroup(this, 'LogGroup', {
logGroupName: `/aws/lambda/${id}`,
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
this.function = new lambda.Function(this, 'Function', {
...props,
timeout: props.timeout ?? cdk.Duration.seconds(30),
memorySize: props.memorySize ?? 256,
tracing: lambda.Tracing.ACTIVE, // X-Ray tracing always on
logGroup,
insightsVersion: lambda.LambdaInsightsVersion.VERSION_1_0_119_0,
reservedConcurrentExecutions: 100, // prevent runaway invocations
});
// Deny all internet access by default (apply your VPC config separately)
// Deny use of deprecated runtimes via policy
this.function.addToRolePolicy(new iam.PolicyStatement({
effect: iam.Effect.DENY,
actions: ['lambda:InvokeFunction'],
resources: ['*'],
conditions: {
StringEquals: { 'lambda:Principal': 'apigateway.amazonaws.com' },
Null: { 'lambda:SourceArn': 'true' }, // Require source ARN to prevent confused deputy
},
}));
}
}
CDK Pipelines for CI/CD
CDK Pipelines is a high-level construct for self-mutating CI/CD pipelines. "Self-mutating" means the pipeline updates itself when you change the pipeline definition — no manual pipeline updates needed.
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { pipelines } from 'aws-cdk-lib';
export class PipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
pipelineName: 'AppPipeline',
synth: new pipelines.ShellStep('Synth', {
input: pipelines.CodePipelineSource.gitHub(
'my-org/my-repo',
'main',
{
authentication: cdk.SecretValue.secretsManager('github-token'),
}
),
commands: [
'npm ci',
'npm run build',
'npm run test',
'npx cdk synth',
],
}),
// Enable Docker for Lambda bundling
dockerEnabledForSynth: true,
});
// Staging stage
const staging = pipeline.addStage(
new AppStage(this, 'Staging', {
env: { account: '111111111111', region: 'us-east-1' },
environment: 'staging',
})
);
// Add integration tests before promoting to prod
staging.addPost(
new pipelines.ShellStep('IntegrationTests', {
envFromCfnOutputs: {
SERVICE_URL: staging.serviceUrl, // CfnOutput from AppStack
},
commands: [
'npm run test:integration',
'curl -f $SERVICE_URL/health',
],
})
);
// Production stage with manual approval
pipeline.addStage(
new AppStage(this, 'Production', {
env: { account: '222222222222', region: 'us-east-1' },
environment: 'prod',
}),
{
pre: [new pipelines.ManualApprovalStep('ApproveProd')],
}
);
}
}
CDK Nag: Security and Compliance Checks
CDK Nag applies security rule packs to your CDK app during synthesis. It checks against AWS Solutions Security Best Practices, NIST 800-53, and PCI DSS rules. Use it in CI to catch security misconfigurations before deploy:
import { App, Aspects } from 'aws-cdk-lib';
import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag';
const app = new App();
const stack = new MyStack(app, 'MyStack');
// Apply the AWS Solutions rule pack
Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true }));
// Suppress specific rules with justification (these appear in audit logs)
NagSuppressions.addStackSuppressions(stack, [
{
id: 'AwsSolutions-S1',
reason: 'Server access logs disabled intentionally for cost — CloudTrail covers S3 object-level access',
},
{
id: 'AwsSolutions-EC23',
reason: 'Port 443 open to 0.0.0.0/0 is required for the public-facing load balancer',
},
]);
Install cdk-nag and run checks:
npm install cdk-nag
# Synth will now fail if security rules are violated
cdk synth 2>&1 | grep -E "\[Error\]|\[Warning\]"
# Run nag checks only (no deploy)
npx cdk synth --quiet 2>&1 | grep "AwsSolutions"
bin/app.ts entry point so it runs on every cdk synth locally AND in CI. Treat [Error] findings as pipeline blockers and [Warning] as required justification. This creates an audit trail of every conscious security decision.FAQ: AWS CDK
Q: When should I use CDK vs Terraform for a new project?
If your team is AWS-only and knows TypeScript or Python, CDK is the better choice — the L2 constructs save enormous amounts of boilerplate, and the type system catches configuration mistakes before you deploy. If you need multi-cloud support, have an existing Terraform team, or are deploying non-AWS resources (e.g., Datadog monitors, PagerDuty schedules, GitHub Actions), Terraform wins due to its provider ecosystem. Many teams use both: CDK for AWS-native infrastructure, Terraform for everything else.
Q: What happens if I rename a CDK construct or move it between stacks?
This is the most common CDK footgun. CDK generates CloudFormation logical IDs from the construct tree path. Renaming a construct or moving it to a different parent generates a new logical ID, which causes CloudFormation to delete the old resource and create a new one. For stateful resources (RDS, DynamoDB, S3), this is catastrophic. To rename safely: use overrideLogicalId() to pin the logical ID, or use CfnResource.overrideLogicalId() on the underlying L1. For databases, always pin logical IDs from day one.
Q: How do I share resources between CDK stacks?
Use CfnOutput in the producing stack and Fn.importValue() in the consuming stack — but be aware that cross-stack references in the same CDK app are automatically handled if you pass the construct directly. CDK creates an export/import pair in CloudFormation automatically. For cross-account or cross-region sharing, use SSM Parameter Store to store ARNs and retrieve them in the consuming stack. Avoid direct cross-stack references between independently deployed stacks — they create hidden dependencies that prevent stack deletion.
Q: How do I run CDK in CI/CD without storing AWS credentials?
Use OIDC (OpenID Connect) federation. GitHub Actions has native support: create an IAM role that trusts token.actions.githubusercontent.com, attach the CDK deployment policies, and use the aws-actions/configure-aws-credentials action with role-to-assume. This gives short-lived credentials scoped to the specific repo and branch, with no long-term access keys stored anywhere. AWS recommends this approach over IAM user access keys for all CI/CD systems.
Q: How do I unit-test CDK infrastructure code?
Use CDK's built-in assertions library. After synthesizing the app, use Template.fromStack() to get the CloudFormation template and assert on it with hasResourceProperties(), resourceCountIs(), and hasOutput(). Example: assert that every S3 bucket has versioning enabled, that Lambda functions have X-Ray tracing on, or that security groups don't allow unrestricted inbound. These tests run in milliseconds (no AWS calls) and catch regressions instantly.