The AWS Cloud Development Kit lets you define infrastructure in TypeScript, JavaScript, Python, Java, C# or Go and deploy it through CloudFormation. A few lines can produce a VPC, a load balancer and a container service with sensible defaults and least-privilege IAM. That leverage is real, and it is also why CDK surprises teams: the program you write is not what gets deployed. It builds an in-memory model, the model is synthesised into templates and assets, and CloudFormation does the deploying.

This article follows that path from construct to resource, then covers bootstrapping, environments, testing, pipelines and the failure modes that cause most CDK incidents, with a worked example throughout. It covers CDK v2; v1 reached end of support in June 2023.

Advertisement

Two parts: the library and the toolkit

CDK appTypeScript, Python, ...Construct treeApp > Stage > Stack > ...cdk synthtokens, aspects, validationCloud assembly (cdk.out)templates, assets, manifestAsset publishingS3 files, ECR imagesBootstrap stackbucket, repo, rolesCloudFormationchange set, rollbackAWS resourcesper stack and environmentintodeploy roleYour program only builds a model and writes files. CloudFormation, assuming bootstrap roles, changes the account.
From CDK program to AWS resources: synthesis produces a cloud assembly; CloudFormation deploys it using the bootstrap resources.

CDK has two parts. The Construct Library, published as aws-cdk-lib together with the base constructs package, is what your code imports. The Toolkit is what runs it: the cdk command-line tool and, more recently, a programmatic Toolkit Library for driving synthesis and deployment from your own code.

Since February 2025 the two are released independently. The CLI moved to its own repository and a new version line starting at 2.1000.0, while the library continued its existing 2.x line (2.174 onward). The newest CLI supports every library version released before it, so the rule is simple: keep the CLI at least as new as the library, and do not expect their version numbers to match.

The construct tree and logical IDs

Everything in CDK is a construct: an object with a scope (its parent) and an id unique among its siblings. The root is an App; below it are optional Stages that group stacks for an environment, then Stacks, which each become one CloudFormation stack, then the constructs that make up your resources. The path of a construct, such as Ingest-Prod/Parser/ServiceRole, identifies it within the app.

Constructs come at three levels. L1 constructs, named Cfn*, map one-to-one onto CloudFormation resource types and are generated from the CloudFormation specification. L2 constructs such as s3.Bucket wrap an L1 with defaults and methods such as grantRead. L3 constructs, often called patterns, assemble several resources into a working unit, like the load-balanced Fargate service used in ECS on Fargate deployments.

The CloudFormation logical ID of each resource is derived from its construct path, with a hash suffix to guarantee uniqueness. This is the most important fact in day-to-day CDK work. Rename a construct id, move a construct under a different parent, or wrap existing constructs in a new construct, and the logical ID changes. To CloudFormation, a changed logical ID is a delete of the old resource and a create of a new one. For a stateless Lambda function that is merely noisy; for a database, a bucket or a DynamoDB table, it can mean data loss or a failed deployment because the physical name is already taken.

Advertisement

A worked example

An ingestion service receives files into an S3 bucket, a queue feeds a parser function, and failures go to a dead-letter queue. The same stack class is instantiated for a development and a production account.

import { App, Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';

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

    const bucket = new s3.Bucket(this, 'Raw', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: true,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    const dlq = new sqs.Queue(this, 'IngestDlq', { retentionPeriod: Duration.days(14) });
    const queue = new sqs.Queue(this, 'IngestQueue', {
      visibilityTimeout: Duration.seconds(180),
      deadLetterQueue: { queue: dlq, maxReceiveCount: 5 },
    });

    const fn = new lambda.Function(this, 'Parser', {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/parser'),   // becomes a file asset
      timeout: Duration.seconds(30),
      environment: { BUCKET: bucket.bucketName },     // a token until deploy
    });

    fn.addEventSource(new SqsEventSource(queue, { batchSize: 10 }));
    bucket.grantReadWrite(fn);                        // least-privilege IAM policy
  }
}

const app = new App();
new IngestStack(app, 'Ingest-Dev',  { env: { account: '111111111111', region: 'eu-west-1' } });
new IngestStack(app, 'Ingest-Prod', { env: { account: '222222222222', region: 'eu-west-1' } });

