# Infrastructure as code does not mean Terraform. CDK, Pulumi, and the question that actually decides it

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.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision split. Who owns the infrastructure? Application team that already writes TypeScript: CDK if all-AWS, Pulumi if multi-cloud or non-AWS. Separate platform team serving many app teams and languages: Terraform, or OpenTofu. Below each branch, the reasons: same language, same repo, shared types versus one language for all infra, declarative reviewable plans, largest provider ecosystem.">
<defs><marker id="arrI" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9aa3c7"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="300" y="20" width="300" height="50" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="450" y="50" text-anchor="middle" fill="#f1f3ff" font-weight="700">who owns the infrastructure?</text>
<line x1="380" y1="72" x2="230" y2="108" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrI)"/><line x1="520" y1="72" x2="670" y2="108" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrI)"/>
<rect x="40" y="110" width="380" height="120" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="230" y="134" text-anchor="middle" fill="#4fffb0" font-weight="700">the application team</text><text x="230" y="156" text-anchor="middle" fill="#f1f3ff">already writes TypeScript · same repo · same review</text><text x="230" y="176" text-anchor="middle" fill="#f1f3ff">shared types between app and infra</text><text x="230" y="204" text-anchor="middle" fill="#4fffb0" font-weight="700">CDK if all-AWS · Pulumi if not</text><text x="230" y="222" text-anchor="middle" fill="#9aa3c7" font-size="11">a second language is a daily cost for every person</text>
<rect x="480" y="110" width="380" height="120" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="670" y="134" text-anchor="middle" fill="#ffd166" font-weight="700">a separate platform team</text><text x="670" y="156" text-anchor="middle" fill="#f1f3ff">serves many teams in many languages</text><text x="670" y="176" text-anchor="middle" fill="#f1f3ff">wants plans a non-programmer can review</text><text x="670" y="204" text-anchor="middle" fill="#ffd166" font-weight="700">Terraform · OpenTofu</text><text x="670" y="222" text-anchor="middle" fill="#9aa3c7" font-size="11">HCL's limits are a feature here: hard to be too clever</text>
</g>
</svg>
</div>

## 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](/en/blog/cloudformation-500-resource-limit-split-a-stack), slow rollbacks, [replacement semantics](/en/blog/cloudformation-deleted-our-app-runner-services) | 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](/en/blog/aws-three-accounts-one-cdk-codebase) 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 plan` were 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.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three columns rating the tools on the decisive factors. State: Terraform you manage, CDK none to manage, Pulumi service or self-managed. Diff quality: Terraform best, CDK weak on IAM, Pulumi good. Typed abstractions: Terraform modules without types, CDK L2 constructs with grant methods, Pulumi components with types. Clouds: Terraform all, CDK AWS only, Pulumi all. Reads well for non-programmers: Terraform yes, others no.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">The factors that decide it</text>
<g fill="#f1f3ff" font-weight="700"><text x="20" y="56">factor</text><text x="260" y="56" fill="#ffd166">Terraform</text><text x="480" y="56" fill="#4fffb0">CDK</text><text x="700" y="56" fill="#7b8cff">Pulumi</text></g>
<line x1="20" y1="64" x2="880" y2="64" stroke="#2a3150"/>
<text x="20" y="88" fill="#f1f3ff">state to manage</text><text x="260" y="88" fill="#ff6b8a">yes · most incidents live here</text><text x="480" y="88" fill="#4fffb0">none · CloudFormation is the state</text><text x="700" y="88" fill="#ffd166">service, or self-managed</text>
<text x="20" y="112" fill="#f1f3ff">change preview</text><text x="260" y="112" fill="#4fffb0">plan · the gold standard</text><text x="480" y="112" fill="#ff6b8a">diff · weak on IAM</text><text x="700" y="112" fill="#ffd166">preview · good</text>
<text x="20" y="136" fill="#f1f3ff">typed abstractions</text><text x="260" y="136" fill="#ff6b8a">modules, no types</text><text x="480" y="136" fill="#4fffb0">constructs + grant()</text><text x="700" y="136" fill="#4fffb0">components, typed</text>
<text x="20" y="160" fill="#f1f3ff">clouds</text><text x="260" y="160" fill="#4fffb0">all · deepest ecosystem</text><text x="480" y="160" fill="#ff6b8a">AWS only</text><text x="700" y="160" fill="#4fffb0">all</text>
<text x="20" y="184" fill="#f1f3ff">non-programmer can review</text><text x="260" y="184" fill="#4fffb0">yes</text><text x="480" y="184" fill="#ff6b8a">no</text><text x="700" y="184" fill="#ff6b8a">no</text>
<line x1="20" y1="196" x2="880" y2="196" stroke="#2a3150"/>
<text x="450" y="220" text-anchor="middle" fill="#9aa3c7">Green is a strength, red a cost you will pay. None of the columns is all green. Pick by who writes and reads the code.</text>
</g>
</svg>
</div>

## 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](/contact), including when the answer is "the one you have".
