AWS CDK
LearningTree ยท AWS ยท DevOps

AWS CDK โ€”
Cloud Development Kit

Define cloud infrastructure using real programming languages โ€” Python, Java, TypeScript, Go. CDK synthesizes your code into CloudFormation templates and deploys them.

โšก CDK in 30 Seconds

  • Write infrastructure in Python, Java, TypeScript, C#, or Go โ€” real code with loops, conditions, abstractions
  • CDK synthesizes your code into CloudFormation templates (JSON/YAML)
  • Deploys via CloudFormation under the hood โ€” same reliability, rollback, and drift detection
  • Constructs are the building blocks โ€” from low-level (L1: raw CFN) to high-level (L2/L3: opinionated defaults)
  • Free to use โ€” you pay only for the AWS resources deployed
01
Chapter One

What is CDK

Introduction Introductory

The AWS Cloud Development Kit (CDK) is an open-source framework that lets you define AWS infrastructure using familiar programming languages. Instead of writing YAML/JSON templates, you write Python classes, Java objects, or TypeScript code โ€” and CDK converts it into CloudFormation.

๐Ÿ‘‰ Think of CDK as: CloudFormation written in real code โ€” with variables, loops, functions, and IDE support

CDK doesn't replace CloudFormation โ€” it generates CloudFormation. Your CDK code is compiled into a CloudFormation template (called "synthesis"), and then CloudFormation handles the actual deployment. You get the power of a programming language with the reliability of CloudFormation.

Why CDK Over Raw CloudFormation Introductory
โš ๏ธ

CloudFormation Pain Points

  • YAML/JSON at scale becomes verbose (thousands of lines)
  • No loops or conditionals (limited !If)
  • Copy-paste patterns across templates
  • No IDE autocomplete or type checking
  • Hard to abstract reusable components
  • No unit testing for infrastructure
โœ…

CDK Solves

  • Real programming โ€” loops, conditions, inheritance
  • IDE support โ€” autocomplete, type checking, refactoring
  • Constructs โ€” reusable, shareable, composable abstractions
  • L2 constructs have smart defaults (less boilerplate)
  • Unit testing with standard test frameworks (pytest, JUnit)
  • One language for app code AND infrastructure
How CDK Works Core
CDK Workflow โ€” Code to Cloud
CDK CODE Python / Java TypeScript / Go Your IDE cdk synth CFN TEMPLATE Generated YAML cdk deploy CLOUDFORMATION Provisions resources AWS RESOURCES EC2 ยท S3 ยท Lambda VPC ยท RDS ยท SQS Running in AWS CDK synth produces CloudFormation โ†’ CFN deploys (same rollback, drift detection, stack operations)
CDK vs CloudFormation vs Terraform Core
FeatureCloudFormationCDKTerraform
LanguageYAML / JSONPython, Java, TypeScript, Go, C#HCL
AbstractionLow (raw resources)High (L2/L3 constructs with defaults)Medium (modules)
IDE supportLimited (YAML lint)Full โ€” autocomplete, types, refactoringGood (HCL LSP)
TestingNone built-inUnit tests with pytest / JUnit / Jestterraform plan + Terratest
Loops / LogicVery limitedFull language powerLimited (count, for_each)
StateManaged by AWSManaged by AWS (via CFN)Self-managed state file
Multi-cloudAWS onlyAWS only (CDK for Terraform exists)Multi-cloud
Deploy engineCloudFormationCloudFormationTerraform CLI
Best forSimple stacks, AWS-native shopsComplex infra, dev teams, reusable patternsMulti-cloud, platform teams
Supported Languages Introductory
๐Ÿ

Python

  • Most popular CDK language
  • Great for data/ML teams
  • pip install aws-cdk-lib
  • Fast prototyping
โ˜•

Java

  • Enterprise teams
  • Strong typing + Maven/Gradle
  • Same language as backend
  • Best IDE support (IntelliJ)
๐Ÿ“˜

TypeScript

  • CDK is written in TypeScript
  • Best documentation coverage
  • Full-stack JS/TS teams
  • npm install aws-cdk-lib
Hello CDK โ€” First Look Introductory

Here's what a simple S3 bucket looks like in CDK vs CloudFormation:

CloudFormation (YAML)

Resources:
  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-app-bucket
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

CDK Python (4 lines)

from aws_cdk import aws_s3 as s3

s3.Bucket(self, "MyBucket",
    bucket_name="my-app-bucket",
    versioned=True,
    encryption=s3.BucketEncryption.S3_MANAGED,
    block_public_access=s3.BlockPublicAccess.BLOCK_ALL
)

L2 construct โ€” encryption + public access block are smart defaults (even simpler with no props = secure by default)

