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.

Context Context: Context with 4 facets. Context Definition typed Python Provider cloud API State recorded facts Outcome reproducible infra
Context: how Definition, Provider, State relate in this pattern.

The alternative most teams reach for first is a plain function that creates the resources and returns a tuple. That works until the second environment appears. A function leaves every subnet, route table and gateway sitting at the root of the stack, so pulumi preview prints forty flat lines with no grouping, deletion ordering is decided purely by dependency edges, and there is no single object you can protect or hand to a downstream stack. A ComponentResource costs about ten extra lines and buys all of that back.

The component boundary in the resource graph

A component is a resource in Pulumi's state, but not one any cloud provider knows about. It exists purely to own a subtree. That has three mechanical effects worth understanding before you write the class, because each of them shows up later as either a convenience or a migration hazard.

The type token becomes part of every child's URN. The string you pass to super().__init__"myorg:network:Vpc" — is a three-part package:module:Type token. Once registered, the VPC resource inside the component has a fully qualified URN of the form:

# CLI: pulumi stack --show-urns --stack dev
urn:pulumi:dev::acme-platform::myorg:network:Vpc$aws:ec2/vpc:Vpc::app-vpc
urn:pulumi:dev::acme-platform::myorg:network:Vpc$aws:ec2/subnet:Subnet::app-public-0

The $ separates the parent's type from the child's. Rename the token to "myorg:net:Vpc" and every URN under it changes, so Pulumi sees the old resources as deleted and the new ones as created — a full network replacement. The type token is a public API surface, not a label.

Deletion is ordered around the boundary. When the component is destroyed, Pulumi tears down the whole subtree before considering anything that depended on the component as a whole. Without parenting, a security group in another part of the stack that references a subnet id can end up scheduled in a way that produces DependencyViolation: The subnet 'subnet-0a1b' has dependencies and cannot be deleted.

register_outputs closes the component. Until it is called, the engine treats the component as still registering children. It also publishes the dictionary you pass as the component's own output map, which is what makes network.vpc_id resolvable from outside and what a StackReference consumer eventually reads. Calling it with an empty dict is legal and is still better than not calling it at all, because it marks the boundary complete in the state.

The URN chain a component creates The URN chain a component creates: choose among 4 options. myorg:network:Vpc :: app child Vpc app-vpc child Subnet public-0 child RouteTable app-rt child NatGateway app-nat
Every child inherits the component's type token, which is why renaming the token replaces the whole network.

Prerequisites

Prerequisites Prerequisites: layered from Python interface / API down to Cloud runtime. Python interface / API Typed resource model Provider plugin State backend Cloud runtime
Prerequisites: the stack from the Python interface down to the cloud runtime.
  • 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

Implementation Implementation: 1. Define typed then 2. Create the VPC then 3. Add an internet then 4. Instantiate and 1. Define typed 2. Create the VPC 3. Add an internet 4. Instantiate and
Implementation: the stages run left to right — 1. Define typed, 2. Create the VPC, 3. Add an internet, 4. Instantiate and.

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)

Validation belongs here, not in __init__. A dataclass constructed with bad values fails before any resource is registered, so the error message names the argument rather than surfacing as an opaque provider rejection halfway through an update. __post_init__ is the right hook:

# components/vpc.py (continued)
# CLI: python -c "from components.vpc import VpcArgs; VpcArgs('10.0.0.0/28')"
import ipaddress


@dataclass
class VpcArgs:
    cidr_block: str
    az_count: int = 2
    enable_nat: bool = False
    ha_nat: bool = False          # one NAT gateway per AZ instead of one shared
    tags: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        net = ipaddress.ip_network(self.cidr_block, strict=True)
        if net.prefixlen > 24:
            raise ValueError(
                f"cidr_block {self.cidr_block} is too small: AWS rejects VPC "
                f"CIDRs smaller than /28 and this layout needs at least /24"
            )
        if not 1 <= self.az_count <= 6:
            raise ValueError(f"az_count must be between 1 and 6, got {self.az_count}")

Note that ipaddress.ip_network(..., strict=True) also rejects a host address given where a network was expected — "10.0.0.5/16" raises ValueError: 10.0.0.5/16 has host bits set, which is a far better failure than AWS returning InvalidVpcRange twenty seconds into an update.

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)

Two details in that block deserve more than a passing glance.

aws.get_availability_zones is an invoke, not a resource. It runs synchronously during the program's execution and returns a plain Python object, which is why zones.names[i] can be indexed directly instead of being wrapped in an Output. That is convenient — you can use the length in a range() — but it also means the call happens on every pulumi preview, and it requires working credentials even for a preview. In a unit test it must be intercepted by the call method of your mocks, exactly as the verification section does.

The hardcoded 10.0.{i}.0/24 is the component's biggest latent bug: it ignores args.cidr_block entirely. Derive the subnet ranges from the VPC CIDR instead, so the component composes with itself and with whatever address plan the organisation runs:

# components/vpc.py (continued)
# CLI: pulumi preview --stack dev
# Provider note: subnet CIDRs are computed in Python before any API call, so a
# collision fails locally rather than as an InvalidSubnet.Conflict from EC2.
import ipaddress
from typing import Iterator


