Pulumi Component Resources: Packaging a VPC So Teams Stop Copy-Pasting It
Pulumi component resources let you package repeated infrastructure patterns—like a VPC with subnets and routing—into one versioned, reusable unit. Here's how they work, with a TypeScript example and the trade-offs.
17 Sept 2026, 03:25 UTC

The copy-paste VPC problem
Every team that manages more than a couple of Pulumi stacks eventually hits the same wall: the networking code. Someone writes a solid VPC setup — subnets, internet gateway, route tables, the works — and then it gets copied into the next project. And the next. Six months later you have four slightly different VPCs, and nobody is sure which one is "correct."
Pulumi component resources are the built-in answer to this. A component resource groups several real ("child") resources into one logical unit with its own inputs, outputs, and lifecycle. You define it once, publish it as a normal package, and every stack consumes it like a single resource. This post walks through how they work, a concrete VPC example in TypeScript, and the trade-offs you should know before adopting them.
What a component resource actually is
A component resource is a class that inherits from pulumi.ComponentResource. It creates no cloud infrastructure by itself — it is a logical container. The resources it creates inside its constructor are registered as children via the parent option, which gives Pulumi two things:
- Structured URNs. Each child's URN includes the component, so the state file reads like a tree (
myVpc → publicSubnet-0 → ...) instead of a flat list. - Dependency tracking. Outputs you expose from the component carry dependency information, so consumers of
vpc.idcorrectly wait on the real VPC resource.
The constructor signature matters: always call super("yourorg:network:Vpc", name, {}, opts) with a namespaced type token, and pass opts through so callers can supply providers.
A worked example: a versioned VPC component
Create a component file, e.g. vpc.ts, in a Pulumi TypeScript project (pulumi new aws-typescript). It encapsulates a VPC, two public subnets, an internet gateway, and a route table, exposing only what consumers need:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
export interface VpcArgs {
cidrBlock: string;
availabilityZones: pulumi.Input<string[]>;
}
export class Vpc extends pulumi.ComponentResource {
public readonly vpcId: pulumi.Output<string>;
public readonly publicSubnetIds: pulumi.Output<string>[];
constructor(name: string, args: VpcArgs, opts?: pulumi.ComponentResourceOptions) {
super("myorg:network:Vpc", name, {}, opts);
const vpc = new aws.ec2.Vpc(name, {
cidrBlock: args.cidrBlock,
enableDnsHostnames: true,
}, { parent: this });
const igw = new aws.ec2.InternetGateway(`${name}-igw`, {
vpcId: vpc.id,
}, { parent: this });
const routeTable = new aws.ec2.RouteTable(`${name}-rt`, {
vpcId: vpc.id,
routes: [{ cidrBlock: "0.0.0.0/0", gatewayId: igw.id }],
}, { parent: this });
this.publicSubnetIds = pulumi.output(args.availabilityZones)
.apply(azs => azs.map((az, i) => {
const subnet = new aws.ec2.Subnet(`${name}-subnet-${i}`, {
vpcId: vpc.id,
cidrBlock: pulumi.interpolate`10.0.${i}.0/24`,
availabilityZone: az,
mapPublicIpOnLaunch: true,
}, { parent: this });
new aws.ec2.RouteTableAssociation(`${name}-rta-${i}`, {
subnetId: subnet.id,
routeTableId: routeTable.id,
}, { parent: this });
return subnet.id;
}));
this.vpcId = vpc.id;
this.registerOutputs({
vpcId: this.vpcId,
publicSubnetIds: this.publicSubnetIds,
});
}
}Consuming it in index.ts looks like any other resource:
import { Vpc } from "./vpc";
const network = new Vpc("app-network", {
cidrBlock: "10.0.0.0/16",
availabilityZones: ["us-east-1a", "us-east-1b"],
});
export const vpcId = network.vpcId;Run pulumi up from the project directory (with AWS credentials configured and permission to manage EC2 networking). In the preview you should see the component as a parent node with the VPC, subnets, gateway, route table, and associations nested beneath it. Afterwards, pulumi stack output vpcId confirms only the intended output is exposed — the internal route table IDs and gateway IDs stay hidden from consumers.
Sharing it across teams
Pulumi does not publish component packages for you. To share the component, package the directory as a normal npm package (or PyPI/NuGet for other languages) with a semver version, and have consuming projects install it. Because components can be consumed across languages via Pulumi's schema-based package generation, the same abstraction can serve a TypeScript platform team and a Python application team.
Treat the component's public surface — its Args interface and outputs — as an API. Adding an optional input is a minor version bump; renaming an output is a breaking change that deserves a major bump and a migration note.
Trade-offs and limitations
- Debugging indirection. When an update fails inside a component, the error surfaces on a child resource you may not have written. Use
pulumi up --verboseor inspectpulumi stack exportto see the full child tree. - Refactoring cost. Renaming the component's type token or moving children around changes URNs, which Pulumi interprets as delete-and-recreate. Use
aliaseson the component and children when restructuring to preserve state. - Versioning discipline is on you. Nothing forces consumers to upgrade; stale component versions across stacks recreate the drift problem you were solving.
Where to start
Pick the one resource group your teams copy most often — networking, a Kubernetes namespace baseline, a database with its IAM wiring — and wrap it in a component with a minimal input surface. Verify it by running pulumi up in a throwaway stack, checking the nested resource tree in the preview, and confirming the stack outputs contain only what you intended to expose. Once that works, publish version 0.1.0 and let one other team consume it before rolling it out further.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.