๐Ÿ‘‰ The CDK L2 Bucket construct is secure by default โ€” if you write just s3.Bucket(self, "MyBucket") with no properties, it automatically enables encryption and blocks public access. CloudFormation requires you to explicitly set every property.

CDK CLI Commands Core
CommandPurposeEquivalent
cdk initScaffold a new CDK projectโ€”
cdk synthSynthesize CDK code โ†’ CloudFormation templateGenerate YAML
cdk diffShow what will change (like a Change Set preview)CFN Change Set
cdk deployDeploy stack to AWS (synth + create/update CFN stack)aws cloudformation deploy
cdk destroyDelete the stack and all resourcesaws cloudformation delete-stack
cdk bootstrapOne-time setup โ€” creates CDK staging S3 bucket + IAM rolesโ€”
cdk watchHot-deploy on file change (dev mode)โ€”
๐Ÿ‘‰ Key Takeaway

CDK lets you write infrastructure in real programming languages (Python, Java, TypeScript) and synthesizes it to CloudFormation. You get IDE autocomplete, type safety, loops, abstractions, and unit testing โ€” while CloudFormation handles the actual deployment with its proven reliability.

02
Chapter Two

Core Concepts โ€” Apps, Stacks & Constructs

The CDK Architecture Core

CDK organises infrastructure into a tree hierarchy: App โ†’ Stack(s) โ†’ Construct(s).

CDK Hierarchy โ€” App โ†’ Stacks โ†’ Constructs
APP (cdk.App) STACK: NetworkStack STACK: AppStack VPC Subnets NAT GW Lambda API GW DynamoDB IAM Role Stage + Route Each construct can contain child constructs โ€” forming a tree. L2 constructs auto-create supporting resources (IAM, logs).
ConceptWhat It IsMaps To
AppThe root of the CDK tree. Contains one or more stacks. Entry point for synthesis.The overall CDK project
StackA deployable unit = one CloudFormation stack. Contains constructs.CloudFormation stack
ConstructA cloud component (one resource or a composition of many). The fundamental building block.One or more CloudFormation resources
Construct Levels Core

CDK organises constructs into three levels of abstraction:

๐Ÿ”ง

L1 โ€” CFN Resources

  • Direct 1:1 mapping to CloudFormation
  • Prefix: Cfn* (e.g., CfnBucket)
  • No smart defaults
  • All properties required
  • Use when L2 doesn't exist
โšก

L2 โ€” Curated Constructs

  • Higher-level, opinionated wrappers
  • E.g., s3.Bucket, ec2.Vpc
  • Smart defaults (secure, best practices)
  • Helper methods (.grantRead())
  • Most commonly used level
๐Ÿ—๏ธ

L3 โ€” Patterns

  • Multi-resource architectures in one construct
  • E.g., LambdaRestApi = Lambda + API GW + IAM
  • Entire application patterns
  • AWS Solutions Constructs library
  • Highest abstraction, least flexibility
App & Stack โ€” Python Core

๐Ÿ Python โ€” CDK App Structure

# app.py โ€” Entry point
import aws_cdk as cdk
from my_app.network_stack import NetworkStack
from my_app.app_stack import AppStack

app = cdk.App()

# Deploy to specific account/region
env = cdk.Environment(account="123456789012", region="us-east-1")

network = NetworkStack(app, "NetworkStack", env=env)
AppStack(app, "AppStack", vpc=network.vpc, env=env)

app.synth()

๐Ÿ Python โ€” Stack Definition

# my_app/network_stack.py
from aws_cdk import Stack
from aws_cdk import aws_ec2 as ec2
from constructs import Construct

class NetworkStack(Stack):

    def __init__(self, scope: Construct, id: str, **kwargs):
        super().__init__(scope, id, **kwargs)

        # L2 Construct: VPC with best-practice defaults
        self.vpc = ec2.Vpc(self, "MainVpc",
            max_azs=2,
            nat_gateways=1,
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name="Public",
                    subnet_type=ec2.SubnetType.PUBLIC,
                    cidr_mask=24
                ),
                ec2.SubnetConfiguration(
                    name="Private",
                    subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
                    cidr_mask=24
                ),
            ]
        )
        # One line โ†’ creates VPC + 4 subnets + IGW + NAT + route tables
App & Stack โ€” Java Core

โ˜• Java โ€” CDK App Structure

// src/main/tech/com/myapp/CdkApp.java
package com.myapp;

import software.amazon.awscdk.App;
import software.amazon.awscdk.Environment;
import software.amazon.awscdk.StackProps;

public class CdkApp {
    public static void main(String[] args) {
        App app = new App();

        Environment env = Environment.builder()
            .account("123456789012")
            .region("us-east-1")
            .build();

        StackProps props = StackProps.builder().env(env).build();

        NetworkStack network = new NetworkStack(app, "NetworkStack", props);
        new AppStack(app, "AppStack", props, network.getVpc());

        app.synth();
    }
}

