TL;DR: For most multi-team platforms, Terraform remains the right default — it's cloud-agnostic, has the deepest provider ecosystem, and its declarative HCL is easier to review across teams than general-purpose code. Reach for Pulumi when your organization is engineering-heavy and wants real loops, types, and unit tests in infrastructure code. Reach for CDK only if you're genuinely AWS-only and want opinionated high-level constructs. The tool matters less than getting state ownership, module boundaries, and drift detection right — get those wrong and it won't matter which tool you picked.

Picking an infrastructure-as-code (IaC) tool is usually framed as a syntax preference — HCL vs. TypeScript vs. Python. That framing misses what actually breaks at scale: state ownership across teams, how changes get reviewed and rolled back, and what happens when someone makes a manual change in the console. This article works through the tool comparison, then spends more time than most comparisons do on the organizational architecture question, because that's where multi-team platforms actually run into trouble.

The three tools, briefly

Terraform (HashiCorp, now under IBM) is declarative HCL, cloud-agnostic by design, and has the largest module and provider ecosystem of the three — every major cloud plus dozens of SaaS platforms (Datadog, PagerDuty, Cloudflare) have first-class providers. Its declarative model means the plan/apply diff is legible to a reviewer who didn't write the code, which matters enormously once you have more than one team touching infrastructure.

Pulumi lets you write infrastructure in TypeScript, Python, Go, or C#, giving you real control flow, functions, and standard testing frameworks instead of HCL's more limited expression language. Teams with strong software engineering discipline like this — infrastructure code that can be unit-tested the same way application code is. The trade-off is real: general-purpose languages make it possible to write infrastructure code that's clever in ways HCL structurally prevents, and "clever" is rarely what you want in code that provisions production.

AWS CDK compiles down to CloudFormation and gives you high-level constructs (an ApplicationLoadBalancedFargateService in a few lines instead of assembling a dozen primitive resources by hand) that encode AWS's own best practices. It's the right call only when you're AWS-only and want to move fast on well-trodden patterns — the moment you need a second cloud, or a resource CDK's construct library doesn't model well, you're fighting the abstraction.

DimensionTerraformPulumiAWS CDK
LanguageHCL (declarative)TS/Python/Go/C#TS/Python/Java/C# → CloudFormation
Multi-cloudBest-in-classGoodAWS-only
Review legibilityHigh (declarative diff)Depends on code disciplineModerate (CFN diff)
Learning curve for new hiresLow-moderateLow if they know the languageModerate (AWS-specific patterns)
TestabilityLimited (native)Native unit testingNative unit testing
State backendSelf-managed (S3, TFC, etc.)Pulumi Cloud or self-hostedCloudFormation-managed

The decision that actually matters more: state ownership

Whichever tool you pick, the question that determines whether a multi-team setup stays healthy is: who owns which piece of state, and how is a cross-team dependency expressed? Three patterns show up in practice, in increasing order of maturity:

Monolithic state. One state file, one team (usually platform/infra) applies all changes on behalf of everyone else via tickets or PRs. Simple to reason about, but it doesn't scale past a handful of product teams — the platform team becomes a bottleneck, and a bad apply in one team's resources can lock state for everyone.

Workspace-per-environment, state-per-team. Each product team owns a root module and state file for their own resources (their services, their databases, their queues), with shared foundational infrastructure (VPCs, IAM boundaries, shared clusters) living in a separate, platform-owned state that teams reference via remote state data sources or explicit outputs. This is the sweet spot for most organizations above ~3 product teams: teams can apply their own changes without touching anyone else's, and the blast radius of any single apply is scoped to what that team owns.

Fully modular with a platform-provided abstraction layer. The platform team publishes versioned modules (a "service" module that wires up compute, networking, logging, and IAM in one call) that product teams consume as black boxes, with the underlying primitives never touched directly by product teams. This is where the most mature platform engineering orgs end up — it trades some flexibility for a drastically smaller surface area that product teams can misconfigure, and it's the pattern that makes "self-service infrastructure" actually safe rather than just fast.

Remote state backends need locking regardless of which pattern you choose — an S3 backend with DynamoDB-based state locking (Terraform) or Pulumi Cloud's managed locking prevents two concurrent applies from corrupting the same state file, which is the single most common cause of "someone's Tuesday afternoon apply broke everyone's Wednesday morning."

Module boundaries: the real API design problem

A shared module is an API. Product teams are its callers. Treat its inputs and outputs with the same discipline you'd apply to a public API: semantic versioning, a changelog, and a deprecation window before breaking changes land — not because Terraform enforces this (it doesn't), but because a badly versioned shared module is how one team's Tuesday refactor becomes every other team's Wednesday incident.

Concretely: pin module version constraints (version = "~> 2.3" rather than unpinned >= 0), require a PR against the module repo with a changelog entry for anything that changes an input's meaning, and never let "just update the module in place" be a valid workflow for a breaking change. This is more process discipline than tooling — Pulumi and CDK don't solve it either, because the underlying problem is organizational, not syntactic.

