If you build backends in TypeScript, the standard advice for infrastructure is to go learn HCL and write Terraform. That advice ages badly the moment you have to pass a value from your application into a Terraform variable, keep two type systems in sync by hand, or explain to an AI agent why the code it just wrote has no matching infrastructure.
The good news: you can stay in TypeScript. There are four credible approaches, and the differences between them run deeper than syntax. This guide covers Pulumi, AWS CDK, CDK for Terraform (CDKTF), and Encore, with real code for each and notes on how each one feels day to day.
| Pulumi | AWS CDK | CDKTF | Encore | |
|---|---|---|---|---|
| What you write | Infra program in TS | Infra program in TS | Infra program in TS | App code with inline resources |
| Engine under it | Pulumi engine + providers | CloudFormation | Terraform / OpenTofu | Encore + your cloud APIs |
| Clouds | AWS, GCP, Azure, k8s, 100+ | AWS only | Anything Terraform supports | AWS, GCP (your account) |
| State | Pulumi Cloud or self-managed | CloudFormation (managed by AWS) | Terraform state file | None you manage |
| Separate from app code | Yes | Yes | Yes | No, it is the app code |
| License | Apache 2.0 | Apache 2.0 | MPL 2.0 | MPL 2.0 (open source) |
The first three sit in the same family: a dedicated infrastructure program, written in TypeScript, that produces cloud resources. Encore sits in a different family, and the last section explains why that distinction matters more than the syntax.
Pulumi was the tool that made "real programming language IaC" mainstream. You write a normal TypeScript program, import provider packages, and construct resources as objects. The Pulumi CLI diffs the desired state against actual state and applies the changes.
import * as aws from "@pulumi/aws";
const db = new aws.rds.Instance("orders-db", {
engine: "postgres",
engineVersion: "16",
instanceClass: "db.t3.micro",
allocatedStorage: 20,
dbName: "orders",
username: "app",
manageMasterUserPassword: true,
});
const topic = new aws.sns.Topic("order-placed");
export const dbEndpoint = db.endpoint;
What works well: you get loops, conditionals, functions, and npm packages, so factoring out a reusable component is just writing a class. The multi-cloud story is real. The same program can provision AWS, GCP, Azure, Cloudflare, and Kubernetes resources side by side, which is hard to do cleanly with anything else here.
The friction is the async value model. Resource outputs are Output<T>, not plain values, because they aren't known until apply time. You compose them with .apply() and pulumi.interpolate, which feels foreign the first week. State lives in Pulumi Cloud by default, or a backend you host (S3, GCS, local). That is one more system to run and secure. The free tier covers individuals; team features are paid.
Good fit for: teams deploying across multiple clouds, or anyone who wants a full imperative language for infrastructure and is comfortable running a state backend.
The AWS Cloud Development Kit is Amazon's own TypeScript IaC. You define "constructs," synthesize them to a CloudFormation template, and CloudFormation does the actual provisioning. Because AWS owns both ends, the construct library is deep and the high-level (L2/L3) constructs bake in sensible defaults.
import * as cdk from "aws-cdk-lib";
import * as rds from "aws-cdk-lib/aws-rds";
import * as sns from "aws-cdk-lib/aws-sns";
import * as ec2 from "aws-cdk-lib/aws-ec2";
export class OrdersStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2 });
new rds.DatabaseInstance(this, "OrdersDb", {
engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_16,
}),
vpc,
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.MICRO,
),
databaseName: "orders",
});
new sns.Topic(this, "OrderPlaced");
}
}
What works well: no separate state to manage, because CloudFormation tracks it inside AWS. Drift detection, rollback, and change sets come from CloudFormation for free. The L2 constructs handle things like IAM policies and security groups that you would otherwise wire up by hand, so a DatabaseInstance comes with a working networking and credentials story.
The costs are also real. CDK is AWS-only, so it is not an option if you deploy anywhere else. You inherit CloudFormation's rough edges: slow deploys on large stacks, occasional stuck states that need manual recovery in the console, and a 500-resource-per-stack ceiling that pushes you into stack-splitting. The abstraction can also hide what gets created, and cdk synth output is verbose to audit.
Good fit for: teams committed to AWS who want first-party constructs and no external state backend.
CDKTF is the bridge for teams that want to keep Terraform's engine and provider ecosystem but write TypeScript instead of HCL. You author constructs the same way as AWS CDK, but they synthesize to Terraform JSON and run through terraform (or tofu) with a normal state file.
Note before you adopt it: HashiCorp archived CDKTF on December 10, 2025, and no longer maintains or develops the project. The repository is read-only, no further updates or compatibility fixes ship, and HashiCorp recommends migrating to standard Terraform HCL (or AWS CDK). It remains usable and MPL 2.0 licensed, but it is a frozen codebase.
import { App, TerraformStack } from "cdktf";
import { Construct } from "constructs";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { DbInstance } from "@cdktf/provider-aws/lib/db-instance";
import { SnsTopic } from "@cdktf/provider-aws/lib/sns-topic";
class OrdersStack extends TerraformStack {
constructor(scope: Construct, id: string) {
super(scope, id);
new AwsProvider(this, "aws", { region: "us-east-1" });
new DbInstance(this, "orders-db", {
engine: "postgres",
engineVersion: "16",
instanceClass: "db.t3.micro",
allocatedStorage: 20,
dbName: "orders",
username: "app",
manageMasterUserPassword: true,
});
new SnsTopic(this, "order-placed");
}
}
const app = new App();
new OrdersStack(app, "orders");
app.synth();
What works well: you keep the entire Terraform provider registry, which is the broadest in the industry, and any existing Terraform state and modules keep working. If your org already runs Terraform in CI but your app engineers dislike HCL, CDKTF lets the two coexist.
The friction is that you are now debugging two layers. When something breaks, the question is whether it is your TypeScript, the synthesized JSON, or Terraform itself, and stack traces don't always make that obvious. The provider bindings are generated from Terraform schemas, so the types are accurate but not idiomatic, and docs often send you back to the HCL-oriented Terraform provider pages anyway. CDKTF has also historically trailed the other tools in polish and release pace.
Good fit for: teams already invested in Terraform with an existing CDKTF codebase who want to keep a TypeScript authoring layer over their state and provider ecosystem. Because the project is archived, it is hard to recommend for a new adoption. If you are weighing this against staying in HCL, see the CDKTF guide and Terraform vs Pulumi.
The three tools above share one shape: infrastructure is a separate program, in a separate file or repo, that you run with a separate CLI, against a separate state. Your application code has no idea the infrastructure exists, and the infrastructure has no idea how the app uses it. You keep the two in sync by hand.
Encore collapses that split. You declare a database, Pub/Sub topic, cron job, or object storage bucket inline in your backend code, and Encore provisions the real cloud resources from those declarations. This is infrastructure from code rather than infrastructure as code, and the practical difference is that there is no second program to maintain.
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { Topic } from "encore.dev/pubsub";
// Provisions managed Postgres (RDS on AWS, Cloud SQL on GCP, Docker locally).
const db = new SQLDatabase("orders", { migrations: "./migrations" });
// Provisions Pub/Sub (SNS+SQS on AWS, Pub/Sub on GCP, NSQ locally).
export const orderPlaced = new Topic<{ orderID: string }>("order-placed", {
deliveryGuarantee: "at-least-once",
});
export const create = api(
{ method: "POST", path: "/orders", expose: true },
async (req: { customerID: string }) => {
const row = await db.queryRow<{ id: string }>`
INSERT INTO orders (customer_id) VALUES (${req.customerID})
RETURNING id
`;
if (!row) throw new Error("insert failed");
await orderPlaced.publish({ orderID: row.id });
return row;
},
);
The same code runs locally with encore run (it starts a real Postgres and message queue for you), spins up a preview environment per pull request, and deploys to production AWS or GCP. There is no Pulumi program, no CDK stack, no Terraform state file. The db object your handler queries is the same declaration that provisioned the database, so the type of the resource and the use of the resource cannot drift apart.
That tight coupling is also the limitation. Encore's primitives cover application-level infrastructure: databases, queues, cron, object storage, secrets, and the services around them. It is not a general-purpose cloud resource compiler. If you need to configure a VPC peering arrangement, a custom IAM boundary policy, or a fleet of raw EC2 instances, Encore doesn't model that, and you would reach for Pulumi, CDK, or Terraform for those pieces. Many teams do exactly that, running Encore for the app-shaped infrastructure and a traditional IaC tool for the rest.
Encore Cloud provisions into your own AWS or GCP account, so the resources are yours. It is open source (over 12,000 GitHub stars, MPL 2.0), used in production by teams including Groupon, and encore build docker produces a standalone image if you decide to leave.
One more thing worth calling out for 2026: because the infrastructure declarations live in the application code, an AI agent editing the codebase sees them. Ask it to add an endpoint that writes to a queue, and it uses the existing Topic declaration instead of guessing at a separate Terraform file it can't see. The separate-program tools structurally can't offer that, because the context is split across two codebases.
Choose Pulumi if you deploy across multiple clouds, want a full imperative language with npm packages for infrastructure, and are comfortable operating a state backend.
Choose AWS CDK if you are all-in on AWS, want Amazon's first-party constructs and CloudFormation-managed state, and never need to provision outside AWS.
Choose CDKTF if your organization already runs Terraform, has an existing CDKTF codebase, and wants to keep TypeScript engineers contributing without learning HCL. For new projects, weigh the fact that HashiCorp archived CDKTF in December 2025 and no longer maintains it.
Choose Encore if you are building a new TypeScript backend, most of your "infrastructure" is databases, queues, cron, and storage, and you would rather declare those inline than maintain a parallel infrastructure program. Pair it with one of the above if you also have low-level cloud resources to manage.
The split is roughly: Pulumi, CDK, and CDKTF answer "how do I write my existing IaC in TypeScript." Encore answers "what if the backend code and its infrastructure were the same thing." Which question is the right one depends on whether you are maintaining an infrastructure estate or building an application.
Deploy a TypeScript backend with Postgres and Pub/Sub to your own AWS or GCP account, no separate IaC program required.
Yes. Pulumi, AWS CDK, and CDKTF all let you define infrastructure in TypeScript. Encore takes a different route: you declare resources inline in your application code and it provisions them. None of these require learning HCL.
AWS CDK is the better fit if you are all-in on AWS and want CloudFormation-backed deployments with no extra state to manage. Pulumi is better if you deploy across AWS, GCP, Azure, or Kubernetes, or want a single tool across clouds.
CDK for Terraform (CDKTF) lets you write Terraform configurations in TypeScript instead of HCL. It synthesizes to Terraform JSON and runs through the Terraform (or OpenTofu) engine, so you keep the provider ecosystem and state model while writing TS. HashiCorp archived CDKTF on December 10, 2025, so it is no longer maintained.
Pulumi and CDK are standalone IaC tools: you write infrastructure programs separate from your application. Encore is infrastructure from code: you declare a database or Pub/Sub topic inline in your backend, and it provisions the resources into your own AWS or GCP account. There is no separate IaC program or state file.
Pulumi and CDKTF are multi-cloud. AWS CDK is AWS-only. Encore provisions into your own AWS or GCP account and is open source, and `encore build docker` produces a standalone image, so you keep the underlying resources if you leave.