Infrastructure as Code with AWS CDK: Beyond CloudFormation
Why I switched from raw CloudFormation and Terraform to AWS CDK, and how to structure CDK projects for real-world serverless applications.
The Shift
I've written a lot of CloudFormation YAML. Thousands of lines of it. And at some point, I started asking: why am I writing infrastructure in a language that doesn't have functions, loops, or type checking?
AWS CDK lets you define infrastructure in TypeScript (or Python, Go, Java). Under the hood, it synthesizes to CloudFormation — so you get the same deployment engine, but with a real programming language on top.
A Real Example
Here's a typical serverless API stack:
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigw from "aws-cdk-lib/aws-apigateway";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
export class ApiStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const table = new dynamodb.Table(this, "Orders", {
partitionKey: { name: "PK", type: dynamodb.AttributeType.STRING },
sortKey: { name: "SK", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
const fn = new NodejsFunction(this, "ApiHandler", {
entry: "src/handlers/api.ts",
runtime: lambda.Runtime.NODEJS_20_X,
memorySize: 256,
timeout: cdk.Duration.seconds(30),
environment: {
TABLE_NAME: table.tableName,
},
});
// CDK handles the IAM policy automatically
table.grantReadWriteData(fn);
new apigw.LambdaRestApi(this, "Endpoint", {
handler: fn,
});
}
}
Notice table.grantReadWriteData(fn). In CloudFormation, you'd write an IAM policy with the exact ARN, actions, and conditions. CDK generates the least-privilege policy for you.
Project Structure
For medium-to-large projects, I organize CDK code like this:
infra/
├── bin/
│ └── app.ts # Entry point, instantiates stacks
├── lib/
│ ├── constructs/ # Reusable L3 constructs
│ │ ├── api-function.ts
│ │ └── monitored-queue.ts
│ ├── stacks/
│ │ ├── api-stack.ts
│ │ ├── data-stack.ts
│ │ └── monitoring-stack.ts
│ └── stages/
│ └── application-stage.ts
├── test/
│ └── stacks/
│ └── api-stack.test.ts
└── cdk.json
Constructs are reusable building blocks. For example, a MonitoredQueue that always includes a DLQ and CloudWatch alarm:
export class MonitoredQueue extends Construct {
public readonly queue: sqs.Queue;
public readonly dlq: sqs.Queue;
constructor(scope: Construct, id: string, props: MonitoredQueueProps) {
super(scope, id);
this.dlq = new sqs.Queue(this, "DLQ", {
retentionPeriod: Duration.days(14),
});
this.queue = new sqs.Queue(this, "Queue", {
visibilityTimeout: props.visibilityTimeout ?? Duration.seconds(60),
deadLetterQueue: { maxReceiveCount: 3, queue: this.dlq },
});
new cloudwatch.Alarm(this, "DLQAlarm", {
metric: this.dlq.metricApproximateNumberOfMessagesVisible(),
threshold: 1,
evaluationPeriods: 1,
alarmDescription: `Messages in ${id} DLQ`,
});
}
}
Now every queue in your project gets monitoring for free.
Testing Infrastructure
CDK supports snapshot testing and fine-grained assertions:
import { Template } from "aws-cdk-lib/assertions";
test("creates DynamoDB table with PAY_PER_REQUEST", () => {
const app = new cdk.App();
const stack = new ApiStack(app, "TestStack");
const template = Template.fromStack(stack);
template.hasResourceProperties("AWS::DynamoDB::Table", {
BillingMode: "PAY_PER_REQUEST",
KeySchema: [
{ AttributeName: "PK", KeyType: "HASH" },
{ AttributeName: "SK", KeyType: "RANGE" },
],
});
});
Run npm test before every deploy. Catch IAM policy changes, missing environment variables, and configuration drift in CI — not in production.
CDK vs Terraform vs Serverless Framework
| Feature | CDK | Terraform | Serverless Framework | |---------|-----|-----------|---------------------| | Language | TypeScript/Python | HCL | YAML | | Type safety | Yes | Limited | No | | Multi-cloud | No | Yes | Limited | | AWS integration | Deep | Good | Lambda-focused | | Learning curve | Medium | Medium | Low |
My take: Use CDK if you're all-in on AWS and want type-safe infrastructure. Use Terraform if you need multi-cloud. Use Serverless Framework for quick Lambda projects that don't need complex infrastructure.
Pitfalls
- Don't put application code in the CDK project. Keep
infra/andsrc/separate. CDK is for infrastructure, not business logic. - Watch out for construct ID collisions. Every construct needs a unique ID within its scope. Changing an ID forces a resource replacement.
- Pin CDK versions. All
aws-cdk-libpackages must be the same version. Usenpm ls aws-cdk-libto check. - Use
cdk diffbefore every deploy. Review changes before they hit your AWS account.
Conclusion
CDK brings software engineering practices to infrastructure: type checking, code reuse, unit testing, and refactoring. The learning curve is real, but after two weeks you'll wonder how you ever managed with YAML.