โ˜• Java โ€” Stack Definition

// src/main/tech/com/myapp/NetworkStack.java
package com.myapp;

import software.amazon.awscdk.Stack;
import software.amazon.awscdk.StackProps;
import software.amazon.awscdk.services.ec2.*;
import software.constructs.Construct;
import java.util.List;

public class NetworkStack extends Stack {

    private final Vpc vpc;

    public NetworkStack(Construct scope, String id, StackProps props) {
        super(scope, id, props);

        // L2 Construct: VPC with best-practice defaults
        this.vpc = Vpc.Builder.create(this, "MainVpc")
            .maxAzs(2)
            .natGateways(1)
            .subnetConfiguration(List.of(
                SubnetConfiguration.builder()
                    .name("Public")
                    .subnetType(SubnetType.PUBLIC)
                    .cidrMask(24)
                    .build(),
                SubnetConfiguration.builder()
                    .name("Private")
                    .subnetType(SubnetType.PRIVATE_WITH_EGRESS)
                    .cidrMask(24)
                    .build()
            ))
            .build();
        // One call โ†’ VPC + 4 subnets + IGW + NAT + route tables
    }

    public Vpc getVpc() { return vpc; }
}
Construct Composition In-Depth

The power of CDK is composition โ€” you build complex infrastructure by nesting constructs. A single L2 construct like Vpc generates 15+ CloudFormation resources automatically. You can also create your own reusable constructs.

๐Ÿ

Python โ€” Custom Construct

class SecureApi(Construct):
    """Reusable: API GW + Lambda + WAF"""

    def __init__(self, scope, id, *,
                 handler_path: str,
                 stage: str):
        super().__init__(scope, id)

        fn = _lambda.Function(self, "Handler",
            runtime=_lambda.Runtime.PYTHON_3_12,
            code=_lambda.Code.from_asset(handler_path),
            handler="index.handler",
        )

        self.api = apigateway.LambdaRestApi(
            self, "Endpoint",
            handler=fn,
            deploy_options={"stage_name": stage}
        )
โ˜•

Java โ€” Custom Construct

public class SecureApi extends Construct {

    private final LambdaRestApi api;

    public SecureApi(Construct scope, String id,
                     String handlerPath, String stage) {
        super(scope, id);

        Function fn = Function.Builder.create(this, "Handler")
            .runtime(Runtime.PYTHON_3_12)
            .code(Code.fromAsset(handlerPath))
            .handler("index.handler")
            .build();

        this.api = LambdaRestApi.Builder
            .create(this, "Endpoint")
            .handler(fn)
            .deployOptions(StageOptions.builder()
                .stageName(stage).build())
            .build();
    }

    public LambdaRestApi getApi() { return api; }
}

Now use your custom construct in any stack:

๐Ÿ Python Usage

# In any stack:
api = SecureApi(self, "OrdersApi",
    handler_path="lambda/orders",
    stage="prod"
)

โ˜• Java Usage

// In any stack:
SecureApi api = new SecureApi(
    this, "OrdersApi",
    "lambda/orders", "prod"
);
Grant Methods โ€” Permissions Made Easy Core

One of CDK's killer features: L2 constructs expose .grant*() methods that automatically create the correct IAM policies โ€” no manual policy writing.

๐Ÿ

Python

# Lambda needs to read from S3 bucket
bucket = s3.Bucket(self, "DataBucket")
fn = _lambda.Function(self, "Processor", ...)

# ONE LINE โ€” creates IAM policy automatically
bucket.grant_read(fn)

# Other grant methods:
# bucket.grant_read_write(fn)
# table.grant_read_data(fn)
# queue.grant_send_messages(fn)
# topic.grant_publish(fn)
โ˜•

Java

// Lambda needs to read from S3 bucket
Bucket bucket = Bucket.Builder.create(this, "DataBucket")
    .build();
Function fn = Function.Builder.create(this, "Processor")
    ...
    .build();

// ONE LINE โ€” creates IAM policy automatically
bucket.grantRead(fn);

// Other grant methods:
// bucket.grantReadWrite(fn);
// table.grantReadData(fn);
// queue.grantSendMessages(fn);
// topic.grantPublish(fn);

๐Ÿ‘‰ Without CDK, you'd write a 15-line IAM policy document with the correct S3 actions (s3:GetObject, s3:ListBucket), the bucket ARN, and the objects ARN (arn:aws:s3:::bucket/*). With CDK: bucket.grant_read(fn) does all of this automatically with least-privilege.

Bootstrapping Core

cdk bootstrap is a one-time setup command that prepares your AWS account for CDK deployments. It creates a CloudFormation stack called CDKToolkit containing:

