Terraform Lesson 71 of 89

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

In a nutshell

If you have ever snapped together a Lego sub-assembly — a pre-built cockpit you drop into ten different spaceships — you already understand a ComponentResource. It is a reusable sub-assembly of cloud resources: you build the bucket + website config + policy once, give it a typed set of knobs, and stamp out “a static site” as a single named block wherever you need one. The children live inside the block; callers see one clean part, not the wiring.

The Automation API is the other half of this lesson, and it is just as concrete: instead of typing pulumi up at a terminal, you drive Pulumi from your own program. Your Node.js code becomes the operator — it selects a stack, sets config, previews, and applies, then reads the outputs back as plain values. That is how you turn Pulumi into a self-service platform, a custom CI step, or a control plane that spins up one isolated stack per tenant.

Put together: ComponentResources give you reusable building blocks; the Automation API lets a program assemble and deploy them without a human at the keyboard. Everything else here — Output<T>, stacks, config, secrets, testing — is the glue that makes those two ideas safe in production.

Level: Advanced · Time: ~35 min

Pulumi ComponentResource + Automation API flow

Read left to right: an Automation API driver runs your Pulumi program, which constructs a ComponentResource (a parent with nested children); the engine diffs that graph against the state backend and calls providers to create cloud resources, and outputs flow back to the driver.

Before you start. You should be comfortable with basic IaC concepts (desired state, plan/apply, state) and with TypeScript classes, interfaces, and async/await. If you have ever written a Terraform module, you already have the mental model a ComponentResource maps onto.

After this lesson you can:

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.

Going deeper

This section is the “under the hood” tour: what actually happens when your component registers, how the Automation API talks to the engine, and the resource options and backends that decide your blast radius in production.

ComponentResource internals: parent, children, and registerOutputs

A ComponentResource is not a namespace or a folder — it is a real node in Pulumi’s resource graph, just one with no CRUD of its own. It exists to own children and to publish their combined outputs. Four things happen inside a well-formed component:

  1. super(type, name, inputs, opts) registers the component node with the engine. The type token (<package>:<module>:<Type>, e.g. kloudvin:web:StaticSite) is not cosmetic — it becomes part of every child’s URN (the stable, globally-unique identifier Pulumi uses to track state). The third argument, inputs, is usually {} or the args object; it is recorded so the preview can show what the component was given.
  2. Children pass { parent: this }. This is what nests them under the component in the tree, prefixes their URNs with the component’s URN, and — importantly — makes them inherit selected options from the parent, notably the provider/providers and protect settings. Set a provider once on the component and every child uses it, unless a child overrides it.
  3. registerOutputs({...}) is called once, at the end of the constructor. It records the component’s output properties and signals to the engine that the component is fully constructed. Skip it and Pulumi emits a warning; more subtly, dependencies expressed on the component (rather than on a specific child) may not wire up cleanly.
  4. Public fields expose outputs. Assigning this.url = ... before registerOutputs gives callers a typed handle they can export or feed into other resources.

Naming discipline pays off. The name you pass a component becomes the prefix for child names (${name}-bucket), so a component instantiated as new StaticSite("marketing") produces marketing-bucket, marketing-website, and so on. Deterministic, collision-free child names are what let you run many instances of the same component in one stack.

Local components vs. remote (multi-language) components

The StaticSite class above is a local component: it is authored and instantiated in the same program and language. It is a compile-time abstraction — there is no separate plugin, and it only exists for TypeScript consumers.

A remote component, also called a Multi-Language Component (MLC), is packaged as a component provider so it can be consumed from any Pulumi language. You author the component once (its construct logic runs in a provider host), publish it as a plugin, and a Python or Go program can then instantiate your TypeScript-authored component through a generated SDK. The trade-off: MLCs add packaging and versioning overhead, so reach for them only when you genuinely need cross-language reuse or a versioned, independently-released building block. For a single TypeScript codebase, a local component is simpler and just as powerful.

Resource options: the levers that control blast radius

Every resource and component accepts an options bag (CustomResourceOptions / ComponentResourceOptions). The ones worth memorizing:

Option What it does Typical use
parent Nests a resource under another Always, inside components ({ parent: this })
dependsOn Adds an explicit dependency edge with no data flow Ordering when there is no Output link between two resources
protect Blocks destroy/delete until removed Stateful resources: databases, prod buckets
ignoreChanges Ignores drift on listed property paths A field mutated out-of-band (e.g. autoscaler-managed desiredCount)
aliases Tells Pulumi a resource’s identity changed Refactoring/renaming/re-parenting without replacement
retainOnDelete Removes from state but leaves the real resource Handing a resource to another tool or stack
deleteBeforeReplace Deletes the old resource before creating the new Unique-name constraints that block create-then-delete
replaceOnChanges Forces replacement when a normally-updatable prop changes Effectively-immutable fields
provider / providers Selects an explicit provider instance Multi-region/multi-account: pass a configured provider

