Somewhere around 2019, "infrastructure as code" became a synonym for Terraform in most conversations, the way "search" means Google. It is a reasonable default. It is also the wrong tool for a meaningful share of teams, and the reason is not features, which all three serious options have, but who is going to write and read the code.
We run our client platforms on AWS CDK. We have shipped Terraform and Pulumi for other clients. This is the comparison we wish we had read before choosing, organised around the question that decides it, with the tradeoffs stated plainly and the cases where we would still pick Terraform.
The question
Is infrastructure owned by the application team, or by a separate platform team?
If the people writing the Next.js app are the people writing the infrastructure, they already have a language, a package manager, a test runner and an editor set up for TypeScript. Giving them a second language (HCL) with its own semantics, its own module system and its own testing story is a cost paid every day by every person. CDK or Pulumi lets them define infrastructure in the language they already think in, in the same repository, with the same review process, and share types between the app and the infra.
If infrastructure is owned by a platform team that serves many application teams in many languages, the calculus flips. That team wants one language for all infrastructure regardless of what the apps are written in, a declarative model that is easy to review across hundreds of modules, a huge provider ecosystem, and a plan output that a non-programmer can read in a change ticket. That is Terraform's home ground, and HCL's limitations are a feature there: it is hard to be too clever in it.
Everything else in the comparison follows from that answer.
The three, honestly
| Terraform / OpenTofu | AWS CDK | Pulumi | |
|---|---|---|---|
| Language | HCL, declarative | TypeScript, Python, Java, Go, C#; synthesises to CloudFormation | TypeScript, Python, Go, C#, Java, YAML; its own engine |
| Clouds | All, via providers; the largest ecosystem | AWS only (CDK for Terraform exists, but that is Terraform) | All, via providers, many wrapping Terraform's |
| State | A state file you manage: S3 + locking, or Terraform Cloud | CloudFormation holds it; nothing to manage, nothing to corrupt | Pulumi Cloud, or self-managed S3 |
| Plan / diff | terraform plan: precise, readable, the gold standard |
cdk diff: good for resources, weak for IAM and some property changes |
pulumi preview: good, between the two |
| Abstractions | Modules; composition is verbose, no real types | Constructs with real types; L2 constructs encode best practice (a Queue gets encryption, a Bucket blocks public access) |
Components, real types, plus you can import CDK constructs |
| Testing | Plan assertions, Terratest (Go) | Jest against the synthesised template; fast and precise | Unit tests in-language, mocked |
| Failure mode | State drift; a lock left behind; provider upgrades | CloudFormation's limits: 500 resources per stack, slow rollbacks, replacement semantics | Engine bugs are yours to debug; smaller community |
| Reads well for | Reviewers who do not program | Programmers | Programmers |
| Who is paying | HashiCorp licence changes in 2023; OpenTofu is the community fork | Free; AWS's problem | Free OSS; the cloud service is paid |
Three things in that table decide more choices than the rest combined.
State. Terraform's state file is the source of most Terraform incidents we have been called to: a corrupted state after an interrupted apply, a stale lock, a state that references a resource someone deleted by hand, a migration between backends gone wrong. CDK has no state file; CloudFormation is the state, and it is AWS's problem to keep consistent. That alone removes an entire category of operational work. The price is CloudFormation's own quirks, which are real, but which we have never lost a state file to.
The diff. terraform plan is genuinely the best change preview in the industry, and it is a large part of why platform teams love it. cdk diff is good enough for the daily case and bad for IAM: it shows a policy changed but does not always show how. We compensate with cdk-nag and a policy of pasting the synthesised IAM into the PR for any change that touches it. If your review process is "someone who does not write code reads the plan", Terraform wins here and it is not close.
Abstractions with types. In CDK, new sqs.Queue(this, 'Q') gives you an encrypted queue with sensible defaults, and queue.grantConsumeMessages(fn) writes the IAM policy for you, correctly, with the exact ARN. The equivalent in Terraform is a module someone wrote, or twenty lines of policy JSON you write by hand and get wrong once. That difference compounds across a platform: our CDK codebase for three accounts is about 6,000 lines; the Terraform equivalent we estimated at roughly twice that, before modules.
When we would still pick Terraform
- Multi-cloud with a platform team. One language, one workflow, every provider. Pulumi can do this too, but the Terraform ecosystem of modules and providers is a decade deeper.
- Anything where the reviewer is not a programmer. Compliance-driven environments where the change ticket must contain a plan that an auditor reads. HCL and
terraform planwere built for that. - Non-AWS, single cloud, small team. GCP or Azure teams without a strong TypeScript identity are usually better off with Terraform than with Pulumi, on community size alone.
- A team that already has it and it works. Migrating working Terraform to CDK for aesthetic reasons is a bad trade. We have declined that job twice.
When Pulumi, over CDK
When the team is TypeScript-native and the estate is not purely AWS: a Cloudflare zone, a Vercel project, a Datadog monitor set, a GCP bucket, alongside the AWS resources. CDK stops at the AWS boundary; Pulumi does not. Pulumi also has the better diff of the two and a faster engine than CloudFormation for large stacks. The cost is a smaller community and a state backend you either pay for or run.
What we run, and why
CDK, TypeScript, for every AWS-only client platform. The application teams are TypeScript teams; infrastructure lives in infra/ in the same monorepo as the apps; the API's route types and the infra's environment variable names come from the same packages/types; a change to a queue and its consumer is one PR. No state file, ever. cdk diff pasted into every PR, IAM synthesised and pasted for anything that touches it, cdk-nag in CI, Jest assertions on the templates for the invariants we care about (every bucket blocks public access, every queue has a DLQ, no DESTROY removal policy outside a named teardown stack).
We pay for that with CloudFormation's limits and slowness, which we have written about at length, and with the IAM-diff gap, which the paste-into-PR rule covers. For these teams it is the right trade by a wide margin. For a platform team serving twelve languages across two clouds, we would tell them Terraform, and mean it.
The short version
Infrastructure as code is a practice, not a product. The practice is: infrastructure is in a repository, changes are reviewed, nothing is clicked. The product is chosen by who owns the code. Application team in TypeScript on AWS: CDK. Same team, more than AWS: Pulumi. Platform team, many languages, plans read by non-programmers: Terraform. Working Terraform you already have: keep it.
If you are choosing, or inherited a choice that does not fit the team, we have run all three and will tell you which one fits yours, including when the answer is "the one you have".