๐Ÿ“ฆ

What Bootstrap Creates

  • S3 bucket โ€” stores synthesized templates and assets (Lambda zips, Docker images)
  • ECR repository โ€” stores Docker images for container constructs
  • IAM roles โ€” deployment, file publishing, lookup roles
  • SSM parameter โ€” bootstrap version tracking
โš™๏ธ

When to Run

  • Once per account + region combination
  • Before the first cdk deploy
  • Re-run when CDK updates the bootstrap template
  • Cross-account deploys need bootstrap in target account
Project Structure Core
๐Ÿ

Python Project

my-cdk-app/
โ”œโ”€โ”€ app.py              # Entry point
โ”œโ”€โ”€ cdk.json            # CDK config
โ”œโ”€โ”€ requirements.txt    # Dependencies
โ”œโ”€โ”€ my_app/
โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”œโ”€โ”€ network_stack.py
โ”‚   โ””โ”€โ”€ app_stack.py
โ””โ”€โ”€ tests/
    โ””โ”€โ”€ test_app_stack.py
โ˜•

Java Project

my-cdk-app/
โ”œโ”€โ”€ cdk.json            # CDK config
โ”œโ”€โ”€ pom.xml             # Maven deps
โ”œโ”€โ”€ src/main/tech/com/myapp/
โ”‚   โ”œโ”€โ”€ CdkApp.java    # Entry point
โ”‚   โ”œโ”€โ”€ NetworkStack.java
โ”‚   โ””โ”€โ”€ AppStack.java
โ””โ”€โ”€ src/test/tech/com/myapp/
    โ””โ”€โ”€ AppStackTest.java
๐Ÿ‘‰ Key Takeaway

CDK organises infra as App โ†’ Stacks โ†’ Constructs. L1 = raw CFN, L2 = smart defaults + grant methods, L3 = full patterns. Composition lets you build reusable custom constructs. Grant methods eliminate manual IAM policy writing. Bootstrap once per account/region before first deploy.

03
Chapter Three

Building Stacks

Serverless API โ€” Lambda + API Gateway + DynamoDB Core

A common serverless pattern: API Gateway โ†’ Lambda โ†’ DynamoDB. In CDK, this is just a few constructs wired together with grant methods handling all IAM.

๐Ÿ

Python

from aws_cdk import Stack, RemovalPolicy
from aws_cdk import aws_lambda as _lambda
from aws_cdk import aws_apigateway as apigw
from aws_cdk import aws_dynamodb as dynamodb
from constructs import Construct

class ServerlessApiStack(Stack):

    def __init__(self, scope: Construct, id: str, **kwargs):
        super().__init__(scope, id, **kwargs)

        # DynamoDB table
        table = dynamodb.Table(self, "Orders",
            partition_key=dynamodb.Attribute(
                name="orderId",
                type=dynamodb.AttributeType.STRING
            ),
            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
            removal_policy=RemovalPolicy.DESTROY,
        )

        # Lambda function
        handler = _lambda.Function(self, "OrderHandler",
            runtime=_lambda.Runtime.PYTHON_3_12,
            code=_lambda.Code.from_asset("lambda"),
            handler="orders.handler",
            environment={
                "TABLE_NAME": table.table_name,
            },
        )

        # Grant Lambda read/write access to DynamoDB
        table.grant_read_write_data(handler)

        # API Gateway
        api = apigw.RestApi(self, "OrdersApi",
            rest_api_name="Orders Service",
        )
        orders = api.root.add_resource("orders")
        orders.add_method("GET", apigw.LambdaIntegration(handler))
        orders.add_method("POST", apigw.LambdaIntegration(handler))
โ˜•

Java

import software.amazon.awscdk.*;
import software.amazon.awscdk.services.lambda.*;
import software.amazon.awscdk.services.apigateway.*;
import software.amazon.awscdk.services.dynamodb.*;
import software.constructs.Construct;
import java.util.Map;

public class ServerlessApiStack extends Stack {

    public ServerlessApiStack(Construct scope, String id,
                              StackProps props) {
        super(scope, id, props);

        // DynamoDB table
        Table table = Table.Builder.create(this, "Orders")
            .partitionKey(Attribute.builder()
                .name("orderId")
                .type(AttributeType.STRING).build())
            .billingMode(BillingMode.PAY_PER_REQUEST)
            .removalPolicy(RemovalPolicy.DESTROY)
            .build();

        // Lambda function
        Function handler = Function.Builder.create(this, "OrderHandler")
            .runtime(Runtime.PYTHON_3_12)
            .code(Code.fromAsset("lambda"))
            .handler("orders.handler")
            .environment(Map.of(
                "TABLE_NAME", table.getTableName()
            ))
            .build();

        // Grant Lambda read/write to DynamoDB
        table.grantReadWriteData(handler);

        // API Gateway
        RestApi api = RestApi.Builder.create(this, "OrdersApi")
            .restApiName("Orders Service")
            .build();
        Resource orders = api.getRoot().addResource("orders");
        orders.addMethod("GET", new LambdaIntegration(handler));
        orders.addMethod("POST", new LambdaIntegration(handler));
    }
}

