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.