Skip to content
Unverified — AI-generated content. Help verify this page

Terraform vs Pulumi vs CDK vs Crossplane

Infrastructure as Code (IaC) has moved from a best practice to a hard requirement. The question is no longer "should we codify infrastructure?" but "which tool should we use?" Each of these four tools takes a fundamentally different approach: Terraform uses a custom DSL, Pulumi uses general-purpose languages, CDK generates CloudFormation, and Crossplane uses Kubernetes as the control plane. This comparison helps you make the right call.

Overview

ToolMaintainerFirst ReleaseApproachPrimary Language
TerraformHashiCorp (IBM)2014Declarative DSL (HCL)HCL
PulumiPulumi Corp2018Imperative with declarative engineTypeScript, Python, Go, C#, Java
AWS CDKAmazon2019Imperative, compiles to CloudFormationTypeScript, Python, Java, C#, Go
CrossplaneUpbound (acquired by CNCF)2018Kubernetes-native CRDsYAML (Kubernetes manifests)

License Matters

Terraform switched from MPL to the BSL (Business Source License) in August 2023, prompting the creation of OpenTofu (Linux Foundation fork). If open-source licensing is a concern for your organization, consider OpenTofu as a drop-in replacement for Terraform.

Architecture Comparison

Feature Matrix

FeatureTerraformPulumiAWS CDKCrossplane
LanguageHCL (custom DSL)TS, Python, Go, C#, Java, YAMLTS, Python, Java, C#, GoYAML (K8s manifests)
Cloud supportMulti-cloud (3,000+ providers)Multi-cloud (bridged TF providers)AWS only (CDKTF for multi)Multi-cloud (providers)
State managementRemote backends (S3, TF Cloud, etc.)Pulumi Cloud, S3, localCloudFormation (managed by AWS)etcd (Kubernetes)
State lockingBuilt-in (DynamoDB, TF Cloud)Built-in (Pulumi Cloud)Handled by CloudFormationKubernetes resource locks
Drift detectionterraform planpulumi previewCloudFormation drift detectionContinuous reconciliation
Import existingterraform importpulumi importcdk importManual CRD creation
Modules / reuseModules (TF Registry)Packages (npm, PyPI, etc.)Constructs (Construct Hub)Compositions / XRDs
Testingterraform test, TerratestNative unit tests (Jest, pytest)CDK Assertions, JestKubernetes-native testing
Policy as codeSentinel (paid), OPACrossGuard (built-in)cdk-nag (open-source)OPA Gatekeeper
Preview / planterraform planpulumi previewcdk diffN/A (continuous)
SecretsState (encrypted at rest)First-class secret encryptionSSM / Secrets ManagerK8s Secrets / External Secrets
CI/CD integrationTF Cloud, Atlantis, SpaceliftPulumi Deployments, GitHub ActionsCDK Pipelines (built-in)GitOps (ArgoCD / Flux)
Learning curveLow (HCL is simple)Medium (need to learn SDK)Medium (need CDK constructs)High (need K8s knowledge)
IDE supportVS Code extension, LSPFull IDE support (native lang)Full IDE support (native lang)K8s YAML tooling
CostFree (OSS) / TF Cloud paidFree (OSS) / Pulumi Cloud paidFreeFree (OSS) / Upbound paid
LicenseBSL 1.1 (was MPL)Apache 2.0Apache 2.0Apache 2.0

Code & Config Comparison

Provisioning an S3 Bucket + Lambda Function

Terraform:

hcl
# main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket = "my-tf-state"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_s3_bucket" "data" {
  bucket = "my-app-data-${var.environment}"

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "data" {
  bucket = aws_s3_bucket.data.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_lambda_function" "processor" {
  function_name = "data-processor-${var.environment}"
  runtime       = "nodejs20.x"
  handler       = "index.handler"
  role          = aws_iam_role.lambda_role.arn
  filename      = data.archive_file.lambda.output_path

  environment {
    variables = {
      BUCKET_NAME = aws_s3_bucket.data.id
    }
  }
}