๐Ÿ‘‰ What CDK generated behind the scenes: ~25 CloudFormation resources โ€” Lambda function + execution role + policy, API Gateway + deployment + stage + methods, DynamoDB table, Lambda permission for API GW. You wrote ~40 lines of code.

Web App โ€” VPC + ALB + ECS Fargate In-Depth
๐Ÿ

Python โ€” ECS Fargate Service

from aws_cdk import Stack
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_ecs as ecs
from aws_cdk import aws_ecs_patterns as patterns
from constructs import Construct

class WebAppStack(Stack):

    def __init__(self, scope: Construct, id: str, **kwargs):
        super().__init__(scope, id, **kwargs)

        vpc = ec2.Vpc(self, "AppVpc", max_azs=2)

        cluster = ecs.Cluster(self, "Cluster", vpc=vpc)

        # L3 Pattern: ALB + Fargate in ONE construct
        service = patterns.ApplicationLoadBalancedFargateService(
            self, "WebService",
            cluster=cluster,
            cpu=256,
            memory_limit_mib=512,
            desired_count=2,
            task_image_options=patterns.ApplicationLoadBalancedTaskImageOptions(
                image=ecs.ContainerImage.from_asset("./docker"),
                container_port=8080,
            ),
            public_load_balancer=True,
        )

        # Auto-scaling
        scaling = service.service.auto_scale_task_count(
            min_capacity=2, max_capacity=10
        )
        scaling.scale_on_cpu_utilization("CpuScale",
            target_utilization_percent=70
        )
โ˜•

Java โ€” ECS Fargate Service

import software.amazon.awscdk.*;
import software.amazon.awscdk.services.ec2.*;
import software.amazon.awscdk.services.ecs.*;
import software.amazon.awscdk.services.ecs.patterns.*;
import software.constructs.Construct;

public class WebAppStack extends Stack {

    public WebAppStack(Construct scope, String id, StackProps props) {
        super(scope, id, props);

        Vpc vpc = Vpc.Builder.create(this, "AppVpc")
            .maxAzs(2).build();

        Cluster cluster = Cluster.Builder.create(this, "Cluster")
            .vpc(vpc).build();

        // L3 Pattern: ALB + Fargate in ONE construct
        ApplicationLoadBalancedFargateService service =
            ApplicationLoadBalancedFargateService.Builder
                .create(this, "WebService")
                .cluster(cluster)
                .cpu(256)
                .memoryLimitMiB(512)
                .desiredCount(2)
                .taskImageOptions(
                    ApplicationLoadBalancedTaskImageOptions.builder()
                        .image(ContainerImage.fromAsset("./docker"))
                        .containerPort(8080)
                        .build())
                .publicLoadBalancer(true)
                .build();

        // Auto-scaling
        ScalableTaskCount scaling = service.getService()
            .autoScaleTaskCount(EnableScalingProps.builder()
                .minCapacity(2).maxCapacity(10).build());
        scaling.scaleOnCpuUtilization("CpuScale",
            CpuUtilizationScalingProps.builder()
                .targetUtilizationPercent(70).build());
    }
}

The L3 pattern ApplicationLoadBalancedFargateService creates: VPC, ALB, target group, ECS cluster, task definition, Fargate service, security groups, IAM roles, CloudWatch log group โ€” all wired together with correct permissions.

Environment Configuration Core

CDK uses context values and environment-specific configurations to customize deployments:

๐Ÿ

Python โ€” Environment Config

# app.py
app = cdk.App()

# Read context from cdk.json or CLI
env_name = app.node.try_get_context("env") or "dev"

config = {
    "dev": {
        "instance_type": "t3.micro",
        "min_capacity": 1,
        "max_capacity": 2,
    },
    "prod": {
        "instance_type": "t3.large",
        "min_capacity": 2,
        "max_capacity": 20,
    },
}

AppStack(app, f"AppStack-{env_name}",
    config=config[env_name],
    env=cdk.Environment(
        account="123456789012",
        region="us-east-1"
    )
)
# Deploy: cdk deploy -c env=prod
โ˜•

Java โ€” Environment Config

// CdkApp.java
App app = new App();
String envName = (String) app.getNode()
    .tryGetContext("env");
if (envName == null) envName = "dev";

Map<String, Map<String, Object>> config = Map.of(
    "dev", Map.of(
        "instanceType", "t3.micro",
        "minCapacity", 1,
        "maxCapacity", 2
    ),
    "prod", Map.of(
        "instanceType", "t3.large",
        "minCapacity", 2,
        "maxCapacity", 20
    )
);

