All Insights
Cloud & DevOps10 min read

Terraform vs CDK vs Pulumi 2026 for a 5-Person Platform Team

  • Terraform
  • Pulumi
  • AWS CDK
  • Infrastructure as Code
  • Platform Engineering
Terraform vs CDK vs Pulumi 2026 for a 5-Person Platform Team

I have run Terraform, CDK, and Pulumi with a five person platform team in anger. We lived through fast moving product changes, multi account AWS, and compliance gates. This is the comparison I wish I had up front, with the repo shapes and guardrails that avoided pager duty at 2 a.m.

Where Each Tool Fit Best In Practice

  • Terraform. Strong when infra spans clouds and vendors. Clear plans. Widest module ecosystem. Your team pays a tax in glue code and composition. Best if the app teams do not need to touch IaC.
  • CDK. Strong for AWS heavy workloads and serverless. Fast feedback for developers because it lives in their language. You adopt the AWS way, which is a feature and a constraint. Great if most engineers are comfortable with TypeScript or Python.
  • Pulumi. Strong middle ground. Multi cloud support with a real language. Great testability. Previews are good, sometimes noisy with large objects. Best when you want infra in the same language as services without locking to AWS abstractions.

In our last two builds, Terraform owned the shared platform layers, CDK owned serverless app stacks, and Pulumi ran infra for a data platform where rich tests mattered. A five person team can run one primary tool and tolerate a small satellite if the boundaries are strict.

If you are choosing one standard this quarter, make that call inside an architecture review and lock it with repo scaffolding, CI, and policy from day one. A small team does not have time to retrofit discipline later.

Repo Shapes That Stayed Maintainable At 5 People

You can make any of these tools hurt if the repo is flat or hand rolled. These layouts survived real changes, with about 40 PRs a week and two concurrent deploys most days.

Terraform mono with modules per domain:

infra/
  modules/
    vpc/
    eks/
    rds/
  envs/
    prod/
      networking/
      compute/
      data/
    staging/
      networking/
      compute/
      data/
  global/
    org/
      accounts.tf
      sso.tf
  • One module per domain, versioned via git tags. No nested modules inside env folders.
  • One state per folder under envs. Remote backend, DynamoDB lock table, state name includes workspace.
  • Everything that touches AWS Organizations or SSO lives in global with its own pipeline and strict reviewers.

CDK multi app, one repo per bounded context:

app-infra/
  packages/
    shared-constructs/
    service-a-infra/
    service-b-infra/
  cdk.json
  package.json
  • Reuse lives in shared constructs. Each service package synthesizes independent stacks.
  • Stacks map 1:1 to accounts and regions. CDK context has explicit account and region, never read from your current CLI profile in CI.

Pulumi project per domain, stacks per env:

data-infra/
  Pulumi.yaml
  Pulumi.prod.yaml
  Pulumi.staging.yaml
  src/
    index.ts
    components/
  • Each project deploys one cohesive slice, for example data lake or ML platform.
  • Config and secrets live in stack yaml files. Use the Pulumi service backend for state. Never use local state for shared infra.

We used codeowners to enforce a minimum of two reviewers on envs and global, and one reviewer on modules or components. That kept the bus factor above one without blocking velocity.

Multi Account Patterns That Did Not Melt Down

You will run into cross account access early. These patterns worked.

Terraform provider alias per account with role assumption:

terraform {
  required_version = ">= 1.6"
  backend "s3" {
    bucket         = "company-tfstate"
    key            = "networking/prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.region
}

provider "aws" {
  alias  = "prod"
  region = var.region
  assume_role {
    role_arn     = "arn:aws:iam::${var.prod_account_id}:role/InfraDeployRole"
    session_name = "tf-prod"
  }
}

module "vpc" {
  source    = "../../modules/vpc"
  providers = { aws = aws.prod }
  name      = "prod-core"
  cidr      = "10.20.0.0/16"
}

CDK target account and region per stack, do not rely on defaults:

new MyServiceStack(app, 'svc-prod', {
  env: { account: process.env.PROD_ACCOUNT_ID, region: 'us-east-1' },
});

Pulumi stacks per account, with config keys for account and region. Use AWS provider options in the program to assume roles when needed.

Avoid sharing providers or contexts across stacks. That is where blast radius gets large.

Policy As Code That Actually Blocked Bad Changes

We tried three routes, and ended with a hybrid.

  • Terraform in Terraform Cloud with Sentinel for org level policies. Clear, central place to block public S3, enforce tags, and limit instance sizes. Works best when all applies run in TFC. If you self host applies, use OPA with Conftest or Checkov in CI.
  • CDK with cdk-nag and org level Service Control Policies. cdk-nag catches many issues early, but you still need a downstream guardrail because anyone can bypass a warning.
  • Pulumi with Policy as Code, TypeScript policy packs. This gave us the same early feedback as cdk-nag and the same hard stop as Sentinel when using the Pulumi service.

A real example of Pulumi policy that paid for itself in the first week:

// policy-pack/index.ts
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";

new PolicyPack("org-security", {
  policies: [
    {
      name: "no-public-s3",
      description: "Disallow public S3 buckets",
      enforcementLevel: "mandatory",
      validate: validateResourceOfType(aws.s3.Bucket, (bucket, args, report) => {
        const acl = bucket.acl || "private";
        if (acl !== "private") {
          report("S3 bucket ACL must be private");
        }
        if (bucket.publicRead || bucket.website) {
          report("S3 bucket must not be publicly readable");
        }
      }),
    },
  ],
});

For Terraform, the equivalent lived as a Sentinel rule in Terraform Cloud, and we mirrored it with Checkov so PRs failed before TFC.

Whatever tool you pick, run static checks in PR, then enforce in the apply system. Relying on one layer is how exceptions slip through on Fridays.

Testability That Caught Real Bugs

  • Terraform. Terratest caught ordering and data source mistakes, but unit testing pure HCL is limited. We used Terratest for the top 10 risky modules, and plan snapshots for the rest. It added about 6 minutes to PRs on average.
  • CDK. We wrote jest tests for constructs with the assertions library. That gave us fast, deterministic checks on naming, encryption flags, and IAM statement shapes. These ran in under 2 seconds per suite.
  • Pulumi. We used language unit tests for components, and a weekly integration run with the Automation API in a throwaway account. That found drift in IAM policies that previews did not show.

Small but real example, CDK jest test for an S3 construct:

import { Template } from 'aws-cdk-lib/assertions';
import { App } from 'aws-cdk-lib';
import { LogsBucket } from '../src/logs-bucket';

test('bucket is encrypted and private', () => {
  const app = new App();
  const stack = new LogsBucket(app, 'test');
  const template = Template.fromStack(stack);
  template.hasResourceProperties('AWS::S3::Bucket', {
    BucketEncryption: {
      ServerSideEncryptionConfiguration: [{
        ServerSideEncryptionByDefault: { SSEAlgorithm: 'aws:kms' }
      }]
    },
    AccessControl: 'Private'
  });
});

For Terraform, a Terratest snippet that saved us from a bad variable default:

func TestVpcTags(t *testing.T) {
  t.Parallel()
  opts := &terraform.Options{
    TerraformDir: "../../modules/vpc",
    Vars: map[string]interface{}{
      "name": "test",
      "cidr": "10.10.0.0/16",
    },
  }
  defer terraform.Destroy(t, opts)
  terraform.InitAndApply(t, opts)
  tags := terraform.OutputMap(t, opts, "tags")
  require.Equal(t, "true", tags["cost-center-set"]) // enforce policy driven tag
}

Do not try to test everything. Test the expensive mistakes, like IAM wildcards, encryption, and cross account roles.

CI Pipelines That Kept Plans Reviewable

  • Always render a plan or preview into the PR. Terraform with Atlantis or Spacelift was the most readable. Pulumi comments were fine. CDK needs a synth step then a diff, so we posted both.
  • Concurrency. Cap concurrent applies per environment. We used one at a time for prod, two for staging, unbounded for dev. That avoided state lock contention.
  • Secrets. Never export cloud credentials as repo or org secrets. Use OIDC to assume roles on the fly.

Example GitHub Actions job for Terraform plan with OIDC:

name: tf-plan
on:
  pull_request:
    paths:
      - 'infra/**'

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
      - run: terraform -chdir=infra/envs/staging/networking init -upgrade
      - run: terraform -chdir=infra/envs/staging/networking plan -no-color

We standardized the above across repos in a single afternoon. If you need help putting that repeatability in place, our team documents this in engineering process setup deliverables so it survives turnover.

State, Drift, And Scale

  • Terraform state lived in S3 with DynamoDB locking. We shard by folder. We keep state file sizes under 10 MB by splitting stacks when they grow. Plan times stayed under 2 minutes for most changes. The worst stack, EKS plus addons, hit 6 minutes.
  • CDK had predictable synth times, under 10 seconds for most apps. The diff step was noisy if constructs emitted large defaults, so we added higher level constructs to tame the output.
  • Pulumi state lived in the Pulumi service. Previews averaged 30 to 90 seconds. The automation API for integration tests took about 12 minutes to stand up a throwaway environment of 120 resources.

For drift, we scheduled weekly read only applies in staging. Terraform plan, cdk diff, and pulumi preview. Anything that changed without a PR triggered an incident in Slack.

Hiring And Ergonomics In A Small Team

  • If three of five engineers write TypeScript daily, CDK or Pulumi reduce ramp time. They also embed infra into the same tooling as services, which tightens feedback loops.
  • If you have contractors or partners helping with infra, Terraform or OpenTofu lowers coordination cost. It is the lingua franca. You can hand them a module interface and a plan pipeline and be done.
  • If your org cares about centralized policy and audit, Terraform Cloud or Pulumi Cloud give you a faster route to guardrails. CDK leans on AWS native controls, which are excellent but spread across services.

We have seen teams flip from Terraform to CDK for serverless heavy stacks, and from CDK to Terraform when multi cloud becomes real. Avoid lock in to one approach in your org level patterns. Isolate boundaries so you can swap.

Failure Modes And What We Would Change Next Time

  • Terraform modules grew too many input variables. Past 25 inputs, changes stalled. We split modules by lifecycles, for example networking core versus endpoints. That got plans back under 2 minutes and reviews back under 200 lines.
  • CDK constructs leaked AWS defaults that changed under us. We now pin construct library versions and wrap L2 constructs in a company L3 layer with explicit defaults.
  • Pulumi stack config drifted across environments because humans edited yaml by hand. We moved shared config into code and generated per env overlays in CI.

If we started fresh, we would write a thin platform interface for app teams. A single repo template with a make target, a plan comment, and a small quality gate. Then we would choose the tool that matches the platform layer they deploy to. Serverless and event driven in AWS, CDK. Broad vendor mix, Terraform. Data or ML infra that benefits from unit tests in code, Pulumi.

A Simple Decision Filter For This Quarter

  • Mostly AWS, mostly serverless, engineers write TypeScript every day. Choose CDK. Read our notes on pitfalls in our write up on AWS CDK benefits and tradeoffs before you commit.
  • Multi cloud, consultants or multiple teams touch infra, need strongest plan readability. Choose Terraform or OpenTofu. If you want help hardening the pipeline, our cloud devops practice has the runbooks to keep you out of trouble.
  • You want infra in a real language, multi cloud, with strong tests. Choose Pulumi. Pair it with policy packs from day one.

If you need a structured way to decide, we run concise vendor stack decisions workshops that front load tradeoffs and leave you with a repo template, a CI pipeline, and a policy pack.

What To Copy And What To Ignore From This Post

Copy the repo shapes, the multi account provider pattern, and the two layer policy approach. Keep plans in PRs. Cap prod concurrency to one. Test the high risk pieces in the tool that can test them cheaply.

Ignore dogma about one true tool. A five person platform team wins with boring, well lit paths, not with purity. Pick one primary, isolate blast radius, and automate the paved road. Then go build product.

Techlusion
TechlusionAI-native product engineering partner
Share

Need help with Cloud & DevOps?

Talk to Khelan, Founder & CTO, about cloud & devops on a free 45-minute call.

Book Your Free Call