Two of these deserve extra care. aliases is how you refactor safely: when you move a resource under a new parent (say, into a component), its URN changes, and by default Pulumi reads a changed URN as “delete the old thing, create a new one.” Add an alias describing the old identity — for a resource that previously had no parent, aliases: [{ noParent: true }] — and Pulumi treats it as the same resource, updating in place. protect is your seatbelt: set it on anything whose accidental deletion would ruin your week.

const db = new aws.rds.Instance("app-db", { /* ... */ }, {
  parent: this,
  protect: true,
  ignoreChanges: ["password"],           // rotated out-of-band
  aliases: [{ noParent: true }],         // was top-level before this refactor
});

Output<T>, secrets, and lifting

Output<T> is a value that will be known after apply and carries the dependency edges that produced it. A few internals that save hours:

Automation API: inline vs. local, and how it talks to the engine

The Automation API drives Pulumi from code through a LocalWorkspace. There are two program shapes:

Either way, LocalWorkspace shells out to the Pulumi CLI binary under the hood — “no CLI” means no interactive terminal, not “no pulumi installed.” The binary must be present on the host (a common surprise in slim containers).

The operations mirror the CLI: up, preview, refresh, destroy, plus setConfig/getConfig, outputs, exportStack/importStack, history, and cancel. Streaming comes in two flavors:

const changeCounts: Record<string, number>[] = [];

const up = await stack.up({
  onOutput: (msg) => process.stdout.write(msg),   // raw CLI text
  onEvent: (e) => {                               // structured engine events
    if (e.diagnosticEvent?.severity === "error") console.error(e.diagnosticEvent.message);
    if (e.summaryEvent) changeCounts.push(e.summaryEvent.resourceChanges);
  },
  parallel: 8,
});

onOutput streams the same text you would see in a terminal; onEvent gives typed EngineEvent objects — resource pre/outputs, diagnostics, policy violations, and a final summary — which is what you use to build dashboards or gate a pipeline. Read outputs programmatically from the result:

const outs = await stack.outputs();
const url = outs.siteUrl.value;                 // plain value
const isSecret = outs.dbPassword?.secret;       // true → do NOT log .value
if ((up.summary.resourceChanges?.update ?? 0) > 20) {
  throw new Error("too many changes — aborting auto-apply");
}

Every entry in an OutputMap is { value, secret }. When secret is true, the value is the decrypted secret — treat it like a password: never console.log it, never put it in an error message. Wrap Automation API calls in try/catch; the SDK throws typed errors such as ConcurrentUpdateError (another update is running), StackAlreadyExistsError, and StackNotFoundError, so you can retry or fail cleanly.

Provider inheritance and explicit providers

By default, a resource uses the ambient (default) provider for its package, configured from stack config (aws:region, etc.). Inside a component, children inherit the component’s provider. To deploy across regions or accounts, construct an explicit provider and pass it down:

const usWest = new aws.Provider("us-west", { region: "us-west-2" });
const site = new StaticSite("dr", {}, { providers: { aws: usWest } });

Because children inherit { parent: this }, that provider flows to every resource inside the component — one line configures the whole sub-tree.

State backends and secrets providers

Pulumi separates where state lives (the backend) from how secrets are encrypted (the secrets provider):

Concern Options Selected by
State backend Pulumi Cloud (default), s3://, azblob://, gs://, file:// (local) pulumi login <url>
Secrets provider service-managed (Pulumi Cloud), passphrase, awskms://, azurekeyvault://, gcpkms://, hashivault:// pulumi stack init --secrets-provider=<url>

State holds resource metadata and encrypted secret values, so it is sensitive — lock down the bucket/backend exactly as you would a Terraform .tfstate. The secrets provider is chosen at stack init and is painful to change afterward (it forces a re-encrypt of every secret), so decide up front: service-managed keys for convenience, or a customer-managed KMS/Key Vault key for compliance.

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.

Practice challenges

Work these in order; each builds on the last. Solutions assume @pulumi/pulumi and @pulumi/aws, but the ideas port to any provider. None of these require a live cloud account to reason about — the goal is fluency with the model.

1. Two stacks, typed config (beginner). Create a project with dev and prod stacks. Set infra:replicas to 1 on dev and 3 on prod, and read it back in code as a number.