new AppStack(app, "AppStack-" + envName,
    StackProps.builder()
        .env(Environment.builder()
            .account("123456789012")
            .region("us-east-1").build())
        .build(),
    config.get(envName)
);
// Deploy: cdk deploy -c env=prod
Assets โ€” Bundling Code & Docker In-Depth

CDK assets are local files (Lambda code, Docker images) that CDK automatically uploads to S3/ECR during deployment.

Asset TypeWhat It DoesExample
Code.from_asset("dir")Zips a directory and uploads to S3 for LambdaLambda function code from local folder
ContainerImage.from_asset("dir")Builds Docker image locally, pushes to ECRECS/Fargate container from local Dockerfile
BundlingOptionsRun a build step (pip install, mvn package) before uploadInstall Python deps into Lambda zip
๐Ÿ‘‰ Key Takeaway

CDK excels at full-stack deployments โ€” from serverless (Lambda + API GW + DynamoDB) to containerised (ECS Fargate + ALB). L3 patterns encapsulate entire architectures in one construct. Context values and environment maps enable multi-environment deployments from a single codebase. Assets handle code bundling and Docker builds automatically.

04
Chapter Four

Patterns & Testing

Unit Testing Infrastructure Core

One of CDK's biggest advantages: you can unit test your infrastructure using the same testing frameworks as your application code. CDK provides assertion libraries to verify the synthesized CloudFormation template.

๐Ÿ

Python โ€” pytest

# tests/test_app_stack.py
import aws_cdk as cdk
from aws_cdk.assertions import Template, Match
from my_app.serverless_api_stack import ServerlessApiStack

def test_dynamodb_table_created():
    app = cdk.App()
    stack = ServerlessApiStack(app, "TestStack")
    template = Template.from_stack(stack)

    # Assert DynamoDB table exists
    template.has_resource_properties(
        "AWS::DynamoDB::Table", {
            "KeySchema": [{
                "AttributeName": "orderId",
                "KeyType": "HASH",
            }],
            "BillingMode": "PAY_PER_REQUEST",
        }
    )

def test_lambda_has_env_vars():
    app = cdk.App()
    stack = ServerlessApiStack(app, "TestStack")
    template = Template.from_stack(stack)

    template.has_resource_properties(
        "AWS::Lambda::Function", {
            "Environment": {
                "Variables": {
                    "TABLE_NAME": Match.any_value(),
                }
            },
            "Runtime": "python3.12",
        }
    )

def test_api_gateway_exists():
    app = cdk.App()
    stack = ServerlessApiStack(app, "TestStack")
    template = Template.from_stack(stack)

    template.resource_count_is("AWS::ApiGateway::RestApi", 1)

# Run: pytest tests/
โ˜•

Java โ€” JUnit 5

// src/test/tech/com/myapp/ServerlessApiStackTest.java
import org.junit.jupiter.api.Test;
import software.amazon.awscdk.App;
import software.amazon.awscdk.assertions.Template;
import software.amazon.awscdk.assertions.Match;
import java.util.Map;

class ServerlessApiStackTest {

    @Test
    void dynamoDbTableCreated() {
        App app = new App();
        ServerlessApiStack stack = new ServerlessApiStack(
            app, "TestStack", null);
        Template template = Template.fromStack(stack);

        template.hasResourceProperties(
            "AWS::DynamoDB::Table",
            Map.of(
                "KeySchema", java.util.List.of(
                    Map.of("AttributeName", "orderId",
                           "KeyType", "HASH")),
                "BillingMode", "PAY_PER_REQUEST"
            )
        );
    }

    @Test
    void lambdaHasEnvironmentVars() {
        App app = new App();
        ServerlessApiStack stack = new ServerlessApiStack(
            app, "TestStack", null);
        Template template = Template.fromStack(stack);

        template.hasResourceProperties(
            "AWS::Lambda::Function",
            Map.of(
                "Runtime", "python3.12",
                "Environment", Match.objectLike(
                    Map.of("Variables",
                        Match.objectLike(Map.of(
                            "TABLE_NAME", Match.anyValue()
                        ))
                    )
                )
            )
        );
    }

    @Test
    void apiGatewayExists() {
        App app = new App();
        ServerlessApiStack stack = new ServerlessApiStack(
            app, "TestStack", null);
        Template template = Template.fromStack(stack);

        template.resourceCountIs(
            "AWS::ApiGateway::RestApi", 1);
    }
}
// Run: mvn test
Snapshot Testing In-Depth

Snapshot tests capture the entire synthesized template and compare it against a stored baseline. Any change to the infrastructure produces a diff โ€” catching unintended modifications.

