This content originally appeared on DEV Community and was authored by Clariza Look
The Dilemma Every Engineering Team Faces
Picture this scenario: Your team has been successfully running AWS infrastructure for years using CloudFormation YAML templates. They work, they’re familiar, and everyone knows how to maintain them. Meanwhile, your newer projects are built with AWS CDK, complete with comprehensive unit testing, type safety, and modern development practices.
Sound familiar? You’re not alone. Many engineering teams find themselves at this crossroads, managing a hybrid infrastructure landscape where legacy CloudFormation templates coexist with modern CDK applications. The question isn’t whether both approaches work—they do. The question is whether this fragmentation is serving your team’s long-term interests.
Understanding the Players: CloudFormation vs CDK
Before diving into the migration story, let’s clarify what we’re talking about.
What is AWS CloudFormation?
AWS CloudFormation is Amazon’s native Infrastructure as Code (IaC) service that allows you to define your AWS resources using JSON or YAML templates. Think of it as a blueprint that tells AWS exactly what infrastructure to create.
Here’s a simple CloudFormation YAML example creating an S3 bucket:
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Simple S3 bucket example'
Resources:
MyS3Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: my-example-bucket
VersioningConfiguration:
Status: Enabled
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
Outputs:
BucketName:
Description: 'Name of the S3 bucket'
Value: !Ref MyS3Bucket
Export:
Name: MyS3BucketName
CloudFormation Characteristics:
- Declarative: You describe what you want, not how to create it
- YAML/JSON syntax: Human-readable but can become verbose
- AWS native: Built specifically for AWS resources
- Template-based: Static files that AWS reads and executes
What is AWS CDK?
AWS CDK (Cloud Development Kit) is a newer approach that lets you define cloud infrastructure using familiar programming languages like TypeScript, Python, Java, or C#. Instead of writing YAML or JSON, you write actual code.
Here’s the same S3 bucket example using CDK (TypeScript):
import { Stack, StackProps } from 'aws-cdk-lib';
import { Bucket, BlockPublicAccess, BucketAccessControl } from 'aws-cdk-lib/aws-s3';
import { CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class MyInfrastructureStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Create S3 bucket with sensible defaults
const myBucket = new Bucket(this, 'MyS3Bucket', {
bucketName: 'my-example-bucket',
versioned: true,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL
});
// Export bucket name for other stacks
new CfnOutput(this, 'BucketName', {
value: myBucket.bucketName,
exportName: 'MyS3BucketName',
description: 'Name of the S3 bucket'
});
}
}
CDK Characteristics:
- Programmatic: Uses actual programming languages with IDE support
- Higher-level abstractions: Smart defaults and best practices built-in
- Type safety: Compile-time error checking
- Testable: Full unit testing capabilities like any other code
- Generates CloudFormation: CDK compiles down to CloudFormation templates
The Key Differences at a Glance
Aspect | CloudFormation YAML | AWS CDK |
---|---|---|
Language | YAML/JSON markup | TypeScript, Python, Java, C# |
IDE Support | Basic syntax highlighting | Full IntelliSense, autocomplete, refactoring |
Testing | Limited to template validation | Full unit testing with mocking |
Linting | cfn-lint for template validation | ESLint, Prettier, language-specific linters |
Reusability | Copy/paste templates | Object-oriented inheritance and composition |
Learning Curve | Learn YAML syntax + AWS resources | Use existing programming skills |
Error Detection | Runtime (during deployment) | Compile-time + runtime |
A More Complex Example: The Difference Becomes Clear
Let’s look at a more realistic example—creating an API Gateway that triggers a Lambda function and stores data in an S3 bucket. This shows how the complexity gap widens with real infrastructure.
CloudFormation YAML approach:
AWSTemplateFormatVersion: '2010-09-09'
Description: 'API Gateway with Lambda and S3'
Resources:
# S3 Bucket for data storage
DataBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: my-api-data-bucket
VersioningConfiguration:
Status: Enabled
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
# IAM Role for Lambda execution
LambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: S3Access
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:DeleteObject
Resource: !Sub '${DataBucket}/*'
# Lambda Function
ApiLambdaFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: api-handler
Runtime: nodejs18.x
Handler: index.handler
Role: !GetAtt LambdaExecutionRole.Arn
Code:
ZipFile: |
exports.handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify({ message: 'Hello from Lambda!' })
};
};
Environment:
Variables:
BUCKET_NAME: !Ref DataBucket
# Permission for API Gateway to invoke Lambda
LambdaInvokePermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref ApiLambdaFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub '${ApiGateway}/*/*'
# API Gateway REST API
ApiGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Name: MyAPI
Description: API for Lambda integration
EndpointConfiguration:
Types:
- REGIONAL
# API Gateway Resource
ApiResource:
Type: AWS::ApiGateway::Resource
Properties:
RestApiId: !Ref ApiGateway
ParentId: !GetAtt ApiGateway.RootResourceId
PathPart: data
# API Gateway Method
ApiMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref ApiGateway
ResourceId: !Ref ApiResource
HttpMethod: POST
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ApiLambdaFunction.Arn}/invocations'
# API Gateway Deployment
ApiDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn:
- ApiMethod
Properties:
RestApiId: !Ref ApiGateway
StageName: prod
Outputs:
ApiGatewayUrl:
Description: API Gateway endpoint URL
Value: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/prod'
Export:
Name: ApiGatewayUrl
BucketName:
Description: S3 bucket name
Value: !Ref DataBucket
Export:
Name: DataBucketName
LambdaFunctionArn:
Description: Lambda function ARN
Value: !GetAtt ApiLambdaFunction.Arn
Export:
Name: LambdaFunctionArn
CDK approach (TypeScript):
import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';
import { RestApi, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';
import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda';
import { Bucket, BlockPublicAccess } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
export class ApiStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// S3 Bucket for data storage
const dataBucket = new Bucket(this, 'DataBucket', {
bucketName: 'my-api-data-bucket',
versioned: true,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL
});
// Lambda Function
const apiFunction = new Function(this, 'ApiLambdaFunction', {
functionName: 'api-handler',
runtime: Runtime.NODEJS_18_X,
handler: 'index.handler',
code: Code.fromInline(`
exports.handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify({ message: 'Hello from Lambda!' })
};
};
`),
environment: {
BUCKET_NAME: dataBucket.bucketName
}
});
// Grant Lambda permissions to access S3 bucket
dataBucket.grantReadWrite(apiFunction);
// API Gateway
const api = new RestApi(this, 'ApiGateway', {
restApiName: 'MyAPI',
description: 'API for Lambda integration'
});
// Create API resource and method
const dataResource = api.root.addResource('data');
dataResource.addMethod('POST', new LambdaIntegration(apiFunction));
// Outputs
new CfnOutput(this, 'ApiGatewayUrl', {
value: api.url,
exportName: 'ApiGatewayUrl',
description: 'API Gateway endpoint URL'
});
new CfnOutput(this, 'BucketName', {
value: dataBucket.bucketName,
exportName: 'DataBucketName',
description: 'S3 bucket name'
});
new CfnOutput(this, 'LambdaFunctionArn', {
value: apiFunction.functionArn,
exportName: 'LambdaFunctionArn',
description: 'Lambda function ARN'
});
}
}
The difference is striking:
- CloudFormation: ~105 lines of YAML with manual IAM role creation, permissions, and resource linking
- CDK: ~50 lines of TypeScript with automatic IAM role generation and permission management
The CDK version automatically creates the Lambda execution role, sets up the proper IAM policies for S3 access, configures API Gateway permissions, and handles the deployment stage. The dataBucket.grantReadWrite(apiFunction)
line alone replaces 20+ lines of IAM policy configuration in CloudFormation.
The Hidden Costs of Infrastructure Fragmentation
Two Worlds, Double the Overhead
When your infrastructure spans both CloudFormation YAML and CDK, you’re essentially maintaining two different technology stacks:
- Dual toolchains: Different testing frameworks, deployment pipelines, and development workflows
- Split expertise: Team members specializing in either YAML or CDK, creating knowledge silos
- Inconsistent practices: Different approaches to testing, monitoring, and troubleshooting
- Onboarding complexity: New team members need to learn both approaches
The Testing Gap
Here’s where the pain really shows. Your CDK projects likely have:
- Comprehensive unit tests validating resource configurations
- Integration tests ensuring components work together
- Automated validation in CI/CD pipelines
- Rapid feedback loops for developers
Meanwhile, your CloudFormation YAML templates probably have:
- Manual deployment processes
- Limited or no automated testing
- Post-deployment validation (if any)
- Slower feedback cycles when issues arise
This isn’t just a technical debt issue—it’s a reliability and velocity problem.
The Strategic Case for CDK Migration
1. Standardization as a Force Multiplier
When every project follows the same patterns, magic happens:
Knowledge Transfer Accelerates: A developer who masters testing on one CDK project can immediately apply that knowledge to any other project. No context switching between YAML syntax and TypeScript/Python.
Best Practices Propagate: That clever testing pattern you developed for your API Gateway? It can be instantly applied across all projects, not just the CDK ones.
Tooling Investment Pays Off: Every improvement to your CDK testing framework, every CI/CD optimization, benefits your entire infrastructure portfolio.
2. Operational Excellence Through Consistency
Consider these operational benefits:
Unified Monitoring: All projects can leverage the same observability patterns, making it easier to understand system behavior across your entire infrastructure.
Consistent Debugging: When something goes wrong at 2 AM, you want your on-call engineer using familiar tools and approaches, regardless of which project is failing.
Predictable Maintenance: Security updates, dependency management, and refactoring become routine when you’re working with a single technology stack.
3. The Compound Effect of Testing and Code Quality
Here’s what comprehensive testing and linting really delivers:
// CDK with TypeScript linting and testing
test('API Gateway integrates correctly with Load Balancer', () => {
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::ApiGateway::VpcLink', {
TargetArns: [{ Ref: 'NetworkLoadBalancer' }]
});
});
// ESLint catches issues before deployment
const api = new RestApi(this, 'MyApi', {
restApiName: 'my-api', // ESLint enforces naming conventions
description: 'API for Lambda integration' // Required by custom linting rules
});
CloudFormation Linting:
# Basic template validation
cfn-lint template.yml
# Checks for syntax errors and basic AWS resource validation
CDK Linting & Quality Tools:
# TypeScript compilation catches type errors
npm run build
# ESLint for code quality and style
npm run lint
# Prettier for consistent formatting
npm run format
# Unit tests with coverage
npm run test
# CDK-specific validation
cdk synth # Validates and generates CloudFormation
When you can validate your infrastructure changes before deployment with comprehensive linting, type checking, and testing, you move faster. When you move faster with confidence, you innovate more. When you innovate more, you deliver better outcomes for your users.
Making the Migration Case: A Real-World Example
Let’s walk through a practical scenario. Imagine you have a legacy CloudFormation template managing your API infrastructure. It’s deployed manually, tested manually, and updated carefully.
Now consider the CDK equivalent:
Before: CloudFormation YAML
# 200+ lines of YAML
# Manual testing
# No type safety
# Documentation in separate files
# Manual parameter management
After: CDK with Testing
export class ApiInfrastructureStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// Self-documenting, type-safe infrastructure
const cluster = new ecs.Cluster(this, 'ApiCluster', {
vpc: props.vpc,
containerInsights: true
});
const loadBalancer = new elbv2.NetworkLoadBalancer(this, 'ApiNLB', {
vpc: props.vpc,
internetFacing: false
});
// Outputs that dependent stacks can safely import
new CfnOutput(this, 'LoadBalancerDNS', {
value: loadBalancer.loadBalancerDnsName,
exportName: `${this.stackName}-LoadBalancerDNS`
});
}
}
The CDK version isn’t just more testable—it’s more readable, more maintainable, and less error-prone.
The Migration Strategy That Actually Works
Phase 1: Parallel Implementation (Weeks 1-2)
Create the CDK equivalent while keeping your existing CloudFormation template running. This isn’t about big-bang replacement—it’s about building confidence.
Phase 2: Test Everything (Week 3)
Implement the comprehensive testing that makes CDK valuable:
- Unit tests for resource configuration
- Integration tests for component interaction
- End-to-end tests for user-facing functionality
Phase 3: Validate in Non-Production (Weeks 4-5)
Deploy the CDK version alongside your existing infrastructure in non-production environments. Compare outputs, validate behavior, and build team confidence.
Phase 4: Production Cutover (Week 6)
With comprehensive testing and non-production validation complete, the production migration becomes a low-risk operation.
ROI: When Does Migration Pay Off?
The mathematics are compelling:
Initial Investment: 4-6 weeks of development time
Ongoing Benefits:
- 50% faster infrastructure changes (thanks to testing confidence)
- 75% reduction in deployment-related incidents
- 100% improvement in developer onboarding speed for infrastructure work
- Infinite improvement in sleep quality for your on-call engineers
But the real ROI isn’t just in time saved—it’s in opportunities created. When your infrastructure is reliable and rapidly changeable, your team can focus on building features instead of fighting deployment issues.
The Counterarguments (And Why They Don’t Hold Up)
“Our CloudFormation templates work fine”: Yes, they work. But working and optimal are different things. Your flip phone worked fine too, but you probably upgraded for good reasons.
“Migration is risky”: True, but so is maintaining fragmented infrastructure. The gradual migration approach outlined above minimizes risk while maximizing learning.
“We don’t have time”: You don’t have time not to do this. Every day you spend maintaining two different infrastructure approaches is time you’re not spending on features that matter to your users.
The Bottom Line
Infrastructure standardization isn’t just about choosing between CloudFormation and CDK—it’s about choosing between fragmentation and focus. When your team can channel all their infrastructure expertise into a single, well-tested approach, everyone wins.
Your legacy CloudFormation templates served you well, but they don’t have to define your future. The question isn’t whether you can afford to migrate to CDK—it’s whether you can afford not to.
The path forward is clear: embrace standardization, invest in testing, and build infrastructure that accelerates your team rather than slowing it down. Your future self (and your on-call schedule) will thank you.
Ready to start your CDK migration journey? Begin with a single, non-critical CloudFormation template and apply the gradual migration strategy outlined above. Small steps, big wins.
This content originally appeared on DEV Community and was authored by Clariza Look