Install
# Universal install script
curl -fsSL https://get.pulumi.com | sh
# Or via Homebrew
brew install pulumi
# Or via mise (see /tutorials/mise-polyglot-runtime-versions.html)
mise use -g pulumi@latest
pulumi version
The model
A Pulumi project is a directory with a Pulumi.yaml and source code (in your chosen language). A stack is an instance of that project — "dev," "staging," "prod" — each with its own state and configuration.
State storage options:
- Pulumi Cloud — their SaaS, free up to 200 resources. Simplest setup.
- Self-managed backend — S3, Azure Blob, GCS, or a local filesystem. State is encrypted client-side; you operate the bucket.
For self-hosted, set once:
pulumi login s3://my-pulumi-state-bucket
# Or local:
pulumi login --local
The first project (TypeScript example)
mkdir my-infra && cd my-infra
pulumi new aws-typescript # picks AWS provider + TypeScript template
# Prompts:
# Project name: my-infra
# Project description: ...
# Stack name: dev
# AWS region: us-east-1
The generated index.ts:
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.Bucket("my-bucket", {
acl: "private",
tags: { Environment: "dev" }
});
export const bucketName = bucket.id;
Run it:
pulumi up # plan + confirm + apply
pulumi stack output # show exported outputs
pulumi destroy # tear down
pulumi preview # plan only (no apply)
pulumi up --diff # show full diff of changes
Stack outputs from one project can be consumed by another via StackReference — the "VPC stack exports a vpc-id, the app stack reads it" pattern.
Why real languages matter
Three places this pays off vs HCL:
// Loops with real semantics — create N subnets across N AZs
const azs = await aws.getAvailabilityZones({});
const subnets = azs.names.slice(0, 3).map((az, i) =>
new aws.ec2.Subnet(`subnet-${i}`, {
vpcId: vpc.id,
availabilityZone: az,
cidrBlock: `10.0.${i}.0/24`,
tags: { Name: `private-${az}` },
})
);
// Conditionals based on stack config
const config = new pulumi.Config();
const isProd = config.require("environment") === "prod";
const instance = new aws.ec2.Instance("web", {
instanceType: isProd ? "m6i.large" : "t3.small",
ami: ami.id,
tags: { Environment: config.require("environment") },
});
// Real abstractions — class-based components for reuse
class WebApp extends pulumi.ComponentResource {
public url: pulumi.Output<string>;
constructor(name: string, args: WebAppArgs, opts?: pulumi.ComponentResourceOptions) {
super("custom:webapp:WebApp", name, args, opts);
const lb = new aws.lb.LoadBalancer(...);
const tg = new aws.lb.TargetGroup(...);
// ...
this.url = lb.dnsName;
this.registerOutputs({ url: this.url });
}
}
const app1 = new WebApp("checkout", { ... });
const app2 = new WebApp("admin", { ... });
Reusable ComponentResources are the killer feature: write a WebApp class once, instantiate it for every service. With HCL, you'd write a Terraform module — possible but less ergonomic for complex parameterized cases.
Per-stack config
Pulumi.dev.yaml / Pulumi.prod.yaml hold per-stack values:
pulumi config set environment dev
pulumi config set instanceType t3.small
pulumi config set --secret dbPassword "<sensitive>"
# Read from code
const config = new pulumi.Config();
const env = config.require("environment");
const pass = config.requireSecret("dbPassword"); // typed Output<string>
Secrets are encrypted at rest in the state file using the stack's configured encryption (default: Pulumi Cloud's managed key; or KMS / Vault for self-managed).
Output and Input types
One concept that's different from regular code: pulumi.Output<T>. Some values aren't known until after a resource is created (an ID, a generated URL); they're returned as Output, which is a promise-like wrapper. To chain:
const bucket = new aws.s3.Bucket("data");
const policy = new aws.s3.BucketPolicy("policy", {
bucket: bucket.id,
policy: bucket.arn.apply(arn => JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Principal: { AWS: "*" },
Action: "s3:GetObject",
Resource: `${arn}/*`,
}],
})),
});
.apply() is how you transform an Output's contained value once it's known. pulumi.interpolate handles string concatenation cleanly.
Importing existing resources
# Import an existing S3 bucket into Pulumi management
pulumi import aws:s3/bucket:Bucket existing-bucket my-bucket-name --parent vpc
# Or in code with the import option
const existing = new aws.s3.Bucket("legacy",
{ /* matching args */ },
{ import: "my-bucket-name" });
After the first pulumi up, the resource is under Pulumi management without recreating it.
Testing
Real-language IaC unlocks real unit tests:
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect } from "vitest";
pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}_id`, state: args.inputs }),
call: () => ({}),
});
import { bucket } from "./index";
describe("infrastructure", () => {
it("creates a private bucket", async () => {
const acl = await new Promise(r => bucket.acl.apply(r));
expect(acl).toBe("private");
});
});
Mock the Pulumi runtime, assert on resource properties. Catches accidentally-public buckets in CI before they reach prod.
Policy as code
Pulumi CrossGuard lets you write organizational policies in code that block non-conforming resources:
// CrossGuard policy
new PolicyPack("aws-security", {
policies: [
{
name: "s3-no-public-acl",
description: "S3 buckets cannot have public ACL",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.s3.Bucket,
(bucket, args, reportViolation) => {
if (bucket.acl && ["public-read", "public-read-write"].includes(bucket.acl)) {
reportViolation("S3 buckets must not be public");
}
},
),
},
],
});
Attach the policy pack to a stack; subsequent pulumi up against that stack runs all policies. Mandatory violations block the apply.
Pulumi vs OpenTofu / Terraform
- Same provider ecosystem. If a Terraform provider exists, Pulumi has a wrapper. The cloud catalog (AWS / Azure / GCP / Kubernetes / Cloudflare / Datadog / etc.) is identical.
- Different state format. Migration in either direction is possible but non-trivial.
- OpenTofu (see that tutorial) is the right pick for teams committed to HCL or already invested in Terraform.
- Pulumi is the right pick for teams whose engineers are language-comfortable and want real abstractions, types, and tests.
What Pulumi loses
- The HCL community. Terraform's module registry has thousands of community modules; Pulumi's component library is smaller (though growing).
- Static analysis. HCL is a strict subset; tools like tfsec / Checkov work on it. Pulumi code is Turing-complete and harder to scan; the policy-as-code path (CrossGuard) replaces it.
- The "any team member can read it" claim. HCL is readable by non-coders; Pulumi code requires fluency in the chosen language.
For most teams in 2026, the choice between Pulumi and OpenTofu comes down to "do we want code or DSL?" Both work; both produce the same infrastructure; the difference is in the developer experience.