๐Ÿ

Python Snapshot

def test_snapshot(snapshot):
    app = cdk.App()
    stack = ServerlessApiStack(app, "TestStack")
    template = Template.from_stack(stack)

    # Compare against stored snapshot
    assert template.to_json() == snapshot
โ˜•

Java Snapshot

@Test
void snapshotTest() {
    App app = new App();
    ServerlessApiStack stack = new ServerlessApiStack(
        app, "TestStack", null);
    Template template = Template.fromStack(stack);

    // Match against stored snapshot
    template.templateMatches(
        Match.objectEquals(expectedTemplate));
}
CDK Pipelines โ€” CI/CD In-Depth

CDK Pipelines is a construct that creates a self-mutating CI/CD pipeline for your CDK app. The pipeline automatically updates itself when you change the pipeline definition โ€” and deploys your infrastructure through stages.

CDK Pipelines โ€” Self-Mutating Deployment Pipeline
SOURCE Git push CodeCommit BUILD cdk synth + tests SELF-UPDATE Pipeline mutates itself if changed STAGING Deploy stack Integration tests Auto โœ“ APPROVE Manual gate PRODUCTION Deploy stack Smoke tests Live โœ“ Self-mutating: if you change the pipeline code itself, it updates the pipeline first, then deploys your app
๐Ÿ

Python โ€” CDK Pipeline

from aws_cdk import Stack
from aws_cdk.pipelines import CodePipeline, CodeBuildStep
from aws_cdk.pipelines import CodePipelineSource
from constructs import Construct

class PipelineStack(Stack):
    def __init__(self, scope, id, **kwargs):
        super().__init__(scope, id, **kwargs)

        pipeline = CodePipeline(self, "Pipeline",
            synth=CodeBuildStep("Synth",
                input=CodePipelineSource.git_hub(
                    "my-org/my-repo", "main"),
                commands=[
                    "pip install -r requirements.txt",
                    "npx cdk synth",
                ]
            )
        )

        # Add stages
        pipeline.add_stage(StagingStage(self, "Staging"))
        pipeline.add_stage(ProdStage(self, "Prod"),
            pre=[
                pipelines.ManualApprovalStep("Approve")
            ]
        )
โ˜•

Java โ€” CDK Pipeline

import software.amazon.awscdk.*;
import software.amazon.awscdk.pipelines.*;
import software.constructs.Construct;
import java.util.List;

public class PipelineStack extends Stack {
    public PipelineStack(Construct scope, String id,
                         StackProps props) {
        super(scope, id, props);

        CodePipeline pipeline = CodePipeline.Builder
            .create(this, "Pipeline")
            .synth(CodeBuildStep.Builder.create("Synth")
                .input(CodePipelineSource.gitHub(
                    "my-org/my-repo", "main"))
                .commands(List.of(
                    "mvn package",
                    "npx cdk synth"
                ))
                .build())
            .build();

        // Add stages
        pipeline.addStage(new StagingStage(this, "Staging"));
        pipeline.addStage(new ProdStage(this, "Prod"),
            AddStageOpts.builder()
                .pre(List.of(
                    new ManualApprovalStep("Approve")))
                .build());
    }
}
Escape Hatches In-Depth

When an L2 construct doesn't expose a property you need, escape hatches let you access the underlying L1 (CFN) resource and modify it directly.

๐Ÿ

Python

# Access the underlying CFN resource
bucket = s3.Bucket(self, "MyBucket")

# Escape hatch: get the L1 CfnBucket
cfn_bucket = bucket.node.default_child

# Modify a property not exposed by L2
cfn_bucket.add_property_override(
    "AnalyticsConfigurations", [{
        "Id": "FullAnalytics",
        "StorageClassAnalysis": {
            "DataExport": {
                "Destination": {...}
            }
        }
    }]
)
โ˜•

Java

// Access the underlying CFN resource
Bucket bucket = Bucket.Builder.create(this, "MyBucket")
    .build();

// Escape hatch: get the L1 CfnBucket
CfnBucket cfnBucket = (CfnBucket) bucket.getNode()
    .getDefaultChild();

// Modify a property not exposed by L2
cfnBucket.addPropertyOverride(
    "AnalyticsConfigurations",
    List.of(Map.of(
        "Id", "FullAnalytics",
        "StorageClassAnalysis", Map.of(...)
    ))
);
๐Ÿ‘‰ Key Takeaway

CDK enables real unit testing of infrastructure (assertions + snapshots). CDK Pipelines create self-mutating CI/CD โ€” the pipeline updates itself before deploying your app. Escape hatches let you access L1 resources when L2 constructs don't expose what you need.

05
Chapter Five

Best Practices

Stack Design Principles Core
โœ…

