IaC Multi-Cloud

Programmatic IaC with Pulumi and TypeScript: Component Resources and the Automation API

If you have lived in HCL long enough, you eventually hit its ceiling: no real abstractions, awkward loops, and copy-paste modules that drift. Pulumi takes a different bet, your infrastructure is a TypeScript program, so functions, classes, and npm packages become first-class tools for IaC. This article walks through the model, then builds a reusable ComponentResource, multi-stack config, and a CLI-free deployment via the Automation API.

1. Pulumi’s model vs. Terraform

Both tools maintain desired state, diff it against actual state, and call cloud APIs. The difference is how you express desired state. Terraform parses HCL into a graph. Pulumi runs your program, and the resource objects you construct register themselves with a long-running engine over gRPC. Your code does not apply changes directly, it declares resources, and the engine computes and executes the diff.

Concept Terraform Pulumi
Language HCL (declarative DSL) TypeScript, Python, Go, C#, Java, YAML
Unit of reuse Module Function or ComponentResource (a class)
State .tfstate file/backend State stored in a backend (Pulumi Cloud, S3, Azure Blob, etc.)
Deployable instance Workspace Stack
Dependency values Interpolation/depends_on Output<T> with an implicit dependency graph

The mental shift that trips up newcomers: you are writing a program that describes infrastructure, not a script that provisions it line by line. Constructing a new aws.s3.BucketV2(...) does not create a bucket when that line runs, it registers intent. pulumi up evaluates the whole program, builds the graph, then reconciles.

2. Project and stack setup

A project is a directory with a Pulumi.yaml and your program. A stack is an isolated, independently configurable instance of that project, dev, staging, and prod are typically separate stacks.

mkdir infra && cd infra
pulumi new aws-typescript --name infra --stack dev --yes

That scaffolds Pulumi.yaml, index.ts, package.json, and a Pulumi.dev.yaml stack config. Create additional stacks as you need them:

pulumi stack init staging
pulumi stack init prod
pulumi stack ls

Config and secrets

Set plain config and encrypted secrets per stack. Secrets are encrypted at rest in the stack file and in state:

pulumi config set aws:region us-east-1
pulumi config set infra:instanceCount 3
pulumi config set --secret infra:dbPassword 'S3cr3t!'

Read them back in code with the typed Config helper. Use requireSecret so the value stays an encrypted Output end to end and never lands in plaintext logs:

import * as pulumi from "@pulumi/pulumi";

const cfg = new pulumi.Config();
const instanceCount = cfg.requireNumber("instanceCount");
const dbPassword = cfg.requireSecret("dbPassword");

Secrets encryption uses a per-stack key. With Pulumi Cloud the default is a service-managed key, but you can wire --secrets-provider to KMS, Azure Key Vault, or GCP KMS at stack init time for customer-managed keys. Decide this before you store secrets, changing providers later means re-encrypting the stack.

Stack references

One stack can consume another’s outputs through a StackReference. This is how you keep a networking stack separate from app stacks without copy-pasting IDs:

const net = new pulumi.StackReference("myorg/networking/prod");
const vpcId = net.getOutput("vpcId");

3. Outputs and apply(): the async graph in code

The single most important type in Pulumi is Output<T>. A resource property like a bucket’s arn is not known until the engine creates the resource, so Pulumi represents it as a future-like value that also carries dependency information. You cannot treat it as a plain string.

To derive a new value from an Output, use .apply():

const bucket = new aws.s3.BucketV2("data");
const policyText = bucket.arn.apply(arn =>
  JSON.stringify({ Resource: `${arn}/*` })
);

When you need to combine several outputs, reach for pulumi.all or pulumi.interpolate rather than nesting apply calls:

const url = pulumi.interpolate`https://${dist.domainName}/index.html`;

const combined = pulumi.all([bucket.id, queue.url]).apply(
  ([bucketId, queueUrl]) => `${bucketId}|${queueUrl}`
);

Two rules that prevent most beginner bugs. First, never call .apply() just to log a value, use export or pass the Output directly to another resource so the dependency edge is preserved. Second, avoid creating resources inside an apply callback unless you truly must, it hides them from the dependency graph and can produce confusing diffs. Pass Output values straight into resource constructors instead, they accept Input<T> and wire dependencies for you.

Anything you want surfaced after a deployment goes through export:

export const bucketName = bucket.id;
export const siteUrl = url;

Those appear in pulumi stack output and become consumable via StackReference.

4. Packaging reusable abstractions as ComponentResources

A ComponentResource is a logical grouping of child resources behind a typed interface, the Pulumi equivalent of a well-designed Terraform module, but it is a real class. You get encapsulation, typed arguments, IDE autocomplete, and the ability to publish it as an npm package.

The contract has three parts: an args interface, a class extending pulumi.ComponentResource, and a registerOutputs call at the end of the constructor.

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

export interface StaticSiteArgs {
  indexDocument?: pulumi.Input<string>;
  tags?: pulumi.Input<{ [k: string]: pulumi.Input<string> }>;
}

export class StaticSite extends pulumi.ComponentResource {
  public readonly bucketName: pulumi.Output<string>;
  public readonly url: pulumi.Output<string>;

  constructor(
    name: string,
    args: StaticSiteArgs = {},
    opts?: pulumi.ComponentResourceOptions,
  ) {
    super("kloudvin:web:StaticSite", name, {}, opts);

    const bucket = new aws.s3.BucketV2(
      `${name}-bucket`,
      { tags: args.tags },
      { parent: this },
    );

    const website = new aws.s3.BucketWebsiteConfigurationV2(
      `${name}-website`,
      {
        bucket: bucket.id,
        indexDocument: { suffix: args.indexDocument ?? "index.html" },
      },
      { parent: this },
    );

    this.bucketName = bucket.id;
    this.url = pulumi.interpolate`http://${website.websiteEndpoint}`;

    this.registerOutputs({
      bucketName: this.bucketName,
      url: this.url,
    });
  }
}

