AWS CDK

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

Infrastructure in TypeScript or Python: the three construct levels, why cdk diff matters, bootstrapping, escape hatches and CDK versus Terraform.

The CDK lets you define infrastructure in TypeScript, Python, Java, Go or C#, and synthesises it to CloudFormation. You get loops, conditionals, functions, types and unit tests, and deployment remains ordinary CloudFormation, with the same change sets, rollbacks and drift behaviour.

The trade is that an abstraction which generates 400 lines of CloudFormation from 10 lines of TypeScript is wonderful until you need to know exactly what it produced.

A stack

import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigw from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { Construct } from 'constructs';

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

    const table = new dynamodb.Table(this, 'Orders', {
      partitionKey: { name: 'orderId', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    const fn = new lambda.Function(this, 'GetOrder', {
      runtime: lambda.Runtime.PYTHON_3_12,
      architecture: lambda.Architecture.ARM_64,
      handler: 'app.handler',
      code: lambda.Code.fromAsset('src/get_order'),
      timeout: cdk.Duration.seconds(25),
      environment: { TABLE_NAME: table.tableName },
    });

    table.grantReadData(fn);

    const api = new apigw.HttpApi(this, 'OrdersApi');
    api.addRoutes({
      path: '/orders/{id}',
      methods: [apigw.HttpMethod.GET],
      integration: new HttpLambdaIntegration('GetOrderIntegration', fn),
    });

    new cdk.CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
  }
}

The line doing the most work is table.grantReadData(fn). It writes an IAM policy scoped to exactly that table and exactly the read actions, and attaches it to the function's role. Hand-written equivalents are usually broader than they need to be, because getting the action list right is tedious, and the grant* methods are, for many teams, the strongest single argument for the CDK.

Three levels of construct

LevelWhat it isWhen
L1 (CfnTable)1:1 with CloudFormation, no defaultsNew services, or when you need exact control
L2 (Table)Sensible defaults, grant* helpersAlmost always
L3 patternsSeveral resources wired togetherWhen the pattern fits exactly

L2 is the right default. L1 constructs are always available for a service the CDK has not modelled yet, since they are generated from the CloudFormation specification.

L3 patterns such as ApplicationLoadBalancedFargateService create a VPC, cluster, service, load balancer, security groups and log groups from one declaration. They are excellent for prototypes and a liability when you need to change one detail they did not expose, at which point you are reaching for escape hatches to modify something a simple L2 stack would have made explicit.

Bootstrap first

cdk bootstrap aws://123456789012/eu-west-1

This creates the S3 bucket, ECR repository and IAM roles the CDK needs for deployment. Every account and region combination needs it once, and forgetting is the first error everyone hits:

This stack uses assets, so the toolkit stack must be deployed to the
environment (Run "cdk bootstrap aws://123456789012/eu-west-1")

The bootstrap stack is versioned, and an old one causes confusing failures after a CDK upgrade, re-running the command updates it.

cdk diff is the important command

cdk diff
Stack OrdersStack
Resources
[~] AWS::DynamoDB::Table Orders OrdersTable4B1D2A7C replace
 └─ [~] KeySchema (requires replacement)
     ├─ [-] [{"AttributeName":"orderId","KeyType":"HASH"}]
     └─ [+] [{"AttributeName":"customerId","KeyType":"HASH"}]

IAM Statement Changes
┌───┬─────────────────┬────────┬────────────────┬──────────────────┐
│   │ Resource        │ Effect │ Action         │ Principal        │
├───┼─────────────────┼────────┼────────────────┼──────────────────┤
│ + │ ${Orders.Arn}   │ Allow  │ dynamodb:Query │ ${GetOrder/Role} │
└───┴─────────────────┴────────┴────────────────┴──────────────────┘

requires replacement is the line to read before every production deploy, for the same reason as a CloudFormation change set: the resource will be destroyed and recreated, and the deployment will report success.

cdk diff also summarises IAM and security group changes separately, and cdk deploy pauses for confirmation when either changes, which is a good default to leave on.

To see the generated CloudFormation:

cdk synth > template.yaml

Worth doing when a construct behaves unexpectedly. It is the only way to know what was actually created.

Escape hatches

When an L2 construct does not expose a property, drop to the underlying L1 resource rather than abandoning the construct:

const cfnTable = table.node.defaultChild as dynamodb.CfnTable;
cfnTable.addPropertyOverride('SSESpecification.SSEType', 'KMS');
cfnTable.addOverride('Properties.TableClass', 'STANDARD_INFREQUENT_ACCESS');

You can also add raw CloudFormation the CDK has no construct for:

new cdk.CfnResource(this, 'SomeNewService', {
  type: 'AWS::SomeNew::Resource',
  properties: { Name: 'example' },
});

Escape hatches are a feature, not a workaround. They mean the CDK is never a dead end. They are also a signal: needing several on one construct usually means an L1 resource would be clearer.

Removal policy

removalPolicy: cdk.RemovalPolicy.RETAIN,

Defaults differ by resource and are easy to get wrong. A DynamoDB table or S3 bucket defaults to DESTROY in a CDK stack, so cdk destroy on a development stack takes the data with it. Set RETAIN explicitly on anything stateful.

autoDeleteObjects: true on a bucket creates a custom resource that empties it on delete. Convenient for genuinely disposable buckets, and exactly what you do not want in production.

Testing

Real unit tests against infrastructure, which is a genuine advantage over templates:

import { Template, Match } from 'aws-cdk-lib/assertions';

test('table has PITR and is retained', () => {
  const app = new cdk.App();
  const stack = new OrdersStack(app, 'Test');
  const template = Template.fromStack(stack);

  template.hasResourceProperties('AWS::DynamoDB::Table', {
    PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
  });
  template.hasResource('AWS::DynamoDB::Table', {
    DeletionPolicy: 'Retain',
  });
});

test('lambda cannot write to the table', () => {
  const template = Template.fromStack(new OrdersStack(new cdk.App(), 'Test'));
  template.resourceCountIs('AWS::DynamoDB::Table', 1);
  const policies = template.findResources('AWS::IAM::Policy');
  expect(JSON.stringify(policies)).not.toContain('dynamodb:PutItem');
});

Snapshot tests catch unintended changes across a whole stack:

expect(Template.fromStack(stack).toJSON()).toMatchSnapshot();

They are noisy, a CDK version bump changes hashes and logical IDs, but they do surface the thing you did not mean to change.

Aspects for org-wide rules

An Aspect visits every construct in a tree, which is how you enforce a policy across a whole app:

class RequireEncryption implements cdk.IAspect {
  visit(node: Construct): void {
    if (node instanceof s3.CfnBucket && !node.bucketEncryption) {
      cdk.Annotations.of(node).addError('S3 buckets must be encrypted');
    }
  }
}

cdk.Aspects.of(app).add(new RequireEncryption());

addError fails the synth, so the rule is enforced before anything is deployed rather than flagged in review.

CDK, Terraform, or CloudFormation

StrengthWeakness
CDKReal language, grant* IAM, testableAWS-only, generated output is opaque, version churn
TerraformMulti-cloud, explicit, huge provider ecosystemVerbose, state to manage, HCL's limited abstraction
CloudFormationNo extra tooling, nativeNo abstraction, YAML at scale

Pick the CDK when you are AWS-only, your team is comfortable in one of its languages, and IAM correctness matters. Pick Terraform when you manage more than AWS or already have an estate in it. Note also CDK for Terraform, which uses CDK constructs to emit Terraform. Worth knowing exists.

The honest CDK caveats: logical IDs are derived from the construct tree, so renaming or moving a construct destroys and recreates the resource; upgrades between major versions have been disruptive; and debugging sometimes means reading synthesised CloudFormation, which defeats some of the abstraction.

Common errors

This stack uses assets, so the toolkit stack must be deployed

Run cdk bootstrap for that account and region.

Resolution error: Cannot use resource 'X' in a cross-environment fashion

Two stacks in different accounts or regions referencing each other directly. Pass values explicitly or use SSM.

Error: There is already a Construct with name 'Orders' in OrdersStack

Construct IDs must be unique within a scope.

A resource was replaced unexpectedly. A construct was renamed or moved in the tree, changing its logical ID.

Quick reference

TaskCommand
New projectcdk init app --language typescript
Bootstrapcdk bootstrap aws://<acct>/<region>
Previewcdk diff
Generate CloudFormationcdk synth
Deploycdk deploy / --all / --require-approval never
Fast dev loopcdk watch
List stackscdk ls
Destroycdk destroy
Testnpx jest with aws-cdk-lib/assertions

Key takeaways

  • cdk bootstrap once per account and region, before anything else.
  • grant* methods generate least-privilege IAM: the CDK's strongest single feature.
  • L2 constructs by default. L1 for unmodelled services, L3 patterns with care.
  • Read cdk diff for requires replacement before every production deploy.
  • Set removalPolicy: RETAIN on stateful resources; CDK defaults often destroy.
  • Escape hatches mean the CDK is never a dead end: but several on one construct is a smell.
  • Renaming or moving a construct changes its logical ID and replaces the resource.
  • Test with aws-cdk-lib/assertions, and enforce policy with Aspects that fail synth.

Check your understanding

Exercise

A rename that destroyed a table

A developer renames a construct from new dynamodb.Table(this, "Orders", …) to "OrdersTable" for clarity. The deploy succeeds and the table is empty. Why?

Exercise

A property the construct does not expose

An L2 construct does not expose a property that the underlying CloudFormation resource supports. What is the right approach?


Frequently Asked Questions

What is the difference between L1, L2 and L3 constructs?

L1 constructs (CfnBucket, CfnTable) map one-to-one onto CloudFormation resources with no defaults, and are generated from the resource specification so they cover every service immediately. L2 constructs (Bucket, Table) add sensible defaults, type safety and helper methods such as grantRead, and are what you should use by default. L3 constructs are opinionated patterns combining several resources, an entire load-balanced Fargate service from one declaration, which are excellent when the pattern fits and awkward when you need to change something they do not expose.

Do I need to run cdk bootstrap?

Yes, once per account and region combination, before the first deployment there. It creates the S3 bucket, ECR repository and IAM roles the CDK uses to stage assets and execute deployments. Skipping it produces an error explicitly telling you to run it, which makes it a brief rather than mysterious problem. The bootstrap stack is versioned too, so re-run it after a major CDK upgrade. An outdated bootstrap causes confusing failures that look unrelated.

Why did renaming a construct delete my resource?

Because the CloudFormation logical ID is derived from the construct's path in the tree. Changing the construct ID, or moving it under a different parent, produces a different logical ID, so CloudFormation sees a new resource to create and an old one that has vanished from the template, and deletes it. For stateful resources this loses data while the deploy reports success. Always check cdk diff before deploying, and set removalPolicy: RETAIN so the original survives if it happens.

How do I set a property the CDK construct does not support?

Use an escape hatch. Cast the construct's node.defaultChild to its L1 type and call addPropertyOverride('Path.To.Property', value), or addOverride for anything outside Properties. This works for any property the underlying CloudFormation resource accepts, including ones the L2 API has not caught up with, and it keeps the construct's defaults and grant helpers intact. It is a supported pattern rather than a hack, though several overrides on one construct suggests using the L1 resource directly would be clearer.

Should I use CDK or Terraform?

CDK when you are AWS-only, your team is fluent in TypeScript or Python, and you value the grant* methods that generate least-privilege IAM automatically, that last point prevents a whole class of over-permissive policy. Terraform when you manage more than AWS, already have an estate in it, or prefer explicit configuration over generated abstraction. Terraform's state management is more work; CDK's generated output is more opaque when debugging. CDK for Terraform also exists, combining CDK constructs with Terraform providers.

Can I write tests for CDK infrastructure?

Yes, and it is one of the better reasons to choose it. The aws-cdk-lib/assertions module synthesises a stack in memory and lets you assert on the result, hasResourceProperties to check a table has point-in-time recovery, resourceCountIs to catch accidental duplication, or searching the generated IAM policies to prove a function cannot write where it should only read. Snapshot tests catch unintended changes across a whole stack, though they are noisy across CDK version bumps.

Continue Learning

Explore Related Topics

When it breaks

Related Resources