def _subnet_cidrs(vpc_cidr: str, count: int, new_prefix: int = 24) -> list[str]:
    net = ipaddress.ip_network(vpc_cidr, strict=True)
    pool: Iterator[ipaddress.IPv4Network] = net.subnets(new_prefix=new_prefix)
    blocks = [str(next(pool)) for _ in range(count)]
    if len(blocks) < count:
        raise ValueError(f"{vpc_cidr} cannot be split into {count} /{new_prefix} subnets")
    return blocks

Called with _subnet_cidrs("10.0.0.0/16", 4) this yields 10.0.0.0/24 through 10.0.3.0/24; called with 172.31.0.0/20 it yields the four /24s inside that range instead. Reserve the public and private halves from separate slices of the pool so adding private subnets later does not renumber the public ones — renumbering a subnet CIDR is a replacement, and replacing a subnet takes every ENI in it with it.

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,
            }
        )

An internet gateway attached to the VPC does not by itself make a subnet public. The VPC's main route table has no default route, so instances in those subnets can reach other instances and nothing else — a failure that presents as "the NAT is broken" long after the component shipped. Complete the routing before register_outputs:

# components/vpc.py (continued)
# CLI: pulumi up --stack dev
# State implication: RouteTableAssociation is what actually moves a subnet off
# the VPC's main route table; creating the table alone changes nothing.
        public_rt = aws.ec2.RouteTable(
            f"{name}-public-rt",
            vpc_id=self.vpc.id,
            routes=[
                aws.ec2.RouteTableRouteArgs(
                    cidr_block="0.0.0.0/0",
                    gateway_id=igw.id,
                )
            ],
            tags={"Name": f"{name}-public", **args.tags},
            opts=child,
        )
        for i, subnet in enumerate(self.public_subnets):
            aws.ec2.RouteTableAssociation(
                f"{name}-public-rta-{i}",
                subnet_id=subnet.id,
                route_table_id=public_rt.id,
                opts=child,
            )

The enable_nat flag in VpcArgs is where the component earns its cost difference between environments. A NAT gateway is billed per hour plus per gigabyte processed, so a dev stack usually wants one shared gateway or none at all, while production wants one per availability zone for fault isolation. Keeping that decision inside the component means the caller expresses intent (enable_nat=True) and cannot accidentally create four gateways in a sandbox:

# components/vpc.py (continued)
# CLI: pulumi up --stack prod
# Provider note: an Eip in a VPC needs domain="vpc"; omitting it yields
# InvalidParameterCombination from EC2 on newer provider versions.
        self.nat_gateways: list[aws.ec2.NatGateway] = []
        if args.enable_nat:
            gateway_count = args.az_count if args.ha_nat else 1
            for i in range(gateway_count):
                eip = aws.ec2.Eip(f"{name}-nat-eip-{i}", domain="vpc", opts=child)
                self.nat_gateways.append(
                    aws.ec2.NatGateway(
                        f"{name}-nat-{i}",
                        allocation_id=eip.id,
                        subnet_id=self.public_subnets[i].id,
                        tags={"Name": f"{name}-nat-{i}", **args.tags},
                        # State implication: depends_on the IGW; EC2 rejects the
                        # NAT if the gateway is not attached yet.
                        opts=pulumi.ResourceOptions(parent=self, depends_on=[igw]),
                    )
                )

That depends_on is not decorative. Pulumi infers dependencies from Output references, and the NAT gateway never references the internet gateway, so nothing tells the engine to wait. Without it you get an intermittent failure that only appears when the two resources happen to be scheduled in parallel:

# CLI: pulumi up --stack prod
error: creating EC2 NAT Gateway: InvalidGateway.NotAttached: The internet
    gateway igw-04c6f1e8 is not attached to VPC vpc-0b31a9c2

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:

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# 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

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks IndexError watch this boundary az_count watch this boundary len watch this boundary args.cidr_bloc watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

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__.

Reusing one ResourceOptions object and then mutating it. The child variable is shared by reference across every resource in the constructor. Setting child.depends_on = [...] for one resource changes it for all of them, including resources already created — Pulumi reads the options object when it registers, so the effect is order-dependent and maddening to reproduce. Build a fresh pulumi.ResourceOptions(parent=self, depends_on=[...]) for the exceptional cases, as the NAT gateway does above, and leave the shared object immutable.

Output.all returns a list, not a dict. pulumi.Output.all(*[s.id for s in self.public_subnets]) resolves to a plain Python list in that order. Callers that index it — network.public_subnet_ids[0] — are depending on subnet ordering, which is stable only as long as az_count and the AZ list are stable. If a region gains an availability zone and get_availability_zones reorders, index 0 now points somewhere else. Export a mapping keyed by AZ name when downstream code cares which subnet it gets.

A component cannot be protected on behalf of its children. pulumi.ResourceOptions(protect=True) on the component protects the component registration itself, which is a no-op resource. To stop someone destroying the VPC you must set protect=True on the child resources — pass it through the shared child options for production stacks and leave it off elsewhere.

