If I were building a new AWS infrastructure today and knew nothing else about the project, I would start with Terraform.
Not because AWS CDK is worse. I have worked with both, and both deliver the same production infrastructure reliably. But they make infrastructure complicated in different ways. Terraform forces me to describe infrastructure as infrastructure. CDK lets me model it like software. That is exactly where CDK's strength lies, and for me it is also where its biggest risk lies.
So my default is this: for normal AWS infrastructure I start with Terraform. I choose CDK when real abstraction and reuse justify the extra programming layer. Not before.
What I say about Terraform here applies just as much to OpenTofu, the open fork. The differences between the two do not decide this question.
Two models for the same goal
Both are infrastructure as code, but with different models. Terraform describes resources in HCL, a declarative language of its own. A simplified ECS service starts like this:
resource "aws_ecs_cluster" "app" {
name = "app"
}
resource "aws_ecs_service" "api" {
name = "api"
cluster = aws_ecs_cluster.app.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 2
}CDK expresses the same infrastructure in a general-purpose programming language, TypeScript in this case:
const cluster = new ecs.Cluster(this, "Cluster", { vpc });
new ecs.FargateService(this, "Api", {
cluster,
taskDefinition,
desiredCount: 2,
});At first glance the difference looks cosmetic. It is not. CDK does not replace CloudFormation, it is an abstraction layer on top of it: the program runs, produces a CloudFormation template, and only CloudFormation creates the resources. Terraform talks to the AWS APIs directly through its provider.
That distinction shapes almost every decision that follows. The first one is visibility. When I open a Terraform file and read
resource "aws_sqs_queue" "orders" {
name = "orders"
visibility_timeout_seconds = 60
}I see fairly directly what is supposed to exist. Of course Terraform can be nested too: modules, variables, for_each, dynamic blocks and locals quickly make a configuration indirect. But the basic model stays the same: this resource should exist, with these properties.
I like that for infrastructure, because infrastructure is indirect enough already. Three changed lines can alter networking, IAM, databases and production traffic. I do not automatically need an extra abstraction layer on top.
Why the example is an ECS service and not a Kubernetes deployment is covered in ECS Fargate or Kubernetes.
Where CDK is strong: constructs
CDK works with constructs, and they come in three levels. At the bottom, a construct maps exactly one CloudFormation resource. Above that are the curated constructs with sensible defaults, such as a FargateService that brings its own roles and security groups. At the top, a construct encapsulates a complete platform component:
new PublicApiService(this, "Orders", {
domainName: "orders.example.com",
cpu: 1024,
memory: 2048,
minCapacity: 2,
maxCapacity: 20,
});Behind these seven lines there can be an ECS service, the Fargate task definition, an Application Load Balancer with a target group, CloudWatch alarms, IAM roles, auto scaling, a log group, the DNS record and the TLS certificate. A developer no longer configures twelve AWS resources. All they say is: I need a public API service.
On top of that there is something I genuinely miss in Terraform:
bucket.grantRead(importFunction);This one line creates an IAM policy that allows exactly what reading this bucket means, and attaches it to the function's role. In Terraform I write the same policy by hand, with every action and ARN, and I can get it wrong.
In an organization with many similar workloads that is extremely valuable. But I ask myself one question first: do we really have a recurring platform pattern, or are we building an abstraction for something that occurs twice?
Abstraction moves complexity, it does not remove it
In application code, abstraction is often a mark of quality: repeated logic gets encapsulated, details disappear behind interfaces, complexity gets a name. With infrastructure I am more careful, because an abstraction can also hide important differences.
new Database(this, "Orders");That looks excellent. But what is this Database? Aurora or RDS PostgreSQL? Multi-AZ or not? Which backup retention, which encryption, which security groups, which instance size, which parameter group? Is deletion protection on? Who gets alerted?
The more of this the construct decides, the less the developer sees at the call site. That can be exactly the goal. It can also mean that a business-critical database with ten implicit architecture decisions comes out of a single line of code. Abstraction reduces visible complexity, not real complexity. With infrastructure I therefore want to decide deliberately which details may disappear. The same goes for the grantRead above, by the way: convenient, but the policy it produces appears nowhere in the source.
Terraform has reuse too, of course:
module "orders_api" {
source = "../modules/ecs-service"
name = "orders"
cpu = 1024
memory = 2048
desired_count = 2
}Several resources disappear underneath here as well. So the difference is not whether both can abstract, but how far you get with it and how easily the abstraction starts to contain real program logic. With CDK I have loops, classes, inheritance, functions, dependency injection, external libraries and the full toolset of a programming language. That is great when I need it, and unnecessary power when I do not.
"We already know TypeScript" is not enough for me
I hear this argument often: the team writes TypeScript or Python anyway, so it should define the infrastructure in the same language. At first that is true. A developer understands these lines faster than their HCL counterpart:
if (environment === "production") {
replicas = 4;
}But a familiar language does not automatically make the resulting infrastructure easier to understand. I like constraints in infrastructure. I do not want arbitrary logic running while my environment is being generated, and I want to answer four questions quickly: Which resources are created? Why are they created? What do they depend on? What does this deployment change?
If CDK code makes those questions easier to answer, all is well. If I have to jump through class hierarchies, factory functions and helper libraries to get there, the familiar language has not solved the problem. It has only created a new place to hide it.
State is not a flaw, it is an operating model
Terraform works with state. It links the resources in the configuration to the real objects at the provider, and it has to be treated like production infrastructure: not locally on a laptop, but in a remote backend with access control, encryption and locking, on AWS typically an S3 bucket. Because sensitive values can end up in the state, access to it belongs as tightly restricted as access to production itself.
That is extra operational work, but not an argument against Terraform. Some system has to know which declared resource belongs to which real object. With CDK, CloudFormation takes that on, and it has a real strength there: if a stack update fails, CloudFormation rolls the stack back to the last working state. An interrupted terraform apply, by contrast, leaves part of the changes in place, and the next run has to finish the rest. In return, CloudFormation brings limits of its own, such as 500 resources per stack, plus a bootstrap stack per account and region before CDK can deploy anything at all.
So the question is not whether I want state or no state, but which state and deployment model I want to operate. With Terraform that model lies open in front of me. I see that as an advantage rather than a drawback.
When AWS is not the whole world
This is probably the strongest structural difference. CDK is built around AWS. Terraform works through providers and uses the same language to manage Cloudflare, GitHub, Datadog, Grafana, Kubernetes, SaaS services and other clouds.
That does not mean everything belongs in a single state, quite the opposite. But I use the same language, the same workflows and the same tooling across different infrastructure boundaries. And in real projects AWS is rarely the whole infrastructure: DNS sits at Cloudflare, repositories at GitHub, monitoring at Datadog, the status page somewhere else. Anyone who wants to manage that as code as well will find Terraform's provider model a real advantage.
Portability, on the other hand, is a weaker argument than it often sounds. Just because I can describe AWS and Azure with Terraform does not make an AWS application portable. If it is built on DynamoDB, SQS, EventBridge, Lambda, IAM, S3 and CloudFront, HCL does not turn it into a cloud-independent platform. Terraform makes my tool multi-provider, not my architecture provider-independent. I would not choose it for a hypothetical multi-cloud future.
Where CDK wins: the internal platform and the pure AWS product
Now let me turn the scenario around. A company runs 80 services, several teams are supposed to get the same infrastructure, and every service needs ECS or Lambda, IAM, logging, tracing, alarms, dashboards, DNS, TLS, deployment, tags and security defaults. The platform department does not want every team assembling these building blocks itself.
That is where CDK becomes very strong:
new CompanyService(this, "Billing", {
type: ServiceType.Api,
exposure: Exposure.Internal,
scaling: { min: 2, max: 10 },
});This construct applies the company standards automatically: security, tags, observability, networking, standard alarms, deployment conventions. I am no longer just abstracting AWS resources, I am encoding an internal platform standard. At this point the programming language is no longer a gimmick, it is the tool for a platform product. For me, that is the best use case for CDK.
I see the second one at the other end of the size scale: a small team, a new AWS-native product, everything on AWS, developers who write TypeScript every day anyway, and a system made of many Lambda functions, EventBridge rules and Step Functions. When constructs express domain building blocks, language and infrastructure model fit together well:
new InvoiceProcessingPipeline(this, "Invoices", { ... });
new CustomerEventConsumer(this, "CustomerEvents", { ... });
new ScheduledImport(this, "NightlyImport", { ... });I would still draw a line there: the constructs must not become a second application layer. Business logic belongs in the application, CDK describes the infrastructure it runs on.
Every abstraction is a product
The internal construct sounds like a one-off investment. It is software, with versions, dependencies, tests, breaking changes, documentation, migrations, an owner and a release process. Once 40 teams use it, the platform department owns a product. That is fine, as long as it is treated like one.
A poorly maintained internal CDK framework can become worse than duplicated infrastructure. Then every application is stuck on an old construct version because nobody wants to risk the upgrade, and the abstraction that was meant to create speed turns into a legacy system itself. I would therefore only build extensive internal constructs once the organization is ready to own them for the long term.
The problem is not specific to CDK. Terraform can escalate into an internal module architecture too, and eventually there is company-ecs-service-v17, company-rds-v9, company-vpc-v12 and company-observability-v6, and nobody knows which combination works together. My rule is the same for both tools: I only abstract once I really know the recurring pattern. The first two implementations may resemble each other. By the third, I usually know what is actually the same and what only happened to look similar. That is when the abstraction pays off.
I review the effect, not the lines
A CDK construct turns a hundred lines of CloudFormation into ten lines of TypeScript, a Terraform module turns three hundred lines of resources into twenty lines of configuration. That is not automatically a win. The metric that matters more to me is: how quickly does another engineer understand what this infrastructure actually does? If they have to jump through five repositories for that, the abstraction has become too expensive. Infrastructure runs for a long time, often longer than its original author stays on the project. So I do not optimize it for few lines, but for comprehensibility during changes and incidents.
That includes seeing every change before it is deployed. With Terraform, terraform plan is a matter of course in the pipeline; with CDK it is cdk diff, which shows the CloudFormation changes that will actually happen. What gets reviewed is not the source code but its effect. Because this line
enableFeature();can replace ten resources internally, and this one
engine_version = "17"can affect the database the business depends on. Infrastructure as code does not make infrastructure harmless. It makes its changes versionable and reproducible, and only that makes them reviewable at all.
Existing environments: make them visible first
When I take over infrastructure that has existed for years, the first thing I care about is transparency. What exists? What belongs together? What was created by hand, what may be changed, and where is there drift?
Here I lean even more towards Terraform, because I can move the existing estate into an explicit resource model piece by piece: import blocks bring an existing resource under management, and terraform plan then shows whether code and reality match. CDK can do this too with cdk import and cdk migrate, but the route goes through CloudFormation and feels less direct to me.
I do not try to lay a perfect platform model over it straight away. First make it visible, then reproducible, then simpler, and only after that abstract. It is the same order I prefer with legacy code.
How I introduce Terraform into a grown environment, from the first import to an empty plan, is on its own page.
What to do when code and console drift apart again afterwards is covered in Terraform drift: when console and code diverge.
The decision as a table
| Question | Terraform | AWS CDK |
|---|---|---|
| Normal AWS infrastructure | usually my default | also well suited |
| Explicit resource definition | very strong | depends on the construct |
| General-purpose programming language | deliberately no | yes |
| AWS coverage | very good | very good, straight from CloudFormation |
| Other providers and SaaS | major strength | not the core model |
| Own platform building blocks | modules | constructs particularly powerful |
| Deriving IAM from relationships | by hand | grant methods |
| Risk of over-engineering | present | higher through program logic |
| State | Terraform state, self-operated | CloudFormation stack with rollback |
| Taking over existing infrastructure | very comfortable for me | possible, not my default |
| Large internal AWS platform | good | this is where CDK gets interesting |
| Team knows TypeScript or Python | less relevant | eases the start |
I still would not make a decision from this table alone.
The question I start with
When someone says "We should use CDK because then we can program", I ask back: which infrastructure complexity do we want to encapsulate with this programming language?
If a concrete answer follows, such as "We have 60 services and want to give every team the same secure, observable ECS service as a platform building block", CDK can be an excellent fit. If the answer is "We already know TypeScript", that is not enough for me. Conversely, "Terraform is the industry standard" is not an architecture argument for me either. The tool has to fit the organization and the problem.
For a normal AWS project I therefore keep starting with Terraform. I get an explicit infrastructure model, manage AWS and the other platforms with the same tool, can review every change well, and modules give me enough reuse without building an internal framework straight away.
I prefer CDK when the organization is a step further along: many similar AWS workloads, real platform ownership, recurring standards, constructs that encapsulate them sensibly, and a team that then runs those constructs like a long-lived software product. Then CDK delivers something Terraform modules can only approximate: a programmable internal cloud platform. Before that, I usually do not need this power.
I do not choose infrastructure as code by how few lines I have to write. I choose the tool that lets the next person understand fastest what actually exists in production.

