Skip to main content

Guide

Pulumi vs SST vs CDKTF (2026)

Compare Pulumi, SST, and CDKTF for infrastructure as code in JavaScript and TypeScript. Cloud deployment, serverless, AWS CDK, and which IaC tool to use in.

·PkgPulse Team·
0

TL;DR

Pulumi is the general-purpose IaC platform — define cloud infrastructure in TypeScript, Python, Go, supports all major clouds, state management, policy-as-code, the multi-cloud IaC leader. SST is the serverless framework for AWS — deploy Next.js, APIs, and databases to AWS with minimal config, live Lambda debugging, built-in constructs, purpose-built for full-stack apps. CDKTF (CDK for Terraform) brings AWS CDK's construct model to Terraform — write TypeScript constructs that generate Terraform HCL, leverage the massive Terraform provider ecosystem. In 2026: Pulumi for multi-cloud TypeScript IaC, SST for deploying full-stack apps to AWS, CDKTF for TypeScript + Terraform ecosystem.

Key Takeaways

  • Pulumi: @pulumi/pulumi ~200K weekly downloads — multi-cloud, TypeScript/Python/Go, state management
  • SST: sst ~50K weekly downloads — AWS, full-stack, Next.js/Remix, live Lambda debugging
  • CDKTF: cdktf ~50K weekly downloads — Terraform providers, TypeScript constructs, HCL output
  • Pulumi supports AWS, Azure, GCP, Kubernetes, and 100+ providers
  • SST has the best experience for deploying full-stack JavaScript apps to AWS
  • CDKTF leverages all existing Terraform providers and modules

Pulumi

Pulumi — multi-cloud IaC:

Basic AWS infrastructure

import * as pulumi from "@pulumi/pulumi"
import * as aws from "@pulumi/aws"

// S3 bucket:
const bucket = new aws.s3.Bucket("pkgpulse-assets", {
  website: {
    indexDocument: "index.html",
    errorDocument: "404.html",
  },
})

// Lambda function:
const fn = new aws.lambda.Function("api-handler", {
  runtime: "nodejs20.x",
  handler: "index.handler",
  code: new pulumi.asset.AssetArchive({
    ".": new pulumi.asset.FileArchive("./dist"),
  }),
  environment: {
    variables: {
      BUCKET_NAME: bucket.id,
    },
  },
})

// API Gateway:
const api = new aws.apigatewayv2.Api("http-api", {
  protocolType: "HTTP",
})

const integration = new aws.apigatewayv2.Integration("lambda-integration", {
  apiId: api.id,
  integrationType: "AWS_PROXY",
  integrationUri: fn.arn,
})

// Outputs:
export const bucketUrl = bucket.websiteEndpoint
export const apiUrl = api.apiEndpoint

Multi-cloud

import * as pulumi from "@pulumi/pulumi"
import * as aws from "@pulumi/aws"
import * as gcp from "@pulumi/gcp"
import * as cloudflare from "@pulumi/cloudflare"

// AWS — database:
const db = new aws.rds.Instance("pkgpulse-db", {
  engine: "postgres",
  instanceClass: "db.t4g.micro",
  allocatedStorage: 20,
  dbName: "pkgpulse",
})

// GCP — storage:
const bucket = new gcp.storage.Bucket("pkgpulse-backups", {
  location: "US",
  uniformBucketLevelAccess: true,
})

// Cloudflare — DNS:
const record = new cloudflare.Record("api-record", {
  zoneId: process.env.CLOUDFLARE_ZONE_ID!,
  name: "api",
  type: "CNAME",
  value: "api.pkgpulse.com",
  proxied: true,
})

Component resources

import * as pulumi from "@pulumi/pulumi"
import * as aws from "@pulumi/aws"

// Reusable component:
class StaticSite extends pulumi.ComponentResource {
  public readonly url: pulumi.Output<string>

  constructor(name: string, args: { domain: string }, opts?: pulumi.ComponentResourceOptions) {
    super("pkgpulse:StaticSite", name, {}, opts)

    const bucket = new aws.s3.Bucket(`${name}-bucket`, {
      website: { indexDocument: "index.html" },
    }, { parent: this })

    const cdn = new aws.cloudfront.Distribution(`${name}-cdn`, {
      origins: [{
        domainName: bucket.bucketRegionalDomainName,
        originId: "s3",
      }],
      enabled: true,
      defaultRootObject: "index.html",
      defaultCacheBehavior: {
        viewerProtocolPolicy: "redirect-to-https",
        allowedMethods: ["GET", "HEAD"],
        cachedMethods: ["GET", "HEAD"],
        targetOriginId: "s3",
        forwardedValues: { queryString: false, cookies: { forward: "none" } },
      },
      restrictions: { geoRestriction: { restrictionType: "none" } },
      viewerCertificate: { cloudfrontDefaultCertificate: true },
    }, { parent: this })

    this.url = cdn.domainName
    this.registerOutputs({ url: this.url })
  }
}