Several CDK ideas are visible here. grantReadWrite generates an IAM policy scoped to this bucket and attaches it to the function's role, which is the main way CDK achieves least privilege without hand-written JSON; see the IAM article for what those statements mean. Code.fromAsset turns a local directory into an asset that will be zipped, hashed and uploaded. bucket.bucketName is not a string yet: the bucket's name is generated at deploy time, so CDK represents it as a token that synthesises to a CloudFormation reference. And RemovalPolicy.RETAIN keeps the bucket if the stack or the construct is deleted, which is what you want for anything holding data.

Because values like bucket.bucketName are tokens, ordinary program logic cannot inspect them. A condition such as comparing the name to a string compares a placeholder, not the real value. Decide on configuration values in your program and pass them in; use CloudFormation conditions or custom resources only for values that truly exist only at deploy time.

Synthesis and the cloud assembly

cdk synth runs your program, then walks the construct tree in phases: it applies Aspects, runs validation, resolves tokens into CloudFormation intrinsic functions, and writes the cloud assembly to cdk.out. The assembly contains one template per stack, a manifest describing stacks, environments and dependencies, and asset manifests listing every file and container image to publish, keyed by content hash.

Assets are how application code travels with infrastructure. A file asset is zipped and uploaded to the bootstrap bucket; a Docker image asset is built and pushed to the bootstrap repository. The template refers to them by hash, so an unchanged asset is not uploaded again and an unchanged hash means CloudFormation sees no change. Non-deterministic builds break this: if bundling embeds timestamps, every deployment updates every function.

Synthesis is also where you catch problems cheaply. Nothing touches AWS during synth, so it is safe to run in any CI job, and the generated templates are what you should review, test and diff.

Bootstrapping, environments and context

Before deploying to an account and region, you bootstrap it. cdk bootstrap deploys a stack named CDKToolkit that holds a staging S3 bucket, an ECR repository and a set of IAM roles: a deployment role the CLI assumes, file and image publishing roles for assets, a lookup role for reading context, and a CloudFormation execution role that actually creates resources. Resource names include a qualifier, hnb659fds by default, which you can change to run several independent bootstraps in one account.

# once per account and region, from an admin session
cdk bootstrap aws://222222222222/eu-west-1 \
  --trust 333333333333 \
  --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

cdk synth Ingest-Prod        # writes cdk.out; no AWS changes
cdk diff  Ingest-Prod        # template and IAM changes against the deployed stack
cdk deploy Ingest-Prod       # publishes assets, then creates and executes a change set

--trust lets a separate tooling account assume the deployment roles, which is how cross-account pipelines work. The execution policy decides what CloudFormation may create; AdministratorAccess is the default in examples, and hardening it to what your stacks need is one of the highest-value security changes you can make, because anyone who can deploy can otherwise create anything.

Stacks can be environment-agnostic or bound to an explicit account and region through env. Only bound stacks can use lookups such as Vpc.fromLookup, which query the account during synthesis. The results are cached in cdk.context.json. Commit that file: it makes synthesis deterministic, so a later change in the account, such as a new subnet, does not silently change your templates. Refresh a cached value deliberately with cdk context when you want the new state.

Testing and policy with assertions and Aspects

Because the output of a CDK program is a template, you can unit-test infrastructure without deploying. The assertions module synthesises a stack and lets you check resources and properties.

import { App } from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { IngestStack } from '../lib/ingest-stack';

test('bucket is private and encrypted, queue has a DLQ', () => {
  const app = new App();
  const t = Template.fromStack(new IngestStack(app, 'Test'));

  t.hasResourceProperties('AWS::S3::Bucket', {
    PublicAccessBlockConfiguration: { BlockPublicAcls: true, RestrictPublicBuckets: true },
  });
  t.hasResourceProperties('AWS::SQS::Queue', {
    RedrivePolicy: Match.objectLike({ maxReceiveCount: 5 }),
  });
  t.resourceCountIs('AWS::Lambda::Function', 1);
});

Aspects apply a visitor to every construct in a scope during synthesis. They are the natural place for organisation-wide rules: required tags, encryption, versioning, or banning public resources. Raise an error annotation and synthesis fails, which stops the rule violation before it reaches an account. The open-source cdk-nag project packages rule sets on the same mechanism.