<details><summary>Solution</summary>

pulumi new aws-typescript --name infra --stack dev --yes
pulumi stack init prod
pulumi config set infra:replicas 1 --stack dev
pulumi config set infra:replicas 3 --stack prod
const replicas = new pulumi.Config().requireNumber("replicas");

Why: per-stack config is the idiomatic way to vary environments — the same code reads different data per stack. </details>

2. Your first ComponentResource (beginner→intermediate). Wrap an S3 bucket and its public-access block in a ComponentResource called SafeBucket, exposing bucketName. Make sure the children use { parent: this } and the constructor calls registerOutputs.

<details><summary>Solution</summary>

export class SafeBucket extends pulumi.ComponentResource {
  public readonly bucketName: pulumi.Output<string>;
  constructor(name: string, opts?: pulumi.ComponentResourceOptions) {
    super("kloudvin:storage:SafeBucket", name, {}, opts);
    const bucket = new aws.s3.BucketV2(`${name}-b`, {}, { parent: this });
    new aws.s3.BucketPublicAccessBlock(`${name}-pab`, {
      bucket: bucket.id,
      blockPublicAcls: true, blockPublicPolicy: true,
      ignorePublicAcls: true, restrictPublicBuckets: true,
    }, { parent: this });
    this.bucketName = bucket.id;
    this.registerOutputs({ bucketName: this.bucketName });
  }
}

Why: { parent: this } nests the children and registerOutputs marks the component complete — that is the whole contract. </details>

3. Kill the if (stack === "prod") (intermediate). Replace environment branching with a structured requireObject<Sizing>("sizing") read, and provide different Pulumi.dev.yaml / Pulumi.prod.yaml values.

<details><summary>Solution</summary>

Pulumi.prod.yaml:

config:
  infra:sizing:
    instanceType: m6i.xlarge
    minNodes: 3
interface Sizing { instanceType: string; minNodes: number; }
const sizing = new pulumi.Config().requireObject<Sizing>("sizing");

Why: differences become data the program consumes, so one code path serves every environment — drift stays in config, not logic. </details>

4. Re-parent without replacement (intermediate). You have a top-level aws.s3.BucketV2("logs") and want to move it inside a LoggingStack component. Do it so pulumi preview shows an update, not a replace.

<details><summary>Solution</summary>

const bucket = new aws.s3.BucketV2("logs", {}, {
  parent: this,
  aliases: [{ noParent: true }],   // it used to have no parent
});

Why: moving a resource changes its URN; the alias tells Pulumi it is the same resource, so it updates in place instead of destroy-and-recreate. </details>

5. Gate an Automation API deploy (advanced). Write an inline program that runs preview, and only calls up if the total of created + updated + deleted resources is ≤ 10. Stream structured events and never log secret outputs.

<details><summary>Solution</summary>

const stack = await auto.LocalWorkspace.createOrSelectStack({
  stackName: "dev", projectName: "gated", program,
});
const prev = await stack.preview({ onEvent: (e) => handle(e) });
const c = prev.changeSummary ?? {};
const total = (c.create ?? 0) + (c.update ?? 0) + (c.delete ?? 0);
if (total > 10) throw new Error(`refusing to apply ${total} changes`);

await stack.up({ onOutput: (m) => process.stdout.write(m) });
const outs = await stack.outputs();
for (const [k, v] of Object.entries(outs)) {
  if (!v.secret) console.log(`${k} = ${v.value}`);
}

Why: preview().changeSummary gives a programmatic diff to gate on, and checking .secret before logging keeps decrypted values out of your logs. </details>

6. One stack per tenant (advanced). Using the Automation API, loop over a list of tenants and stand up an isolated stack for each, with per-tenant config and error handling that continues past a single failure.

<details><summary>Solution</summary>

for (const tenant of tenants) {
  try {
    const stack = await auto.LocalWorkspace.createOrSelectStack({
      stackName: tenant.id, projectName: "saas", program,
    });
    await stack.setConfig("saas:plan", { value: tenant.plan });
    const up = await stack.up({ onOutput: () => {} });
    results.push({ tenant: tenant.id, changes: up.summary.resourceChanges });
  } catch (err) {
    results.push({ tenant: tenant.id, error: String(err) });   // keep going
  }
}

Why: because deployment is now just code, per-tenant isolation is a for loop with real try/catch — the pattern behind self-service platforms. </details>

Common beginner mistakes

These are the misconceptions that trip people up — distinct from the operational pitfalls above. Each pairs the wrong mental model with the right one.

Glossary

PulumiTypeScriptAutomation APIIaCStacks
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments