Building a Reusable VPC Component in Pulumi (Python)

Building a reusable VPC component in Pulumi means wrapping a VPC, its subnets across availability zones, and its routing behind one typed ComponentResource class that any stack can instantiate with a few arguments. This task sits under Pulumi Component Resources within Pulumi Patterns & Provider Management, and it produces a self-contained network primitive with typed args, parented children, and registered outputs ready for consumption by other stacks.

Context

Most Pulumi programs need a VPC, and most of them reimplement the same subnet-per-AZ layout inline. Packaging that layout once as a component gives every environment an identical, tested network foundation, and it pairs naturally with structuring stacks per environment so dev and prod differ only in CIDR and AZ count, never in structure.

Prerequisites

  • Python 3.9+ and pulumi >= 3.0 (pulumi version).
  • pulumi-aws >= 6.0 pinned in your lockfile.
  • AWS credentials with ec2:CreateVpc, ec2:CreateSubnet, and related EC2 permissions, supplied via environment variables or OIDC.
  • A configured state backend reachable from your machine and CI.

Implementation

1. Define typed arguments

A dataclass gives the component a clear, statically-checkable contract. Callers see exactly what the VPC needs.

# components/vpc.py
# CLI: imported by __main__.py
from dataclasses import dataclass, field


@dataclass
class VpcArgs:
    cidr_block: str
    az_count: int = 2
    enable_nat: bool = False
    tags: dict[str, str] = field(default_factory=dict)

2. Create the VPC and parent its children across AZs

Subclass ComponentResource, register the type token, then create each child with parent=self. Spreading subnets across the account's availability zones is what makes the component production-ready.

# components/vpc.py (continued)
# CLI: pulumi up --stack dev
import pulumi
import pulumi_aws as aws


class VpcComponent(pulumi.ComponentResource):
    def __init__(self, name: str, args: VpcArgs, opts: pulumi.ResourceOptions | None = None) -> None:
        super().__init__("myorg:network:Vpc", name, None, opts)
        child = pulumi.ResourceOptions(parent=self)

        self.vpc = aws.ec2.Vpc(
            f"{name}-vpc",
            cidr_block=args.cidr_block,
            enable_dns_hostnames=True,
            enable_dns_support=True,
            # State implication: parent=self nests every child under this component.
            tags={"Name": f"{name}-vpc", **args.tags},
            opts=child,
        )

        zones = aws.get_availability_zones(state="available")
        self.public_subnets: list[aws.ec2.Subnet] = []
        for i in range(args.az_count):
            subnet = aws.ec2.Subnet(
                f"{name}-public-{i}",
                vpc_id=self.vpc.id,
                cidr_block=f"10.0.{i}.0/24",
                availability_zone=zones.names[i],
                map_public_ip_on_launch=True,
                # Provider note: get_availability_zones is a read-only provider call, no state change.
                tags={"Name": f"{name}-public-{i}", **args.tags},
                opts=child,
            )
            self.public_subnets.append(subnet)

3. Add an internet gateway and register outputs

Finish the graph, then publish the typed surface with register_outputs so consuming stacks can read it.

# components/vpc.py (continued)
        igw = aws.ec2.InternetGateway(
            f"{name}-igw",
            vpc_id=self.vpc.id,
            opts=child,
        )

        self.vpc_id = self.vpc.id
        self.public_subnet_ids = pulumi.Output.all(*[s.id for s in self.public_subnets])
        # State implication: register_outputs closes the component and exposes these values.
        self.register_outputs(
            {
                "vpc_id": self.vpc_id,
                "public_subnet_ids": self.public_subnet_ids,
                "igw_id": igw.id,
            }
        )

4. Instantiate and export from the program

# __main__.py
# CLI: pulumi up --stack dev
import pulumi
from components.vpc import VpcComponent, VpcArgs

network = VpcComponent("app", VpcArgs(cidr_block="10.0.0.0/16", az_count=2))
pulumi.export("vpc_id", network.vpc_id)
pulumi.export("public_subnet_ids", network.public_subnet_ids)

Verification

Preview shows the children nested under the component, and the outputs resolve after an up:

# CLI: confirm nesting and outputs
pulumi preview --stack dev          # subnets and IGW appear under the "app" component node
pulumi up --stack dev --yes
pulumi stack output public_subnet_ids --stack dev

A mock-based unit test asserts the component creates the expected number of subnets without provisioning anything:

# tests/test_vpc_component.py
# CLI: pytest tests/test_vpc_component.py -q
import pulumi


class _Mocks(pulumi.runtime.Mocks):
    def new_resource(self, args: pulumi.runtime.MockResourceArgs):
        return (args.name + "-id", args.inputs)

    def call(self, args: pulumi.runtime.MockCallArgs):
        # Return two fake AZs for get_availability_zones.
        return {"names": ["us-east-1a", "us-east-1b"]}


pulumi.runtime.set_mocks(_Mocks())
# State implication: no real VPC is created; assertions run in-process.


@pulumi.runtime.test
def test_subnet_count():
    from components.vpc import VpcComponent, VpcArgs
    comp = VpcComponent("app", VpcArgs(cidr_block="10.0.0.0/16", az_count=2))
    assert len(comp.public_subnets) == 2

Gotchas & Edge Cases

AZ count must not exceed the region's available zones. get_availability_zones returns whatever the account has in the region; requesting az_count=4 in a region with three AZs raises an IndexError at synthesis. Validate az_count against len(zones.names) before the loop.

Hardcoded subnet CIDRs collide across components. The example derives 10.0.{i}.0/24 from a fixed prefix; instantiating two components in the same VPC range will overlap. Derive subnet CIDRs from args.cidr_block (e.g. with ipaddress.ip_network(...).subnets()) so each component carves its own space.

Forgetting parent=self on one child silently flattens the tree. A single child created without the child options object lands at the stack root, breaking grouping and deletion ordering. Pass the shared child options to every resource in __init__.