Do

  • One stack per lifecycle โ€” network, data, and app stacks deploy independently
  • Use L2 constructs โ€” they enforce best practices (encryption, IAM least privilege)
  • Use grant methods โ€” never write IAM policies manually
  • Tag everything โ€” cdk.Tags.of(stack).add("Team", "orders")
  • Use RemovalPolicy.RETAIN on databases and S3 buckets in prod
  • Write tests โ€” at minimum, assertion tests for critical resources
  • Use CDK Pipelines for production deployments
โš ๏ธ

Avoid

  • God stacks โ€” everything in one stack (blast radius, slow deploys)
  • Hardcoded account/region โ€” use environment variables or context
  • Ignoring cdk diff โ€” always review before deploy
  • Custom IAM policies when grant methods exist
  • L1 constructs when L2 exists (lose smart defaults)
  • Skipping bootstrap โ€” leads to confusing deploy errors
  • Mutable infrastructure โ€” don't SSH in and change things manually
Construct Library Publishing In-Depth

Create reusable construct libraries that your team or organisation can share โ€” like internal npm/PyPI/Maven packages for infrastructure patterns.

LanguagePackage ManagerPublish Command
PythonPyPI / CodeArtifacttwine upload dist/*
JavaMaven Central / CodeArtifactmvn deploy
TypeScriptnpm / CodeArtifactnpm publish

๐Ÿ‘‰ Projen is a tool that generates and maintains CDK construct library project configuration. Use it to standardise multi-language construct libraries with consistent builds, tests, and publishing.

CDK vs Terraform โ€” Decision Framework Core
ScenarioBest ChoiceWhy
AWS-only, dev team owns infraCDKSame language as app, testing, IDE support
Multi-cloud (AWS + Azure + GCP)TerraformSingle tool for all providers
Platform team serving many app teamsTerraformHCL is consistent, state management mature
Complex logic (loops, conditionals, inheritance)CDKReal programming language vs HCL limits
Strict compliance / policy-as-codeTerraform + SentinelMature policy engine
Rapid prototyping / serverlessCDKL3 patterns deploy full architectures fast
Existing CloudFormation investmentCDKImports existing CFN, same engine underneath
Migrating from CloudFormation In-Depth
๐Ÿ“‹

Approach 1: Import Existing Stack

  • Use CfnInclude to import existing CFN template into CDK
  • Gradually replace L1 resources with L2 constructs
  • No need to recreate resources โ€” same stack continues
  • Allows incremental migration
๐Ÿ”„

Approach 2: Green-Field Rewrite

  • Write new CDK stacks alongside existing CFN
  • Migrate resources between stacks using import/export
  • Delete old CFN stack once migration complete
  • Best for major architecture refactoring
Common Mistakes Introductory
MistakeImpactFix
Changing construct IDResource replacement (data loss for DBs!)Never rename construct IDs after first deploy
Not running cdk diffSurprise replacements in productionAlways diff before deploy; use CDK Pipelines
Using L1 when L2 existsLose secure defaults, more codeCheck if L2 construct is available first
Forgetting RemovalPolicyStack delete = data goneremoval_policy=RemovalPolicy.RETAIN for prod data
Too many stacksDeployment ordering complexityBalance: split by lifecycle, not too granular
No testsBroken infra caught only at deploy timeWrite assertion tests; run in CI before deploy
Ignoring bootstrap versionDeploy failures in new regions/accountsRe-run cdk bootstrap when upgrading CDK versions

AWS CDK โ€” Complete Summary

  • CDK โ€” write infrastructure in Python/Java/TypeScript, synthesize to CloudFormation, deploy with proven CFN engine.
  • Constructs โ€” L1 (raw CFN), L2 (smart defaults + grants), L3 (full patterns like ALB+Fargate).
  • Architecture โ€” App โ†’ Stacks โ†’ Constructs. One stack = one CloudFormation stack.
  • Grant methods โ€” bucket.grant_read(fn) eliminates manual IAM policy writing.
  • Testing โ€” Unit test synthesized templates with assertions (pytest/JUnit). Snapshot testing for regression.
  • CDK Pipelines โ€” self-mutating CI/CD. Source โ†’ Build โ†’ Self-Update โ†’ Deploy staging โ†’ Approve โ†’ Deploy prod.
  • Escape hatches โ€” access L1 resources via node.default_child when L2 doesn't expose a property.
  • Best practices โ€” split stacks by lifecycle, use L2 constructs, grant methods for IAM, RemovalPolicy for data, test before deploy.
๐Ÿ‘‰ Key Takeaway

CDK is the most developer-friendly IaC tool for AWS โ€” same language as your app, full IDE support, unit testable infrastructure, and opinionated L2/L3 constructs that enforce best practices. Use CDK Pipelines for production, always run cdk diff, and never change construct IDs after first deployment.