Three details that matter:

Consuming it is one line, and the component composes like any other resource:

const site = new StaticSite("marketing", {
  tags: { team: "web", env: pulumi.getStack() },
});

export const siteUrl = site.url;

5. Managing multiple stacks and per-environment config cleanly

Avoid if (stack === "prod") branching scattered through your program, it does not scale. Instead, push environment differences into stack config and read them as typed objects.

Define structured config per stack file. Pulumi.prod.yaml:

config:
  aws:region: us-east-1
  infra:sizing:
    instanceType: m6i.xlarge
    minNodes: 3
    maxNodes: 9

And Pulumi.dev.yaml:

config:
  aws:region: us-east-1
  infra:sizing:
    instanceType: t3.small
    minNodes: 1
    maxNodes: 2

Read the object with requireObject and an interface, no branching required:

interface Sizing {
  instanceType: string;
  minNodes: number;
  maxNodes: number;
}

const cfg = new pulumi.Config();
const sizing = cfg.requireObject<Sizing>("sizing");

A clean pattern at the principal level: keep one program that is fully config-driven, then promote changes by applying the same code to successive stacks (pulumi up -s staging, then -s prod). The program never knows or cares which environment it is, which keeps drift between environments down to data, not code paths.

6. Driving deployments without the CLI: the Automation API

The Automation API lets you run pulumi up, preview, destroy, and refresh from inside a Node.js process, no shelling out to the CLI. This is how you build self-service platforms, custom CI steps, or a control plane that provisions per-tenant stacks on demand.

You can run an inline program (a function, no separate project directory needed):

import * as auto from "@pulumi/pulumi/automation";
import * as aws from "@pulumi/aws";

async function run() {
  const program = async () => {
    const bucket = new aws.s3.BucketV2("auto-bucket");
    return { bucketName: bucket.id };
  };

  const stack = await auto.LocalWorkspace.createOrSelectStack({
    stackName: "dev",
    projectName: "automation-demo",
    program,
  });

  await stack.setConfig("aws:region", { value: "us-east-1" });
  await stack.refresh({ onOutput: console.info });

  const up = await stack.up({ onOutput: console.info });
  console.log(`bucket: ${up.outputs.bucketName.value}`);
  console.log(`summary:`, up.summary.resourceChanges);
}

run().catch(err => {
  console.error(err);
  process.exit(1);
});

Key surface area worth knowing:

Because it is just code, you can loop over tenants and stand up an isolated stack for each, with full error handling, retries, and structured logging your platform already has.

7. Testing Pulumi programs

Pulumi supports real unit tests by mocking the resource engine. You register mocks that intercept resource construction and return fake IDs and outputs, so tests run in milliseconds with no cloud calls.

import * as pulumi from "@pulumi/pulumi";

pulumi.runtime.setMocks({
  newResource: (args: pulumi.runtime.MockResourceArgs) => ({
    id: `${args.name}-id`,
    state: args.inputs,
  }),
  call: (args: pulumi.runtime.MockCallArgs) => args.inputs,
});

With mocks registered, import your program and assert on the resulting Output values. Resolve an Output in a test by wrapping apply in a promise:

function promiseOf<T>(output: pulumi.Output<T>): Promise<T> {
  return new Promise(resolve => output.apply(resolve));
}

describe("StaticSite", () => {
  it("defaults the index document", async () => {
    const { StaticSite } = await import("./staticSite");
    const site = new StaticSite("test");
    const name = await promiseOf(site.bucketName);
    expect(name).toContain("test");
  });
});

This is genuine property testing in the sense of asserting invariants on resource inputs, every bucket has encryption enabled, every security group denies 0.0.0.0/0 on port 22, and so on, all without provisioning anything. For deeper assurance, Pulumi also supports policy-as-code (CrossGuard) to enforce such rules at preview/up time across every stack.

Verify

Walk through a quick end-to-end check after wiring the above together.

# Preview without making changes
pulumi preview -s dev

# Apply and confirm
pulumi up -s dev --yes

# Inspect outputs and the resource tree
pulumi stack output
pulumi stack --show-urns

# Run the unit tests (no cloud calls)
npm test

# Tear down when done
pulumi destroy -s dev --yes

A healthy run shows your ComponentResource as a parent node with its children nested beneath it in the preview, and pulumi stack output returns bucketName and siteUrl. The Automation API script should print the same outputs and a non-empty resourceChanges summary on first apply.

8. Migrating or coexisting with Terraform

You rarely get a greenfield. Two supported paths:

pulumi convert --from terraform --language typescript --out ./converted

Migrate by seam, not big bang. Pick one Terraform module with clean output boundaries, consume its state from Pulumi via remote state, then re-implement it as a ComponentResource and cut over. Keep both tools off the same resources at the same time, dual ownership of one resource is where state corruption happens.

Checklist

Pitfalls and next steps

The recurring traps: forgetting { parent: this } (flat, unreadable resource trees), creating resources inside apply callbacks (hidden dependencies and noisy diffs), and switching secrets providers after secrets already exist (forces a full re-encrypt). On the operational side, lock down state backend access the same way you would .tfstate, it contains secrets and resource metadata.

From here, package your best ComponentResourcees into an internal npm scope so teams consume vetted building blocks instead of raw provider resources, then add a CrossGuard policy pack to enforce tagging, encryption, and network guardrails on every pulumi up. That combination, typed components plus policy plus the Automation API, is what turns Pulumi from “Terraform in TypeScript” into an actual internal platform.

PulumiTypeScriptAutomation APIIaCStacks

Comments

Keep Reading