Create reusable Pulumi infrastructure with TypeScript component resources
Wrap repeated cloud resources in a Pulumi ComponentResource, publish it as an npm package, and consume it across stacks with type-safe outputs.
31 Mar 2026, 23:24 UTC

Problem and takeaway
You maintain multiple Pulumi stacks that each declare the same VPC, subnets, and security group. When a networking rule changes, you edit every stack by hand and risk missing one. The useful takeaway is to wrap those repeated resources in a Pulumi ComponentResource—a logical resource that groups child resources into a single unit—publish it as a versioned npm package, and consume it in any stack with full TypeScript type safety.
Desired outcome
By the end of this guide you will have:
- A TypeScript class extending
pulumi.ComponentResourcethat creates related cloud resources as children. - Exposed outputs such as load balancer DNS or subnet IDs for downstream stacks.
- A publishable npm package with semantic versioning.
- A consuming stack that instantiates the component and verifies the plan with
pulumi preview.
Prerequisites
- Pulumi CLI installed and authenticated to your cloud provider.
- Node.js and npm.
- A Pulumi TypeScript project, or a new one created with
pulumi new typescript. - The cloud provider package for your target, for example
@pulumi/aws, and valid cloud credentials. - Access to an npm registry—public, private, or a local folder for testing.
Procedure: define and publish the component
1. Create the component class
In your component package, create src/web-app.ts. A component resource accepts inputs, creates child resources with { parent: this }, and registers outputs before returning.
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
interface WebAppArgs {
containerImage: pulumi.Input<string>;
desiredCount: pulumi.Input<number>;
vpcId: pulumi.Input<string>;
subnetIds: pulumi.Input<string[]>;
tags?: pulumi.Input<{ [key: string]: pulumi.Input<string> }>;
}
export class WebApp extends pulumi.ComponentResource {
public readonly loadBalancerDns: pulumi.Output<string>;
constructor(name: string, args: WebAppArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:index:WebApp", name, args, opts);
const securityGroup = new aws.ec2.SecurityGroup(`${name}-sg`, {
vpcId: args.vpcId,
ingress: [
{
protocol: "tcp",
fromPort: 80,
toPort: 80,
cidrBlocks: ["0.0.0.0/0"],
},
],
tags: args.tags,
}, { parent: this });
const loadBalancer = new aws.lb.LoadBalancer(`${name}-alb`, {
subnets: args.subnetIds,
securityGroups: [securityGroup.id],
tags: args.tags,
}, { parent: this });
this.loadBalancerDns = loadBalancer.dnsName;
this.registerOutputs({
loadBalancerDns: this.loadBalancerDns,
});
}
}
The string acme:index:WebApp is the component type. It becomes part of every child resource's URN in Pulumi state. Changing it later breaks state continuity unless you configure aliases.
2. Build the package
Run the TypeScript compiler in the component package directory:
tsc
Required permissions: standard user permissions for the project directory. Expected result: a dist or build folder with compiled JavaScript and type declarations.
3. Publish to npm
Add a name and version to package.json, then publish:
npm publish --access public
Run this where your package.json lives. You need npm publish rights for the registry. Risk: publishing to a public registry exposes the code; use a private registry or scoped package for internal infrastructure.
Procedure: consume the component in a stack
4. Install and instantiate
In a separate Pulumi project, install the package:
npm install @acme/infrastructure-components@1.0.0
Replace @acme/infrastructure-components with your published package name. Then create the component in index.ts:
import * as pulumi from "@pulumi/pulumi";
import { WebApp } from "@acme/infrastructure-components";
const config = new pulumi.Config();
const app = new WebApp("frontend", {
containerImage: config.require("image"),
desiredCount: config.getNumber("desiredCount") ?? 2,
vpcId: "vpc-0123456789abcdef0",
subnetIds: ["subnet-0111", "subnet-0222"],
tags: { environment: "production" },
});
export const loadBalancerDns = app.loadBalancerDns;
5. Preview and update
Run these commands in the consuming stack directory:
pulumi preview
pulumi up --yes
pulumi preview requires cloud credentials and shows planned creates under the component URN. Confirm that child resources appear and that no existing resources are marked for replacement. pulumi up --yes changes state and creates billable cloud resources. Verify in your cloud console that the load balancer, security group, and tags match the inputs.
Testing without deployment
Use Pulumi's unit testing framework with @pulumi/pulumi/tests to mock providers and assert that the component registers the expected properties and outputs. Run tests with npm test in the component package. Mocks should cover valid inputs and validation errors, such as a missing vpcId.
Enforce standards with CrossGuard
Pulumi CrossGuard evaluates policy packs at preview and update time. Attach a policy pack to your organization or stack that requires an environment tag on all resources. If a consumer instantiates WebApp without the tags input, preview should fail before any resources are created.
Expected checks
| Check | Command or action | Expected result |
|---|---|---|
| Preview | pulumi preview in consuming stack | Child resources listed under component URN; no unexpected replacements |
| Deployment | pulumi up --yes | Resources create successfully; cloud console shows correct tags and configuration |
| Unit tests | npm test in component package | Mocked provider assertions pass for valid and invalid inputs |
| Policy enforcement | Attach CrossGuard tag policy; run preview without tag | Preview fails with policy violation |
Limitations
- URN stability: The component type name is embedded in state URNs. Renaming the class or package breaks continuity unless you add
aliasestoComponentResourceOptionsor usepulumi statecommands. - Output registration: Register outputs in the constructor. Use
pulumi.Output.allto combine multiple child outputs safely before passing them downstream. - Circular dependencies: Pulumi rejects plans with circular component references. Design an acyclic module graph.
- Secrets: Secret inputs are encrypted in state, but consumers must avoid logging secret outputs with
console.logor similar.
Recovery options
Because pulumi up changes state, have a recovery path:
- Remove resources: Run
pulumi destroyin the consuming stack to delete all child resources managed by the component. - Bad deployment: Re-run
pulumi upwith the previous committed code version, or destroy and redeploy from an earlier stack state. Pulumi does not provide a single-click rollback to a prior deployment. - Renamed component: Add an
aliasesarray pointing to the old URN, for examplealiases: [{ urn: "urn:pulumi:stack::project::acme:index:WebApp$oldname" }], so Pulumi treats the new resource as a continuation of the old one.
To verify the result after any change, run pulumi preview and confirm the plan matches your intent before executing pulumi up.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.