Drift: the failure mode every tool shares

All three tools assume the state file (or CloudFormation stack, for CDK) is the source of truth — and all three break the same way when reality diverges from it: someone makes a manual console change during an incident, a Lambda auto-scales a setting the tool doesn't track, or a resource gets deleted out-of-band. The next plan/apply either silently reverts the manual fix (bad, if the fix was legitimate) or errors out confusingly (annoying, but safer).

Two practices reduce how often this bites you:

  • Scheduled drift detection, independent of your deploy pipeline — a nightly terraform plan (or pulumi preview) against production that alerts on any non-empty diff, so drift surfaces within a day rather than at the next unrelated deploy.
  • A documented "break glass" process for manual changes during incidents, including a mandatory follow-up PR to reconcile the manual change back into code within a set window (24–48 hours is typical) — otherwise the manual fix becomes permanent undocumented drift.
hcl
# example: importing a manually-created resource back into state
# after a break-glass change, rather than letting it drift silently
terraform import aws_security_group_rule.emergency_fix sg-0abc123_ingress_tcp_443

CI/CD integration: where the tool choice shows up daily

The tool comparison above matters most at design time; day to day, what teams actually feel is how the tool integrates into CI/CD. A few practical differences worth planning around:

Plan-then-apply review gates. Terraform's plan output is a readable diff of resource-level changes that maps naturally onto a PR comment (via terraform plan posted by a bot like Atlantis or a native CI integration) — a reviewer can approve "add 2 subnets, modify 1 security group rule" without reading the underlying HCL closely. Pulumi's preview gives an equivalent diff; CDK's cdk diff does too, but because it's diffing synthesized CloudFormation, the diff is sometimes a step removed from the source TypeScript that produced it, which makes review slightly harder for reviewers who aren't fluent in the construct library.

Secrets and provider credentials in CI. All three need cloud credentials available to the CI runner, ideally via short-lived, OIDC-federated tokens (GitHub Actions' OIDC provider issuing temporary AWS/GCP/Azure credentials) rather than long-lived static keys stored as CI secrets. This is tool-agnostic advice, but it's worth stating explicitly because it's the single most common security gap in IaC pipelines regardless of which tool sits on top.

Concurrency control across pipelines. If multiple PRs can trigger applies against the same state concurrently, you need pipeline-level locking (a queue, or a mutex on the state key) in addition to the tool's own state locking — the tool-level lock prevents corruption, but a pipeline-level queue prevents two applies from racing to apply conflicting, individually-valid plans that produce an inconsistent final state when interleaved.

yaml
# simplified example: Atlantis-style plan/apply gate in CI
on:
  pull_request:
    paths: ["infra/**"]
jobs:
  plan:
    steps:
      - run: terraform init
      - run: terraform plan -out=tfplan
      - run: terraform show -json tfplan | post-as-pr-comment
  apply:
    if: github.event.review.state == 'approved'
    needs: plan
    concurrency: terraform-state-${{ inputs.environment }}
    steps:
      - run: terraform apply tfplan

Cost visibility as a first-class concern

Whichever tool you choose, plug cost estimation into the same review gate that shows the resource diff — tools like Infracost (Terraform-native, with growing Pulumi support) annotate a PR with the dollar delta of a proposed change before it's approved, not after it shows up on next month's cloud bill. For a multi-team platform, this closes a gap that's otherwise invisible until finance asks why spend jumped: a product team adding a larger instance class or an always-on NAT gateway looks identical to any other 3-line diff unless the review surface makes the cost delta explicit.

A decision framework

Given a multi-team platform, work through these in order:

  1. Are you single-cloud, forever? If genuinely and permanently AWS-only, CDK is worth evaluating for the abstraction quality. If there's any chance of multi-cloud, GCP workloads, or even heavy use of non-AWS SaaS providers, that rules CDK out fast.
  2. Does your organization have strong software engineering discipline across the teams that will write infrastructure code? If yes and you want real testing and code reuse patterns, Pulumi is worth the learning investment. If infrastructure is written by a broader mix of skill levels, Terraform's more constrained declarative model is a feature, not a limitation — it's harder to write code that's too clever.
  3. Default to Terraform if 1 and 2 don't strongly point elsewhere — the ecosystem depth and review legibility advantages compound over years, and migrating off it later (while painful) is a well-trodden path with tooling support, unlike migrating off a bespoke Pulumi program architecture.

Whatever you pick, the module-boundary and state-ownership decisions above matter more to a multi-team platform's long-term health than the tool choice itself.


Syslabs' engineering team designs multi-team infrastructure-as-code platforms as part of our DevOps & CI/CD work — happy to talk through your specific state-ownership and module architecture.

Sources: Terraform vs Pulumi vs CDK in 2026 — DEV Community, HashiCorp Terraform documentation, Pulumi: Best Terraform Alternatives, Handling Terraform State in Multi-Environment Deployments