Reusing Cloud Patterns with Pulumi Component Resources in TypeScript
Learn how to encapsulate a VPC definition as a Pulumi Component Resource, instantiate it multiple times, and verify unique URNs and outputs without duplicating code.
09 Oct 2026, 12:08 UTC

Problem: Repeated VPC boilerplate across environments
When you manage multiple AWS accounts or environments (dev, test, prod) you often need the same networking foundation: a VPC, public and private subnets, an internet gateway, and route tables. Copy‑pasting this configuration leads to drift, makes updates error‑prone, and obscures the intent of each stack.
Thesis: Pulumi Component Resources let you package a reusable pattern while preserving strong typing and automatic dependency tracking
A ComponentResource is a class that extends Pulumi’s base component, groups child resources into a single logical unit, and can be instantiated many times. Each instance gets its own URN hierarchy, so name collisions are avoided, and inputs/outputs flow through TypeScript types.
Building a reusable VPC component
First, create a new Pulumi TypeScript project that uses the component‑resource template:
# Run in a terminal where you have the Pulumi CLI installed
pulumi new typescript-component --dir my-vpc-component
cd my-vpc-component
This scaffolds a index.ts file and a Pulumi.yaml. Replace the contents of index.ts with the following component definition (you will need AWS credentials configured for the Pulumi CLI):
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
interface VpcArgs {
/** CIDR block for the VPC, e.g. "10.0.0.0/16" */
vpcCidr: pulumi.Input;
/** Number of public subnets to create (minimum 1) */
publicSubnetCount: pulumi.Input;
/** Number of private subnets to create (minimum 1) */
privateSubnetCount: pulumi.Input;
}
class Vpc extends pulumi.ComponentResource {
public readonly vpcId: pulumi.Output;
public readonly publicSubnetIds: pulumi.Output;
public readonly privateSubnetIds: pulumi.Output;
constructor(name: string, args: VpcArgs, opts?: pulumi.ResourceOptions) {
super("pkg:aws:Vpc", name, {}, opts);
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: args.vpcCidr,
enableDnsHostnames: true,
enableDnsSupport: true,
}, { parent: this });
// Create public subnets
const publicSubnetIds: pulumi.Output[] = [];
for (let i = 0; i < args.publicSubnetCount; i++) {
const subnet = new aws.ec2.Subnet(`${name}-public-${i}`, {
vpcId: vpc.id,
cidrBlock: pulumi.interpolate`${args.vpcCidr}.${i + 1}.0/24`,
mapPublicIpOnLaunch: true,
availabilityZone: pulumi.interpolate`${aws.config.require("region")}${i % 2 === 0 ? "a" : "b"}`,
}, { parent: this });
publicSubnetIds.push(subnet.id);
}
// Create private subnets
const privateSubnetIds: pulumi.Output[] = [];
for (let i = 0; i < args.privateSubnetCount; i++) {
const subnet = new aws.ec2.Subnet(`${name}-private-${i}`, {
vpcId: vpc.id,
cidrBlock: pulumi.interpolate`${args.vpcCidr}.${i + 10}.0/24`,
mapPublicIpOnLaunch: false,
availabilityZone: pulumi.interpolate`${aws.config.require("region")}${i % 2 === 0 ? "a" : "b"}`,
}, { parent: this });
privateSubnetIds.push(subnet.id);
}
// Attach an internet gateway and route table for public subnets
const igw = new aws.ec2.InternetGateway(`${name}-igw`, { vpcId: vpc.id }, { parent: this });
const publicRt = new aws.ec2.RouteTable(`${name}-public-rt`, {
vpcId: vpc.id,
routes: [{ cidrBlock: "0.0.0.0/0", gatewayId: igw.id }],
}, { parent: this });
publicSubnetIds.forEach((id, idx) => {
new aws.ec2.RouteTableAssociation(`${name}-public-rta-${idx}`, {
subnetId: id,
routeTableId: publicRt.id,
}, { parent: this });
});
this.vpcId = vpc.id;
this.publicSubnetIds = pulumi.output(publicSubnetIds);
this.privateSubnetIds = pulumi.output(privateSubnetIds);
// Register outputs so they appear in `pulumi stack output`
this.registerOutputs({
vpcId: this.vpcId,
publicSubnetIds: this.publicSubnetIds,
privateSubnetIds: this.privateSubnetIds,
});
}
}
// Export the component so it can be imported elsewhere
export { Vpc };
This class groups a VPC, subnets, an internet gateway, and route tables. Inputs are strongly typed via pulumi.Input, and outputs are exposed as pulumi.Output properties.
Using the component in a stack (worked example)
Now create a separate stack that consumes the component twice with different CIDR blocks, simulating dev and prod environments.
- In the same directory, create a new file
dev.ts:
import * as pulumi from "@pulumi/pulumi";
import { Vpc } from "./index";
new Vpc("dev-vpc", {
vpcCidr: "10.10.0.0/16",
publicSubnetCount: 2,
privateSubnetCount: 2,
});
- Create
prod.tswith a different CIDR:
import * as pulumi from "@pulumi/pulumi";
import { Vpc } from "./index";
new Vpc("prod-vpc", {
vpcCidr: "10.20.0.0/16",
publicSubnetCount: 2,
privateSubnetCount: 2,
});
- Update
Pulumi.yamlto point to the entrypoint (or create two separate stacks). For simplicity, we’ll use a single stack that includes both files:
name: vpc-component-demo
runtime: nodejs
description: Demo of Pulumi Component Resources for VPCs
Run a preview to see the plan:
# Ensure you have AWS credentials configured for the Pulumi CLI
pulumi preview
Expected checks in the output:
- Two distinct sets of resources appear, each prefixed with the component name (
dev-vpcandprod-vpc). - Each resource’s URN includes a unique path, e.g.,
urn:pulumi:stack::vpc-component-demo::pkg:aws:Vpc::dev-vpc::aws:ec2/Vpc:Vpc::dev-vpc-vpcand a similar URN for the prod instance. - No name collisions are reported; Pulumi treats each component instance as an independent resource tree.
After a successful pulumi up, you can verify the exported values:
pulumi stack output vpcId
pulumi stack output publicSubnetIds
pulumi stack output privateSubnetIds
These commands will show the VPC ID and subnet ID arrays for the last instantiated component (if you exported both, you may need to adjust the component to return a map keyed by instance name).
Trade‑offs and limitations
- Language coupling: A Component Resource must be authored in the same language as the consuming program. To share it across teams using different languages you would need to publish it to a language‑specific package registry (npm, PyPI, etc.) and version it.
- Over‑encapsulation risk: Hiding implementation details inside the component can make debugging harder when the internal logic changes. If you modify the subnet CIDR calculation, consumers might not notice until they observe unexpected IP conflicts. Mitigate by documenting the component’s contract and using semantic versioning when you publish updates.
- Cost visibility: Pulumi’s cost estimation still reflects the underlying AWS resources (VPC, subnets, gateway, etc.). The component abstraction does not reduce cost; it only reduces duplication in code.
Actionable closing
- Start small: pick a repeatable pattern in your current Pulumi code (e.g., a Kubernetes namespace with RBAC, or a CloudSQL instance) and wrap it in a
pulumi.ComponentResourceclass. - Define clear input parameters and output properties; use TypeScript interfaces to make the contract explicit.
- Test the component in isolation with
pulumi previewbefore runningpulumi upto avoid unintended provisioning. - When you are ready to share, publish the component to your team’s internal npm registry (or the public one) and version it.
By treating infrastructure as reusable, typed components you gain the same software engineering benefits you enjoy in application code—DRY principles, clear APIs, and safer evolution—while still letting Pulumi manage the real cloud resources underneath.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.