AWS CDK Introduction: Infrastructure as Code with TypeScript
HashiCorp’s Business Source License change in August 2023 sent a lot of teams back to evaluating their IaC options. AWS CDK picked up meaningful adoption during that period — not because it’s new (CDK v1 launched in 2018), but because it solves a real problem: writing infrastructure in a real programming language instead of YAML. If you’ve ever wanted to use a for loop in your CloudFormation template, CDK is where that frustration finally ends.
CDK compiles TypeScript (or Python, Java, Go, C#) into CloudFormation templates. Once deployed, your stack lives in CloudFormation — same console visibility, same rollback behavior, same drift detection. What changes is the source file. Instead of YAML, you’re writing infrastructure in a language with a type system, IDE completion, real loops, and unit tests.
How CDK Works
CDK’s development loop: write TypeScript that describes your resources, run cdk synth, and the toolkit generates CloudFormation JSON into a local cdk.out/ directory. Then cdk deploy takes that template and hands it to CloudFormation. The stack update runs identically to any CloudFormation update — same events, same rollback behavior, same console visibility.
There’s no separate state file here. CloudFormation tracks the deployed stack, so cdk diff shows drift when someone manually changes a resource in the console, and cdk deploy reconciles it. The source of truth is CloudFormation’s stack, not a local .tfstate that needs to be locked, stored remotely, and shared across team members. That also simplifies CI — you can run cdk deploy from any machine with the right AWS credentials without coordinating state access.
The three-layer construct system is what most CDK documentation explains poorly.
L1 constructs (prefixed Cfn) are direct representations of CloudFormation resource types. CfnBucket is exactly an AWS::S3::Bucket. Every property maps to a CloudFormation property. There’s no abstraction — you’re writing CloudFormation in TypeScript. L1s exist for every AWS resource and update automatically when CloudFormation adds new properties.
L2 constructs are the main thing you’ll use. Bucket, Function, Table — these are opinionated abstractions over the underlying CloudFormation resources. An L2 Bucket sets sensible defaults: versioning disabled but available to enable, server-side encryption with S3-managed keys, ACLs disabled in line with the S3 security policy defaults from 2023. L2s also expose convenience methods: bucket.grantRead(role) creates the correct IAM policy and attaches it in one call.
L3 constructs (also called patterns) combine multiple L2s into common solutions. ApplicationLoadBalancedFargateService provisions a Fargate service, an Application Load Balancer, the security groups connecting them, IAM roles, and CloudWatch log groups in a single construct. Patterns are useful for getting a standard architecture running quickly; they’re less useful when your requirements diverge from the pattern’s assumptions.
Your First CDK Project
Install the CDK Toolkit globally and bootstrap:
npm install -g aws-cdk
# Create a new TypeScript project
mkdir my-infra && cd my-infra
cdk init app --language typescript
# Bootstrap CDK in your account/region (one-time per account)
cdk bootstrap aws://123456789012/us-east-1
Bootstrapping creates an S3 bucket and ECR repository in your account that CDK uses to stage assets before deployment. It also creates an IAM role (cdk-cfn-exec-role) that CloudFormation assumes to create resources on your behalf.
The generated project structure:
my-infra/
├── bin/
│ └── my-infra.ts # Entry point: creates App and instantiates stacks
├── lib/
│ └── my-infra-stack.ts # Your stack definition
├── test/
│ └── my-infra.test.ts # Tests
├── cdk.json # CDK configuration
├── package.json
└── tsconfig.json
A basic stack defining an S3 bucket and a Lambda function with access to it:
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';
import { Construct } from 'constructs';
export class MyInfraStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const bucket = new s3.Bucket(this, 'DataBucket', {
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
const processor = new lambda.Function(this, 'Processor', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '../src/processor')),
environment: {
BUCKET_NAME: bucket.bucketName,
},
timeout: cdk.Duration.seconds(30),
});
// Grant read/write access — CDK creates the IAM policy automatically
bucket.grantReadWrite(processor);
}
}
That bucket.grantReadWrite(processor) call generates the IAM policy with the correct S3 actions and resource ARNs, attaches it to the Lambda execution role, and handles the ARN interpolation that CloudFormation requires. Understanding what that convenience method produces — and what the underlying IAM permissions look like — is covered in the AWS IAM roles and policies guide.
Deploy it:
cdk synth # Compile to CloudFormation — inspect the output in cdk.out/
cdk diff # Show what would change
cdk deploy # Deploy to CloudFormation
The CDK Toolkit Commands
cdk synth is where you catch problems before deployment. The output is CloudFormation JSON in cdk.out/. Reading the synthesized output is useful when debugging: you can see exactly what IAM policies CDK generated, what resource properties were set, and whether your conditional logic worked correctly.
cdk diff compares your synthesized template against the currently deployed stack. It shows which resources will be added, modified, or replaced. Replacements are worth paying attention to — some property changes on certain resources (like RDS instances and some EC2 settings) force a replacement, which means delete and recreate. CDK marks these in the diff output.
cdk deploy runs the CloudFormation update with progress output in the terminal. Failed deployments automatically roll back, same as native CloudFormation. Add --require-approval never for CI pipelines where you don’t want manual confirmation prompts.
cdk destroy deletes the stack. Resources with removalPolicy: RETAIN (S3 buckets with data, RDS instances) survive the stack deletion. Set this intentionally — the default for most resources in production should be RETAIN.
CDK Aspects: Cross-Cutting Concerns
Aspects are CDK’s mechanism for applying changes or validations across an entire construct tree. They visit every construct in a stack and can modify or reject based on the construct type.
A practical example: ensuring every S3 bucket in your stack has versioning enabled.
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { IAspect } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
class BucketVersioningChecker implements IAspect {
visit(node: IConstruct): void {
if (node instanceof s3.CfnBucket) {
if (node.versioningConfiguration === undefined) {
cdk.Annotations.of(node).addError(
'All S3 buckets must have versioning enabled'
);
}
}
}
}
// Apply to a stack
const app = new cdk.App();
const stack = new MyStack(app, 'MyStack');
cdk.Aspects.of(stack).add(new BucketVersioningChecker());
cdk synth fails with the error message if any bucket lacks versioning. This is how you enforce security or compliance policies across all stacks in an organization — write the Aspect once, apply it everywhere. For more sophisticated policy enforcement across CDK deployments, the OPA policy-as-code guide covers integrating policy checks into the pipeline.
Testing CDK Code
CDK’s assertions library lets you test synthesized CloudFormation templates without hitting AWS. Write Jest tests against the synthesized output, check resource properties, assert that IAM policies exist — no credentials, no stack deployment, just a template and a few assertions.
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyInfraStack } from '../lib/my-infra-stack';
test('Lambda function has correct runtime', () => {
const app = new cdk.App();
const stack = new MyInfraStack(app, 'TestStack');
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::Lambda::Function', {
Runtime: 'nodejs20.x',
Timeout: 30,
});
});
test('S3 bucket has versioning enabled', () => {
const app = new cdk.App();
const stack = new MyInfraStack(app, 'TestStack');
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::S3::Bucket', {
VersioningConfiguration: {
Status: 'Enabled',
},
});
});
These tests run with npm test — no AWS credentials required, no actual CloudFormation calls. The assertions work against the synthesized template. Testing CDK code this way catches regressions: if someone removes versioning from the bucket construct, the test fails before the change reaches a real AWS account.
CDK vs Terraform: The Practical Difference
Between CDK and Terraform, the practical fork is state management. CloudFormation owns the state for CDK stacks — run cdk deploy, CloudFormation applies the change and tracks what’s deployed. Terraform maintains its own .tfstate, which needs to live somewhere remote, get locked during runs, and stay in sync across your team. Neither is wrong, but the operational burden is different.
CloudFormation’s strong point is its transactional rollback: if a resource fails to create, everything added so far gets rolled back. Terraform’s rollback story is weaker — a failed plan leaves whatever was created before the failure, and you manage recovery manually. For stateful resources like databases, that distinction matters.
Terraform’s strong point is multi-cloud support and a much larger provider ecosystem. CDK only targets AWS. If your infrastructure spans AWS and, say, Cloudflare or Datadog, Terraform’s providers handle that in one tool. CDK requires separate tooling for non-AWS resources.
CDK’s strong point is developer experience. TypeScript autocomplete on resource properties, real loop constructs, testable code, IDE navigation to construct definitions — these reduce the cognitive overhead of writing infrastructure significantly compared to YAML. For teams that are AWS-only and comfortable with TypeScript or Python, CDK often reduces the friction of writing infrastructure compared to HCL.
The Crossplane vs Terraform comparison covers the broader IaC landscape if you’re evaluating multiple tools.
Adding CloudWatch Monitoring
CDK’s L2 constructs make it easy to add CloudWatch alarms alongside the resources they monitor:
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as sns from 'aws-cdk-lib/aws-sns';
const alertTopic = new sns.Topic(this, 'Alerts');
// Lambda error alarm
const errorAlarm = processor.metricErrors({
period: cdk.Duration.minutes(1),
statistic: 'Sum',
}).createAlarm(this, 'ProcessorErrors', {
threshold: 5,
evaluationPeriods: 2,
alarmDescription: 'Lambda error rate too high',
actionsEnabled: true,
});
errorAlarm.addAlarmAction(new cloudwatchActions.SnsAction(alertTopic));
The metric methods on L2 constructs return pre-configured Metric objects with the correct namespace and dimensions already set. You’re not looking up metric names and dimension keys manually — the construct knows what metrics it publishes. For the alarm configuration strategy (evaluation periods, INSUFFICIENT_DATA handling, composite alarms), the AWS CloudWatch deep dive covers the patterns that actually work at 2am.
CDK Pipelines: Self-Mutating CI/CD
The self-mutating part is what makes CDK Pipelines different from wiring up CodePipeline manually. You change the pipeline definition in TypeScript, commit it, and the running pipeline updates itself on the next execution before touching your application. Adding a deployment stage is a TypeScript edit and a push — no CodePipeline console, no separate infrastructure update step.
import * as pipelines from 'aws-cdk-lib/pipelines';
const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
synth: new pipelines.ShellStep('Synth', {
input: pipelines.CodePipelineSource.connection(
'my-org/my-repo',
'main',
{ connectionArn: 'arn:aws:codestar-connections:...' }
),
commands: ['npm ci', 'npm run build', 'npx cdk synth'],
}),
});
pipeline.addStage(new MyApplicationStage(this, 'Production', {
env: { account: '123456789012', region: 'us-east-1' },
}));
CDK Pipelines works well for organizations that are already AWS-native and don’t need multi-cloud pipeline support. If your team is using GitLab CI or GitHub Actions, wiring CDK into those is straightforward — cdk deploy is just a CLI command.
When CDK Makes Sense
CDK is the right choice when your team knows a CDK-supported language well, you’re deploying exclusively to AWS, and you want testable infrastructure code with real IDE support. The TypeScript type system catches configuration errors at write time that CloudFormation would only surface during deployment.
Skip CDK when your infrastructure spans multiple clouds, when you’re already deep in Terraform with modules and pipelines the team depends on, or when nobody on the team knows TypeScript, Python, or Java well enough to maintain CDK code. And don’t switch to CDK just to write L1 constructs because the L2 abstractions feel opaque. Writing L1s in CDK gives you CloudFormation syntax in TypeScript — more complexity for no benefit.
Start with a small stack — an S3 bucket and a Lambda — to understand the synthesize-deploy cycle. Add an L2 construct that uses grant methods to understand IAM automation. Write a test that validates a resource property. That progression teaches you what CDK is actually doing before you commit to it for production infrastructure.
Comments