// Usage:
const site = new StaticSite("pkgpulse", { domain: "pkgpulse.com" })
export const siteUrl = site.url

SST

SST — full-stack AWS framework:

Next.js deployment

// sst.config.ts
export default $config({
  app(input) {
    return {
      name: "pkgpulse",
      removal: input.stage === "production" ? "retain" : "remove",
      home: "aws",
    }
  },
  async run() {
    // Deploy Next.js to AWS:
    const site = new sst.aws.Nextjs("PkgPulse", {
      domain: "pkgpulse.com",
      environment: {
        DATABASE_URL: db.properties.connectionString,
      },
    })

    return { url: site.url }
  },
})

Full-stack app

// sst.config.ts
export default $config({
  app(input) {
    return { name: "pkgpulse", home: "aws" }
  },
  async run() {
    // Database:
    const db = new sst.aws.Postgres("Database", {
      scaling: { min: "0.5 ACU", max: "4 ACU" },
    })

    // Storage:
    const bucket = new sst.aws.Bucket("Assets")

    // API:
    const api = new sst.aws.Function("Api", {
      handler: "packages/api/src/index.handler",
      url: true,
      link: [db, bucket],
      environment: {
        STAGE: $app.stage,
      },
    })

    // Cron job:
    new sst.aws.Cron("DailySync", {
      schedule: "rate(1 day)",
      job: {
        handler: "packages/jobs/src/sync.handler",
        link: [db],
      },
    })

    // Frontend:
    const site = new sst.aws.Nextjs("Web", {
      path: "packages/web",
      link: [api],
      domain: "pkgpulse.com",
    })

    return {
      api: api.url,
      site: site.url,
    }
  },
})

Linked resources

// In your Lambda function code:
import { Resource } from "sst"

// Type-safe resource access:
const dbUrl = Resource.Database.connectionString
const bucketName = Resource.Assets.name

// No manual env var wiring needed — SST links resources automatically

Live Lambda debugging

# Start live development:
npx sst dev

# → Deploys to AWS
# → Proxies Lambda invocations to your local machine
# → Set breakpoints in VS Code
# → See logs in real-time
# → Hot reload on file changes

CDKTF

CDKTF — CDK for Terraform:

Basic infrastructure

import { Construct } from "constructs"
import { App, TerraformStack, TerraformOutput } from "cdktf"
import { AwsProvider } from "@cdktf/provider-aws/lib/provider"
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket"
import { LambdaFunction } from "@cdktf/provider-aws/lib/lambda-function"
import { IamRole } from "@cdktf/provider-aws/lib/iam-role"

class PkgPulseStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id)

    new AwsProvider(this, "aws", { region: "us-east-1" })

    // S3 bucket:
    const bucket = new S3Bucket(this, "assets", {
      bucket: "pkgpulse-assets",
    })

    // IAM role:
    const role = new IamRole(this, "lambda-role", {
      name: "pkgpulse-lambda-role",
      assumeRolePolicy: JSON.stringify({
        Version: "2012-10-17",
        Statement: [{
          Action: "sts:AssumeRole",
          Effect: "Allow",
          Principal: { Service: "lambda.amazonaws.com" },
        }],
      }),
    })

    // Lambda:
    const fn = new LambdaFunction(this, "api", {
      functionName: "pkgpulse-api",
      runtime: "nodejs20.x",
      handler: "index.handler",
      role: role.arn,
      filename: "dist/lambda.zip",
      environment: {
        variables: { BUCKET_NAME: bucket.id },
      },
    })

    new TerraformOutput(this, "bucket-name", { value: bucket.id })
    new TerraformOutput(this, "function-arn", { value: fn.arn })
  }
}

const app = new App()
new PkgPulseStack(app, "pkgpulse")
app.synth()

Terraform providers

import { CloudflareProvider } from "@cdktf/provider-cloudflare/lib/provider"
import { Record } from "@cdktf/provider-cloudflare/lib/record"
import { DataCloudflareZone } from "@cdktf/provider-cloudflare/lib/data-cloudflare-zone"