resource "aws_iam_role" "lambda_role" {
  name = "lambda-role-${var.environment}"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

Pulumi (TypeScript):

typescript
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';

const config = new pulumi.Config();
const environment = config.require('environment');

const bucket = new aws.s3.Bucket('data', {
  bucket: `my-app-data-${environment}`,
  versioning: { enabled: true },
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
});

const lambdaRole = new aws.iam.Role('lambdaRole', {
  assumeRolePolicy: JSON.stringify({
    Version: '2012-10-17',
    Statement: [{
      Action: 'sts:AssumeRole',
      Effect: 'Allow',
      Principal: { Service: 'lambda.amazonaws.com' },
    }],
  }),
});

const processor = new aws.lambda.Function('processor', {
  functionName: `data-processor-${environment}`,
  runtime: aws.lambda.Runtime.NodeJS20dX,
  handler: 'index.handler',
  role: lambdaRole.arn,
  code: new pulumi.asset.FileArchive('./lambda'),
  environment: {
    variables: {
      BUCKET_NAME: bucket.id,
    },
  },
});

// Export outputs
export const bucketName = bucket.id;
export const functionArn = processor.arn;

CDK (TypeScript):

typescript
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class DataStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const environment = this.node.tryGetContext('environment') || 'dev';

    const bucket = new s3.Bucket(this, 'DataBucket', {
      bucketName: `my-app-data-${environment}`,
      versioned: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    const processor = new lambda.Function(this, 'Processor', {
      functionName: `data-processor-${environment}`,
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda'),
      environment: {
        BUCKET_NAME: bucket.bucketName,
      },
    });

    // CDK handles IAM automatically
    bucket.grantRead(processor);

    new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
  }
}

Crossplane:

yaml
# composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: data-pipeline
spec:
  compositeTypeRef:
    apiVersion: platform.example.com/v1alpha1
    kind: DataPipeline
  resources:
    - name: bucket
      base:
        apiVersion: s3.aws.upbound.io/v1beta1
        kind: Bucket
        spec:
          forProvider:
            region: us-east-1
          providerConfigRef:
            name: aws-provider
      patches:
        - fromFieldPath: spec.environment
          toFieldPath: metadata.labels.environment
    - name: bucket-versioning
      base:
        apiVersion: s3.aws.upbound.io/v1beta1
        kind: BucketVersioning
        spec:
          forProvider:
            versioningConfiguration:
              - status: Enabled
---
# claim.yaml
apiVersion: platform.example.com/v1alpha1
kind: DataPipeline
metadata:
  name: my-pipeline
spec:
  environment: production

CDK Advantage: Automatic IAM

Notice how CDK generates IAM policies automatically with bucket.grantRead(processor). In Terraform and Pulumi, you must manually wire IAM roles and policies — a common source of errors and security misconfigurations.

Testing

Terraform (built-in terraform test):

hcl
# tests/s3.tftest.hcl
run "creates_bucket_with_versioning" {
  command = plan

  assert {
    condition     = aws_s3_bucket_versioning.data.versioning_configuration[0].status == "Enabled"
    error_message = "Bucket versioning must be enabled"
  }
}

Pulumi (native unit test with Jest):

typescript
import * as pulumi from '@pulumi/pulumi';
import { describe, it, expect } from 'vitest';

// Mock Pulumi runtime
pulumi.runtime.setMocks({
  newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
  call: (args) => args.inputs,
});

describe('DataStack', () => {
  it('creates a versioned S3 bucket', async () => {
    const { bucket } = await import('./index');
    const versioning = await new Promise<boolean>((resolve) =>
      bucket.versioning.apply((v) => resolve(v?.enabled ?? false))
    );
    expect(versioning).toBe(true);
  });
});

CDK (CDK Assertions):

typescript
import { Template } from 'aws-cdk-lib/assertions';
import { App } from 'aws-cdk-lib';
import { DataStack } from './data-stack';

test('creates versioned S3 bucket', () => {
  const app = new App();
  const stack = new DataStack(app, 'TestStack');
  const template = Template.fromStack(stack);

  template.hasResourceProperties('AWS::S3::Bucket', {
    VersioningConfiguration: { Status: 'Enabled' },
  });

  template.resourceCountIs('AWS::Lambda::Function', 1);
});

Performance

Deployment Speed

ScenarioTerraformPulumiCDKCrossplane
First deploy (10 resources)~60s~45s~90s (CFn overhead)~120s (reconciliation)
No-change plan~5s~3s~15s (synth + diff)Continuous
Add 1 resource~15s~12s~45s (CFn changeset)~30s
50-resource stack~3min~2.5min~8min~5min
State refresh2-10s per resource2-10s per resourceN/A (CloudFormation)N/A (continuous)

CloudFormation Bottleneck

CDK compiles to CloudFormation, which has hard limits: 500 resources per stack, slower changeset creation, and occasional rollback storms. For large infrastructure, you must split across stacks, adding complexity.

State Management Performance

AspectTerraformPulumiCDKCrossplane
State size (100 resources)~500 KB JSON~500 KB JSONN/A (CloudFormation)N/A (etcd)
State lockingDynamoDB, Consul, TF CloudPulumi Cloud, built-inCloudFormation handlesK8s resource versioning
Concurrent operationsOne at a time per workspaceOne at a time per stackOne per CFn stackUnlimited (reconcile loop)
State migrationterraform state mv/rmpulumi state commandsStack refactoringK8s resource management

Developer Experience

Strengths

Terraform:

  • HCL is purpose-built for infrastructure: readable, auditable, diffable
  • 3,000+ providers in the Terraform Registry
  • Massive community, Stack Overflow answers for everything
  • Terraform Cloud offers team workflows, policy enforcement, cost estimation

Pulumi:

  • Real programming language means loops, conditionals, abstractions, testing
  • Reuse existing package managers (npm, pip, Go modules)
  • IDE autocompletion, type checking, and refactoring tools
  • CrossGuard for policy-as-code using your preferred language

CDK:

  • L2/L3 constructs abstract away CloudFormation complexity
  • Automatic IAM policy generation
  • cdk synth produces reviewable CloudFormation templates
  • CDK Pipelines for self-mutating CI/CD

Crossplane:

  • Continuous reconciliation (Kubernetes controller pattern)
  • Self-healing: drift is automatically corrected
  • Platform teams can build self-service abstractions via Compositions
  • GitOps-native: ArgoCD/Flux manage infrastructure like apps

Pain Points

ToolFrustration
TerraformHCL limitations (no real loops, string interpolation quirks); state file conflicts in teams
PulumiSmaller ecosystem than Terraform; bridged providers sometimes lag
CDKCloudFormation limits (500 resources/stack); slow deployments; AWS-only (without CDKTF)
CrossplaneSteep learning curve; requires running K8s cluster; YAML verbosity

When to Use Which

Decision Summary

ScenarioRecommended Tool
Multi-cloud, large team, wide ecosystem neededTerraform
Team wants TypeScript/Python, strong testingPulumi
AWS-only shop, want automatic IAMAWS CDK
Platform engineering, self-service infraCrossplane
Open-source license is a hard requirementOpenTofu or Pulumi
Existing Terraform codebase, want real languageCDKTF (CDK for Terraform)
GitOps-native, K8s cluster already existsCrossplane
Solo developer, simple infrastructureTerraform

Migration

Terraform to Pulumi

Pulumi provides an official converter:

bash
# 1. Install Pulumi
curl -fsSL https://get.pulumi.com | sh

# 2. Convert Terraform HCL to Pulumi
pulumi convert --from terraform --language typescript

# 3. Import existing Terraform state
pulumi import --from terraform ./terraform.tfstate

# 4. Review generated code
# The converter handles ~80% of cases; complex modules
# and dynamic blocks may need manual adjustment

# 5. Verify with preview
pulumi preview

CDK to Terraform (via CDKTF)

bash
# 1. Install CDKTF CLI
npm install -g cdktf-cli

# 2. Initialize CDKTF project
cdktf init --template=typescript

# 3. Use existing CDK constructs with adaptations
# CDKTF uses Terraform providers, not CloudFormation resources
# Rewrite L2/L3 constructs to CDKTF equivalents

# 4. Synthesize Terraform JSON
cdktf synth

# 5. Deploy
cdktf deploy

Terraform to OpenTofu

bash
# OpenTofu is a drop-in replacement for Terraform <= 1.5
# 1. Install OpenTofu
brew install opentofu

# 2. Replace terraform binary references
alias terraform=tofu

# 3. Initialize
tofu init

# 4. Verify
tofu plan
# Output should be identical to terraform plan

Migration Complexity

Terraform to OpenTofu is trivial (same HCL, same state format). Terraform to Pulumi is moderate (automated converter exists). CDK to anything else is painful (CloudFormation constructs do not map 1:1 to Terraform resources).

Verdict

Terraform (or OpenTofu) remains the industry standard for multi-cloud infrastructure. Its massive ecosystem of 3,000+ providers, ubiquitous community knowledge, and battle-tested production use at every scale make it the safe default. HCL's limitations are real but manageable.

Pulumi is the strongest choice for teams that want real programming language power — loops, conditionals, type safety, unit tests, and IDE support. It bridges most Terraform providers, so ecosystem coverage is nearly equivalent.

AWS CDK excels when your world is AWS. The automatic IAM policy generation alone saves hours of error-prone policy writing. However, the CloudFormation bottleneck limits its scalability for very large infrastructures.

Crossplane is the future for platform engineering teams building self-service infrastructure on Kubernetes. Its continuous reconciliation model eliminates drift by design. The trade-off is requiring a Kubernetes cluster and a steep learning curve.

Bottom Line

Start with Terraform/OpenTofu if you have no strong preference — it is the lingua franca of IaC. Choose Pulumi if your team values TypeScript/Python and testing. Pick CDK for AWS-only shops that want high-level abstractions. Adopt Crossplane only when you are building a platform engineering team on Kubernetes.

Which Would You Choose?

Scenario 1: You are a platform engineer at a 500-person company. You need to provide self-service infrastructure for 20 product teams across AWS and GCP. Teams should request resources without knowing Terraform or Pulumi.

Recommendation: Crossplane

Crossplane lets you build self-service abstractions (Compositions) that product teams consume via simple Kubernetes YAML claims. Teams request a "DatabaseCluster" without knowing which cloud provider or configuration is used underneath. The Kubernetes reconciliation loop ensures drift is automatically corrected.

Scenario 2: Your team of 5 backend developers writes TypeScript all day. You need to provision AWS infrastructure and want to write unit tests for your infrastructure the same way you test application code.

Recommendation: Pulumi

Pulumi lets your team use TypeScript, npm packages, and Vitest/Jest to define and test infrastructure. No new language to learn (HCL), no separate toolchain. You can write expect(bucket.versioning).toBe(true) in a unit test alongside your application tests.

Scenario 3: You are a DevOps consultant who works with 10 different clients on AWS, GCP, Azure, and DigitalOcean. You need a tool that works everywhere with the broadest community support.

Recommendation: Terraform (or OpenTofu)

Terraform's 3,000+ providers and massive community make it the lingua franca of multi-cloud IaC. Every cloud, every SaaS tool, and every infrastructure vendor has a Terraform provider. The knowledge transfers across every client engagement.

Common Misconceptions

  • "Terraform's BSL license means you cannot use it" — The BSL restricts competitive hosted services, not end-user usage. You can still use Terraform freely for your own infrastructure. If the license concerns you, OpenTofu is a drop-in BSD-licensed fork.
  • "Pulumi is just Terraform with TypeScript" — Pulumi's architecture is different: it uses a deployment engine that can bridge Terraform providers but also has native providers. The real advantage is not just the language but testing, IDE support, and package management.
  • "CDK locks you into AWS" — CDKTF (CDK for Terraform) lets you use CDK constructs with Terraform providers, supporting multi-cloud. However, native CDK is indeed AWS-only.
  • "Crossplane replaces Terraform" — Crossplane complements Terraform for platform teams. It is not a general-purpose IaC tool; it is a Kubernetes-native control plane for infrastructure self-service.

Real Migration Stories

Loom: Terraform to Pulumi — Loom migrated from Terraform to Pulumi to leverage TypeScript for their infrastructure. Their primary motivation was testing — they wanted to unit test infrastructure configurations the same way they tested application code. The pulumi convert tool handled ~80% of the migration automatically.

Mercedes-Benz: Terraform to Crossplane — Mercedes-Benz adopted Crossplane to build a self-service platform for hundreds of development teams. Instead of each team writing Terraform, platform engineers created Crossplane Compositions that teams consume via simple YAML claims, reducing infrastructure provisioning from days to minutes.

Quiz

1. What is the key difference between Terraform's state management and Crossplane's?

Terraform stores state in a JSON file (remote backend like S3). Crossplane uses Kubernetes etcd as its state store — the desired state is a Kubernetes resource, and the controller continuously reconciles actual state to match.

2. Why does CDK generate CloudFormation instead of calling AWS APIs directly?

CDK uses CloudFormation as its deployment engine to get rollback support, dependency ordering, and change set previews. The tradeoff is CloudFormation's limits (500 resources per stack, slower deployments).

3. What does "continuous reconciliation" mean in Crossplane?

Crossplane controllers run in a loop, comparing the desired state (Kubernetes CRDs) with the actual state (cloud resources). If someone manually changes a cloud resource, Crossplane automatically reverts it to match the desired state. This eliminates infrastructure drift by design.

4. How does Pulumi achieve type safety for infrastructure?

Pulumi generates typed SDKs from provider schemas. When you write new aws.s3.Bucket(...) in TypeScript, the constructor parameters are fully typed with IntelliSense, and the compiler catches invalid configurations before deployment.

5. What is OpenTofu, and why does it exist?

OpenTofu is a Linux Foundation fork of Terraform 1.5.x, created after HashiCorp changed Terraform's license from MPL to BSL in August 2023. It is a drop-in replacement with the same HCL syntax and state format, maintained under a permissive open-source license.

One-Liner Summary

Terraform is the industry-standard multi-cloud DSL, Pulumi brings real programming languages and testing, CDK automates AWS IAM, and Crossplane turns Kubernetes into a self-service infrastructure control plane.

"What I cannot create, I do not understand." — Richard Feynman