Destroying the stack leaves the NAT elastic IPs if they were adopted. An Eip that was imported rather than created carries whatever retain_on_delete setting it was imported with. Check pulumi state after a destroy in a non-production stack before assuming the component cleans up fully; a stranded elastic IP is billed hourly for as long as it stays unassociated.

Operational Notes

In production the value of a VPC component is that its blast radius is fixed: callers cannot forget a route table or misplace a NAT gateway, because the component owns those decisions. Keep the input surface small — a CIDR, a list of availability zones, and a NAT toggle are usually enough — and derive everything else internally so two teams instantiating the component get byte-identical topologies.

VPC component surface VPC component surface: layered from Inputs: cidr, azs, nat down to Consumers: app stacks. Inputs: cidr, azs, nat Child: subnets, routes, IGW Outputs: vpc_id, subnet_ids Consumers: app stacks
A VPC component takes a few typed inputs and exposes a small, stable output surface.

Version the component deliberately. A change to how subnets are numbered is a breaking change even if the Python signature is unchanged, because it can force-replace live subnets. Treat the synthesized resource names as part of the contract, add snapshot tests that fail on unexpected topology changes, and roll upgrades out through non-production stacks first.

Renaming without replacing

Sooner or later you will want to rename a child — app-public-0 to app-public-us-east-1a, say — and the naive rename destroys and recreates the subnet. aliases is the escape hatch. It tells the engine that a resource with a new URN is the same resource it already has under an old one:

# components/vpc.py (continued)
# CLI: pulumi preview --stack prod   (expect "no changes", not a replacement)
# State implication: the alias is consulted only while the old URN is still in
# state; once the update lands you can delete the alias on the next release.
            subnet = aws.ec2.Subnet(
                f"{name}-public-{zones.names[i]}",
                vpc_id=self.vpc.id,
                cidr_block=cidrs[i],
                availability_zone=zones.names[i],
                opts=pulumi.ResourceOptions(
                    parent=self,
                    aliases=[pulumi.Alias(name=f"{name}-public-{i}")],
                ),
            )

Run pulumi preview and confirm it reports no changes at all. If it still shows a replacement, the alias did not match — the most common reason is that the component's own type token also changed, in which case the alias needs parent= or type_= set as well, not just name=.

Rolling a change out safely

Because the component owns a whole subtree, a bad release is a wide release. Two habits contain it. First, run pulumi preview --diff --stack prod and read the replacement lines specifically — pulumi preview --diff | grep -- '--replace' is a crude but effective pre-flight check, since an update is usually fine and a replacement of a subnet or VPC almost never is. Second, when an upgrade genuinely must replace something, use pulumi up --target with an explicit URN to move one environment's resources at a time rather than letting a single update rewrite the entire network.

For the failure mode where a replacement has already started and left the stack half-migrated, pulumi cancel followed by pulumi refresh restores an accurate picture before you decide whether to roll forward or restore from a checkpoint. Avoid pulumi state delete on a component's children: removing a child from state without removing it from AWS leaves an orphaned subnet that will block the VPC's eventual deletion with DependencyViolation.

FAQ

Why wrap a VPC in a ComponentResource?

It hides the dozen subnets, route tables, and gateways behind one typed constructor, so callers request a VPC by intent and get a consistent, tested topology every time.

How do I expose outputs from the component?

Call register_outputs with the values callers need (VPC id, subnet ids); they become first-class stack outputs usable via StackReference.

Can I unit test the component?

Yes — instantiate it under Pulumi mocks and assert on the resources and outputs it registers. Remember to implement the mocks' call method as well as new_resource, because get_availability_zones is an invoke and will otherwise try to reach AWS during the test.

Can I change the component's type token later?

Not without consequences. The token is embedded in every child URN, so changing myorg:network:Vpc to anything else makes Pulumi see a completely new set of resources and destroy the old ones. If you must change it, add a pulumi.Alias(type_="myorg:network:Vpc") to the component's options for at least one release so the engine can match the old URNs to the new ones.

How do I give one environment three availability zones and another one?

Drive az_count from stack config rather than from the code path. pulumi.Config().get_int("azCount") or 2 in __main__.py keeps the component itself free of environment knowledge, which is the whole point of the typed args object. Changing az_count adds or removes subnets; it does not renumber the existing ones as long as the CIDR derivation is deterministic.

Why does my second pulumi up want to replace subnets I did not touch?

Almost always because the subnet CIDR or availability zone changed. cidr_block and availability_zone are both force-new properties on aws.ec2.Subnet, so anything that shifts them — a different AZ ordering from get_availability_zones, a switch from hardcoded 10.0.x blocks to derived ones — is a replacement. Pin the AZ list explicitly in args if the region's ordering has ever moved on you.

Should the component create the NAT gateway or should the caller?

The component, if you want cost control to be enforceable. Once the caller can create NAT gateways alongside the component, a policy check has to inspect the whole stack rather than one argument. Keeping the decision behind enable_nat and ha_nat means a CrossGuard policy can assert on the component's inputs alone.