// Use any Terraform provider:
new CloudflareProvider(this, "cloudflare", {
  apiToken: process.env.CLOUDFLARE_API_TOKEN,
})

const zone = new DataCloudflareZone(this, "zone", {
  name: "pkgpulse.com",
})

new Record(this, "api-record", {
  zoneId: zone.id,
  name: "api",
  type: "CNAME",
  value: "api.pkgpulse.com",
  proxied: true,
})

Reusable constructs

import { Construct } from "constructs"
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket"
import { CloudfrontDistribution } from "@cdktf/provider-aws/lib/cloudfront-distribution"

class StaticSite extends Construct {
  public readonly url: string

  constructor(scope: Construct, id: string, props: { domain: string }) {
    super(scope, id)

    const bucket = new S3Bucket(this, "bucket", {
      bucket: `${props.domain}-static`,
    })

    const cdn = new CloudfrontDistribution(this, "cdn", {
      origin: [{
        domainName: bucket.bucketRegionalDomainName,
        originId: "s3",
      }],
      enabled: true,
      defaultRootObject: "index.html",
      defaultCacheBehavior: {
        allowedMethods: ["GET", "HEAD"],
        cachedMethods: ["GET", "HEAD"],
        targetOriginId: "s3",
        viewerProtocolPolicy: "redirect-to-https",
        forwardedValues: { queryString: false, cookies: { forward: "none" } },
      },
      restrictions: { geoRestriction: { restrictionType: "none" } },
      viewerCertificate: { cloudfrontDefaultCertificate: true },
    })

    this.url = cdn.domainName
  }
}

// Usage:
const site = new StaticSite(this, "site", { domain: "pkgpulse.com" })

CLI workflow

# Initialize:
cdktf init --template=typescript

# Synthesize (TypeScript → Terraform JSON):
cdktf synth

# Plan:
cdktf diff

# Deploy:
cdktf deploy

# Destroy:
cdktf destroy

Feature Comparison

FeaturePulumiSSTCDKTF
LanguageTS, Python, Go, C#TypeScriptTS, Python, Go, C#
Cloud supportMulti-cloud (100+)AWS onlyMulti-cloud (Terraform)
State managementPulumi Cloud/self-hostedAWS (CloudFormation)Terraform state
Full-stack appsManual✅ (Next.js, Remix, Astro)Manual
Live debugging✅ (sst dev)
Resource linkingManual✅ (automatic)Manual
Preview/diff✅ (pulumi preview)✅ (sst diff)✅ (cdktf diff)
Component modelComponentResourceBuilt-in constructsConstructs
Provider count100+AWS only3000+ (Terraform)
Secrets✅ (built-in encryption)✅ (via SSM)✅ (Terraform)
Policy-as-code✅ (CrossGuard)✅ (Sentinel)
Import existing
PricingFree tier + paidFree (open-source)Free (open-source)

When to Use Each

Use Pulumi if:

  • Need multi-cloud infrastructure (AWS + GCP + Azure)
  • Want a mature IaC platform with policy enforcement
  • Need to manage complex infrastructure at scale
  • Want full programming language features in your IaC

Use SST if:

  • Deploying full-stack JavaScript apps to AWS
  • Want live Lambda debugging during development
  • Building serverless apps with Next.js, Remix, or Astro
  • Want automatic resource linking (type-safe)

Use CDKTF if:

  • Already using Terraform or want access to Terraform providers
  • Need the massive Terraform module/provider ecosystem
  • Want TypeScript constructs that output Terraform HCL
  • Migrating from HCL Terraform to TypeScript

State Management and Drift Detection

Infrastructure drift — where your live cloud resources diverge from what your code describes — is one of the hardest operational problems in IaC, and each tool handles it differently. Pulumi stores state in the Pulumi Cloud backend by default (or in S3/GCS/Azure Blob for self-hosted setups). State is a JSON document that maps resource URNs to their last-known configuration. When you run pulumi preview, Pulumi queries live cloud APIs and diffs against stored state. Resources created outside Pulumi are invisible to this diff unless explicitly imported. The pulumi import command handles this: it brings existing cloud resources under Pulumi management by generating the resource code and updating state simultaneously. For organizations migrating from click-ops or CloudFormation, this import story is critical.

