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
Note: CDK synthesizes to CloudFormation under the hood. This means CDK inherits all CloudFormation limits (500 resources per stack, 51 stacks per account per region by default) and CloudFormation's rollback behavior. If you hit stack limits, split your CDK app into multiple stacks.

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
Pro Tip: Use 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"
Pro Tip: Add CDK Nag to your 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.