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
| Feature | Pulumi | SST | CDKTF |
|---|---|---|---|
| Language | TS, Python, Go, C# | TypeScript | TS, Python, Go, C# |
| Cloud support | Multi-cloud (100+) | AWS only | Multi-cloud (Terraform) |
| State management | Pulumi Cloud/self-hosted | AWS (CloudFormation) | Terraform state |
| Full-stack apps | Manual | ✅ (Next.js, Remix, Astro) | Manual |
| Live debugging | ❌ | ✅ (sst dev) | ❌ |
| Resource linking | Manual | ✅ (automatic) | Manual |
| Preview/diff | ✅ (pulumi preview) | ✅ (sst diff) | ✅ (cdktf diff) |
| Component model | ComponentResource | Built-in constructs | Constructs |
| Provider count | 100+ | AWS only | 3000+ (Terraform) |
| Secrets | ✅ (built-in encryption) | ✅ (via SSM) | ✅ (Terraform) |
| Policy-as-code | ✅ (CrossGuard) | ❌ | ✅ (Sentinel) |
| Import existing | ✅ | ❌ | ✅ |
| Pricing | Free tier + paid | Free (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).