Pulumi
Pulumi is an infrastructure-as-code platform that lets you define cloud resources in general-purpose programming languages — TypeScript, Python, Go, C#, and Java — instead of a domain-specific configuration language like HCL. You get real loops, functions, classes, conditionals, and the entire package ecosystem of your language, while Pulumi handles the same desired-state engine that Terraform does: a state file, a diff, and a plan-then-apply workflow. For teams that already live in TypeScript or Python and chafe at the limits of HCL’s expression language, Pulumi trades a declarative DSL for the full power (and the full responsibility) of a real programming language.
How Pulumi works
A Pulumi program is ordinary code that, when run, registers resources with the Pulumi engine. The engine compares the resources your program declares against the recorded state, computes a diff, and shows you a preview before applying. Conceptually this is identical to terraform plan and terraform apply — the difference is that the resource graph is produced by executing your program rather than by parsing static .tf files.
State lives in a backend. Pulumi Cloud is the default managed backend, but you can use a self-managed backend such as an S3 bucket, Azure Blob Storage, GCS, or the local filesystem — directly analogous to Terraform’s remote-state backends. Each set of resources belongs to a stack (for example dev, staging, prod), which is Pulumi’s unit of isolated state and per-environment configuration, roughly equivalent to a Terraform workspace plus a .tfvars file.
A small example
Here is a minimal TypeScript program that provisions an S3 bucket and a public-read object. Notice that it is just code — new aws.s3.BucketV2(...) is a constructor call, and bucket properties are passed as a normal object.
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const bucket = new aws.s3.BucketV2("site", {
tags: { ManagedBy: "pulumi" },
});
const indexObject = new aws.s3.BucketObject("index", {
bucket: bucket.id,
key: "index.html",
contentType: "text/html",
content: "<h1>Hello from Pulumi</h1>",
});
// Exports become stack outputs, like Terraform `output` blocks.
export const bucketName = bucket.bucket;
export const objectUrl = pulumi.interpolate`s3://${bucket.bucket}/${indexObject.key}`;
You preview and apply with the CLI:
pulumi stack init dev
pulumi up
Output:
Type Name Status
+ pulumi:pulumi:Stack site-dev created
+ ├─ aws:s3:BucketV2 site created
+ └─ aws:s3:BucketObject index created
Outputs:
bucketName: "site-9a8b7c6"
objectUrl : "s3://site-9a8b7c6/index.html"
Resources:
+ 3 created
Outputs and dependencies
Resource properties in Pulumi are not plain values — they are Output<T> wrappers, because the real value (an ARN, an ID) is only known after the resource is created. You combine them with .apply() or the pulumi.interpolate template tag, and Pulumi automatically tracks the dependency edges so resources are created in the correct order. This is the programmatic equivalent of Terraform’s implicit dependency graph built from interpolated references.
Loops, functions, and abstractions
The headline benefit over HCL is that abstraction is just programming. Where Terraform offers count, for_each, and modules, Pulumi gives you for loops, functions, and classes (Pulumi calls reusable components ComponentResources).
import * as aws from "@pulumi/aws";
const environments = ["dev", "staging", "prod"];
const logBuckets = environments.map(
(env) =>
new aws.s3.BucketV2(`logs-${env}`, {
tags: { Environment: env },
}),
);
No for_each meta-argument, no toset() gymnastics — it is a .map() over an array. Complex conditionals, shared helper functions, and unit-testable factories all come for free.
Reusing the Terraform provider ecosystem
Pulumi does not maintain a parallel universe of providers from scratch. Most Pulumi providers are bridged from their Terraform equivalents: the @pulumi/aws package is generated from the Terraform AWS provider, so resource names, arguments, and behavior map closely to what Terraform users already know (BucketV2 ↔ aws_s3_bucket). Pulumi can also consume any Terraform provider directly via pulumi package add terraform-provider <name>, and pulumi convert can translate existing HCL into a Pulumi program to ease migration.
Because providers are bridged from Terraform, provider bugs, lifecycle quirks, and even some documentation gaps are inherited. When debugging odd behavior, check the upstream Terraform provider’s issues — the root cause often lives there.
Trade-offs vs HCL
| Dimension | HCL (Terraform / OpenTofu) | Pulumi |
|---|---|---|
| Language | Declarative DSL | TypeScript, Python, Go, C#, Java |
| Abstraction | count, for_each, modules | Loops, functions, classes, packages |
| Learning curve | Low for the DSL itself | Reuses skills you already have |
| Guardrails | Constrained, predictable | Full language = more power and more footguns |
| Testing | terraform test, Terratest | Native unit tests + mocks in your language |
| Ecosystem | Terraform Registry | Bridged TF providers + language package managers |
| Tooling/onboarding | Huge community, ubiquitous | Smaller community, vendor-influenced |
The real tension is power versus constraint. HCL’s narrowness makes infrastructure code uniform and hard to over-engineer; Pulumi’s expressiveness can produce elegant abstractions or an unmaintainable tangle, depending on discipline. Pulumi shines when infrastructure logic is genuinely complex or tightly coupled to application code; HCL remains the safer default for broad teams and simple, stable estates.
Best Practices
- Keep one stack per environment (
dev,staging,prod) and store config and secrets withpulumi config set --secretrather than hardcoding them. - Use a self-managed or Pulumi Cloud backend with locking enabled — never share local state across a team.
- Resist over-abstraction: prefer plain resource declarations and small
ComponentResources over deep class hierarchies that obscure what gets created. - Always run
pulumi previewin CI and require a clean diff beforepulumi up, mirroring a plan-in-PR workflow. - Pin provider plugin versions and your Pulumi SDK versions so previews stay reproducible across machines.
- Write unit tests with Pulumi’s mocking support to assert on resource properties without touching the cloud.