← Back to Blog

Infrastructure as Code with AWS CDK: Beyond CloudFormation

Dat NguyenSeptember 202510 min readDevOps

Why I switched from raw CloudFormation and Terraform to AWS CDK, and how to structure CDK projects for real-world serverless applications.

AWSCDKInfrastructure as CodeCloudFormationServerlessTypeScript

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

  1. Don't put application code in the CDK project. Keep infra/ and src/ separate. CDK is for infrastructure, not business logic.
  2. Watch out for construct ID collisions. Every construct needs a unique ID within its scope. Changing an ID forces a resource replacement.
  3. Pin CDK versions. All aws-cdk-lib packages must be the same version. Use npm ls aws-cdk-lib to check.
  4. Use cdk diff before 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.