import { Aspects, IAspect, Annotations } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';

class RequireBucketVersioning implements IAspect {
  visit(node: IConstruct): void {
    if (node instanceof s3.CfnBucket && !node.versioningConfiguration) {
      Annotations.of(node).addError('S3 buckets must enable versioning');
    }
  }
}

// app is the App instance from bin/ingest.ts
Aspects.of(app).add(new RequireBucketVersioning());   // synth fails on any violation

Snapshot tests, which compare the whole template to a stored copy, are useful for catching unintended changes but noisy on library upgrades; keep them alongside targeted assertions, not instead of them. Pair synthesis-time checks with a detective control such as AWS Config, which catches drift made outside CDK.

Deploying safely and pipelines

cdk diff shows template changes and highlights IAM and security-group changes separately; make its output part of every review. cdk deploy publishes assets and executes a CloudFormation change set, with CloudFormation's rollback on failure. --hotswap and cdk watch update Lambda code and similar resources directly, bypassing CloudFormation, which is excellent for a development stack and unsafe for production because it creates drift.

For production, deploy from a pipeline rather than laptops. The CDK Pipelines module defines a self-mutating CodePipeline in CDK: it synthesises the app, updates itself, then deploys stages to each environment in order with approval steps between them. Whatever tool you use, the pipeline should run synth, tests and diff on every change, deploy to a non-production account first, and deploy to production using bootstrap roles, not personal credentials.

Failure modes

FailureSymptomMitigation
Logical ID changed by refactorDiff shows a stateful resource destroyed and re-createdReview diffs for replacements; keep ids stable; use overrideLogicalId or CloudFormation refactoring when moving
Cross-stack export in useDeploy fails because an export cannot be removed while importedRemove the consumer first, or keep the export temporarily with exportValue
Stack grows past limitsSynthesis or deployment fails near 500 resourcesSplit by lifecycle into multiple stacks or nested stacks
Stale bootstrapDeploy asks for a newer bootstrap versionRe-run cdk bootstrap with the current CLI in every environment
Uncommitted contextTemplates change between machines without code changesCommit cdk.context.json; refresh lookups explicitly
Non-deterministic assetsEvery deploy updates every functionDeterministic bundling; pin build tool versions
Rollback failureStack stuck in UPDATE_ROLLBACK_FAILEDFix the blocking resource, then continue the rollback, skipping resources only with care
Console driftDeploy overwrites or conflicts with manual changesDetect drift; forbid console edits to CDK-managed stacks

Trade-offs

CDK's strengths are abstraction and language: loops, types, packages, IDE support and reusable constructs shared through a registry. Its costs are the same things. A few lines can create many resources you did not read, so you must review synthesised output. It inherits CloudFormation's deployment model, including its limits and speed, and it is AWS-only. Terraform offers a multi-cloud provider ecosystem and explicit state, at the cost of HCL and state management. Raw CloudFormation or SAM is more verbose but what you write is what deploys; for small serverless applications SAM is often enough. Choose CDK when your infrastructure is large, repetitive and owned by developers who will write and test it like code.

What to do next

  1. Pin aws-cdk-lib in your project and keep the CDK CLI at least as new as the library.
  2. Bootstrap each account and region, and replace the default execution policy with one scoped to what your stacks create.
  3. Set RemovalPolicy.RETAIN on every stateful resource and treat any replacement in cdk diff as a blocking review item.
  4. Commit cdk.context.json and refresh lookups only on purpose.
  5. Add assertion tests for security properties and an Aspect or cdk-nag rule set that fails synthesis on violations.
  6. Deploy production only from a pipeline, with diff output reviewed and a non-production stage first.
  7. Split stacks by lifecycle before any approaches the resource limit, and avoid cross-stack exports for values that change.
Key takeaway: The AWS CDK is a program that builds a construct tree, synthesises it into templates and assets, and hands them to CloudFormation through bootstrap roles. Logical IDs come from construct paths, so refactors can replace resources; tokens are placeholders, so decide configuration in code; and the synthesised output is what you must test and review. Bootstrap with scoped policies, commit context, retain stateful resources, enforce rules with assertions and Aspects, and deploy production from a pipeline.