SST's state is entirely managed by AWS CloudFormation under the hood — SST Ion synthesizes CloudFormation stacks and delegates drift detection to CloudFormation's own mechanisms. This means SST benefits from CloudFormation's mature change set system and stack event history, but inherits its limitations: CloudFormation drift detection is slow (it polls every resource individually), and stack deletion can fail on resources with CloudFormation deletion protection or cross-stack dependencies. The upside is that AWS Console users can inspect SST-deployed stacks in the CloudFormation UI without any Pulumi-specific tooling.

CDKTF defers entirely to Terraform's state model, which is the most battle-tested in the industry. Terraform state can live in S3 with DynamoDB locking, Terraform Cloud, or any of a dozen remote backend providers. The cdktf diff command maps directly to terraform plan — same algorithm, same accuracy, same handling of edge cases. For teams with existing Terraform state from HCL configurations, CDKTF can import those configurations and manage them alongside new TypeScript constructs by pointing at the same state file. This migration path from HCL Terraform to CDKTF is one of CDKTF's most practical advantages over starting fresh with Pulumi.

Cost Management and Multi-Environment Patterns

Production infrastructure always involves multiple environments (dev, staging, production), and each tool has different idioms for managing environment-specific configuration. Pulumi uses stacks — a named deployment of a Pulumi program. pulumi stack init dev and pulumi stack init prod create isolated stacks with separate state files. Configuration per-stack is set via pulumi config set dbInstanceType db.t4g.micro --stack dev and read at runtime via pulumi.Config. This explicit stack-per-environment model maps cleanly to branch-based deployment workflows: a GitHub Actions pipeline can deploy to the dev stack on feature branch push and the prod stack on merge to main.

SST's equivalent is the stage concept baked directly into the framework. sst dev deploys to a personal development stage (typically named after your username), sst deploy --stage production deploys to production. Resources are namespaced by stage automatically — your pkgpulse-dev and pkgpulse-prod stacks never collide. The removal option in sst.config.ts lets you set retain for production resources and remove for dev resources, preventing accidental deletion of production data when tearing down a development environment.

CDKTF models this with multiple stack instances — you create a PkgPulseStack for dev and another for prod, passing environment-specific props. The cdktf.json configuration can specify multiple apps, or you can parameterize a single stack class with a stage prop and instantiate it twice. Teams using Terraform Workspaces can continue that pattern in CDKTF, or switch to the multiple-stack model which offers stronger isolation at the cost of more code.

Secret Management and Credential Security Across IaC Tools

How each tool handles secrets — database passwords, API keys, TLS certificates — is one of the most consequential architectural differences for production deployments. Pulumi's built-in secret management encrypts secrets at rest in the Pulumi state file using AES-256-GCM. When you mark a value as a secret with pulumi.secret(value), it is stored encrypted in state and any output derived from it is automatically marked secret as well. The Pulumi Cloud backend manages encryption keys by default, but self-hosted deployments can use AWS KMS, Azure Key Vault, or GCP KMS for key management. Critically, Pulumi tracks secret taint through the dependency graph: if a Lambda's environment variable is derived from a secret database password, the variable's value in state is also encrypted, preventing accidental exposure in logs.

SST's secret handling uses AWS Systems Manager Parameter Store as the backing store. Defining a secret with new sst.Secret("DbPassword") creates a Parameter Store entry under the SST namespace. The value is set with sst secret set DbPassword "..." and injected into linked resources at deploy time. Crucially, SST's type-safe resource linking means you can never accidentally reference the wrong secret or forget to link it — TypeScript catches missing resource references at compile time. In development, sst dev reads the same SSM parameters as production, avoiding the common "works in prod, broken in dev" secret mismatch.

CDKTF inherits Terraform's approach to secrets, which is both mature and nuanced. Sensitive values passed to Terraform resources must be marked with the sensitive attribute to prevent them from appearing in terraform plan output. In CDKTF, you annotate sensitive outputs using TerraformOutput's sensitive property. For secret values stored externally, CDKTF's access to the full Terraform provider ecosystem means you can use the AWS Secrets Manager provider, HashiCorp Vault provider, or 1Password provider to read secrets at plan time without storing them in state. This "read secrets from the source" pattern is the most secure Terraform idiom and carries directly into CDKTF.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on Pulumi v3.x, SST v3.x (Ion), and CDKTF v0.20.x.

Compare DevOps tooling and cloud libraries on PkgPulse →

See also: AVA vs Jest and Terraform vs OpenTofu vs CDKTF: IaC 2026, Coolify vs CapRover vs Dokku (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.