AWS CDK lets you describe AWS infrastructure in TypeScript, Python, Java, or C#, then synthesizes that to CloudFormation and deploys it. The tutorial examples are simple: a Lambda, an S3 bucket, an API Gateway. The production reality is more complex: hundreds of resources across multiple stacks, environments, and accounts.
This post is the patterns that hold up at scale — what to do, what to avoid, and where CDK shows its limits.
Why Use CDK at All
The case for CDK over plain CloudFormation:
- Real programming language with types, IDE support, and refactoring
- Constructs that compose multiple resources into reusable units
- AWS-maintained, so it covers AWS services well
- Generates standard CloudFormation, so it integrates with existing AWS tooling
The case against CDK:
- AWS-only (Terraform and Pulumi support multiple clouds)
- Steep learning curve for "the right way" beyond simple cases
- CloudFormation's slow apply times become CDK's slow apply times
- AWS service additions take months to appear in CDK
For an AWS-only shop, CDK is a strong choice. For multi-cloud, Pulumi or Terraform fits better.
Constructs: The Core Abstraction
CDK builds infrastructure from Constructs. Three levels:
L1 (CFN). Direct one-to-one mapping to CloudFormation resources. Verbose, exposes everything.
new cdk.aws_s3.CfnBucket(this, "Bucket", {
bucketName: "my-bucket",
versioningConfiguration: { status: "Enabled" },
publicAccessBlockConfiguration: {
blockPublicAcls: true,
blockPublicPolicy: true,
},
});
L2. Curated abstractions with sensible defaults. Most production CDK is L2.
new cdk.aws_s3.Bucket(this, "Bucket", {
versioned: true,
blockPublicAccess: cdk.aws_s3.BlockPublicAccess.BLOCK_ALL,
});
L3 (Patterns). High-level abstractions composing multiple resources. Examples include aws-cdk-lib/aws-ecs-patterns which sets up ECS + ALB + autoscaling with one Construct.
The right default is L2. Use L1 when L2 hides something you need. Use L3 for known patterns you want to share.
Your Own Constructs
The biggest payoff from CDK comes from building your own Constructs. Encapsulate organizational patterns:
export class StandardLambdaApi extends Construct {
public readonly url: string;
constructor(scope: Construct, id: string, props: StandardLambdaApiProps) {
super(scope, id);
const fn = new lambda.Function(this, "Function", {
runtime: lambda.Runtime.NODEJS_20_X,
handler: "index.handler",
code: lambda.Code.fromAsset(props.codePath),
timeout: Duration.seconds(30),
memorySize: 256,
tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.ONE_MONTH,
environment: props.environment,
});
const api = new apigw.LambdaRestApi(this, "Api", {
handler: fn,
proxy: true,
});
new cdk.CfnOutput(this, "ApiUrl", { value: api.url });
this.url = api.url;
}
}
Teams use StandardLambdaApi instead of wiring Lambda + API Gateway + IAM + logging themselves. The standards (X-Ray tracing, log retention, memory size) are baked in.
This is where CDK earns its keep. Building a library of organizational Constructs lets every team get the standards for free.
Stack Boundaries
A CDK app is composed of Stacks. Each Stack maps to one CloudFormation stack.
The classic mistake: one giant Stack with everything. CloudFormation stacks have resource limits (500), and a single Stack's failure affects all its resources. Splitting matters.
The pattern that works:
- Per-service Stacks. One Stack per logical service (auth, payments, dashboard).
- Per-environment. Each environment (dev, staging, prod) is a separate set of Stacks.
- Shared infrastructure in its own Stack (VPC, DNS, shared secrets).
const app = new cdk.App();
const sharedStack = new SharedStack(app, "Shared", { env: prodEnv });
const authStack = new AuthStack(app, "Auth", {
env: prodEnv,
vpc: sharedStack.vpc,
});
The cross-Stack references make CDK orchestrate the deploy order automatically.
Environment Configuration
Different environments need different configuration. CDK supports several patterns:
Context. A cdk.json file with environment-specific values.
{
"context": {
"envs": {
"dev": { "vpcCidr": "10.10.0.0/16", "rdsClass": "db.t3.small" },
"prod": { "vpcCidr": "10.20.0.0/16", "rdsClass": "db.r6g.large" }
}
}
}
const config = this.node.tryGetContext("envs")[envName];
Separate Stack constructors per environment.
new AuthStack(app, "Auth-Dev", { env: devEnv, config: devConfig });
new AuthStack(app, "Auth-Prod", { env: prodEnv, config: prodConfig });
Environment variables. Read at synth time.
Each pattern has tradeoffs. Most production teams use a config object passed to the Stack, with environment-specific config in version-controlled files.
Asset Handling
CDK supports bundled assets — Lambda code, Docker images, S3 deployments. The CDK handles building and uploading these.
new lambda.Function(this, "Function", {
code: lambda.Code.fromAsset("./src/handler", {
bundling: {
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
command: ["bash", "-c", "npm install && cp -r . /asset-output"],
},
}),
});
The bundling runs in a Docker container during cdk synth or cdk deploy. This is powerful but slow. For large applications, the synth time becomes a real cost.
The mitigation: pre-build assets in CI, then point CDK at the built artifacts. Faster synth, more orchestration complexity.
Testing
CDK code is real code, so it has real tests.
Two test patterns:
Snapshot tests. Synth the Stack and assert it matches a saved snapshot.
test("snapshot", () => {
const app = new cdk.App();
const stack = new AuthStack(app, "Test", { env: testEnv });
const template = Template.fromStack(stack);
expect(template.toJSON()).toMatchSnapshot();
});
Catches unintended changes. Updates require explicit snapshot regeneration.
Assertion tests. Check specific properties of the synthesized template.
test("Lambda has correct memory", () => {
const app = new cdk.App();
const stack = new AuthStack(app, "Test", { env: testEnv });
const template = Template.fromStack(stack);
template.hasResourceProperties("AWS::Lambda::Function", {
MemorySize: 256,
Runtime: "nodejs20.x",
});
});
More targeted; better for specific invariants.
Most production CDK has both: snapshots for the general shape, assertions for critical properties.
Aspects: The Cross-Cutting Concerns Tool
Aspects apply a function to every node in a CDK app's tree. Useful for enforcing organization-wide policies.
class RequireEncryption implements cdk.IAspect {
visit(node: IConstruct): void {
if (node instanceof s3.Bucket) {
const cfnBucket = node.node.defaultChild as s3.CfnBucket;
if (!cfnBucket.bucketEncryption) {
cdk.Annotations.of(node).addError("Bucket must be encrypted");
}
}
}
}
cdk.Aspects.of(app).add(new RequireEncryption());
Synth fails if any S3 bucket lacks encryption. Useful for organization-wide controls.
Aspects are powerful but expensive to debug. Use them for high-value invariants.
Deployment Patterns
Three common CDK deployment patterns:
Direct deploy. cdk deploy from a developer machine or CI. Fast feedback, no rollback complexity.
CDK Pipelines. CDK's built-in pipeline construct. CodePipeline + CodeBuild that builds the CDK app on every push to git and deploys to environments. AWS-native.
External CI deploys. GitHub Actions or similar runs cdk deploy. More flexible, more configuration to maintain.
Most production teams use external CI. CDK Pipelines is convenient if you are deep in AWS.
Common Mistakes
- Putting everything in one Stack. Hits CloudFormation limits. Breaking it up later is real work.
- No tests. Synth time errors are not caught until deploy time. Quick feedback loops vanish.
- Mutable Construct properties. Constructs that change behavior based on call order. Diff is unstable.
- L1 everywhere. Reinventing what L2 gives for free.
- Cross-region Stacks without thought. CloudFormation references work within a region; cross-region needs different patterns (custom resources, parameters).
Where CDK Falls Short
- Slow apply times. Inherited from CloudFormation. A change to a Lambda function can take minutes.
- Hard rollbacks. CloudFormation's rollback is the only undo. State drift is hard to recover from.
- Limited dry-run.
cdk diffshows changes but not why CloudFormation will react the way it does. - AWS-only. Multi-cloud needs a different tool.
For pure AWS, CDK is the most idiomatic IaC. For multi-cloud, the gap matters.
The Practical Use Case
CDK works best when:
- All infrastructure is on AWS
- The team likes writing in a real programming language
- Organizational Constructs (the "platform library") are a real artifact
- AWS coverage gaps (services CDK lags on) do not affect you
It is the right call for many AWS-native shops. It is the wrong call when multi-cloud or non-AWS workloads are in the mix.
Evaluating CDK for an AWS-heavy infrastructure project, or scaling an existing CDK setup that has gotten messy? We help teams design Construct libraries and Stack boundaries that survive growth. scopeforged.com