Provisioning an AWS VPC with Pulumi

Learn how to build a VPC with public and private subnets, an Internet Gateway, and a NAT Gateway using Pulumi and TypeScript

This guide builds the same public/private VPC topology as the Terraform VPC guide on this site, using Pulumi and TypeScript instead of HCL - public and private subnets across two Availability Zones, an Internet Gateway, a NAT Gateway, and the security group most other examples in this Pulumi series build on. Two AZs isn’t just a best practice here - the EKS and RDS guides that follow this one both have a hard AWS requirement for subnets in at least two AZs, so this guide builds that from the start rather than adding it later.

Prerequisites

  • Pulumi CLI and a configured AWS provider (see the Pulumi introduction)
  • @pulumi/pulumi and @pulumi/aws installed via npm install @pulumi/pulumi @pulumi/aws

VPC and Internet Gateway

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const config = new pulumi.Config();
const vpcCidr = config.get("vpcCidr") ?? "10.0.0.0/16";

const vpc = new aws.ec2.Vpc("main", {
    cidrBlock: vpcCidr,
    enableDnsHostnames: true,
    enableDnsSupport: true,
    tags: { Name: "main-vpc" },
});

const igw = new aws.ec2.InternetGateway("main", {
    vpcId: vpc.id,
    tags: { Name: "main-igw" },
});

Public Subnets

aws.config.region reads the region from the provider’s configuration (set via pulumi config set aws:region ...) as a plain string, so ${aws.config.region}a builds an AZ name for whichever region the stack deploys to without hardcoding it. Mapping over an array of AZ suffixes builds one subnet per AZ instead of repeating the same block twice by hand:

const azSuffixes = ["a", "b"];

const publicRouteTable = new aws.ec2.RouteTable("public", {
    vpcId: vpc.id,
    routes: [{
        cidrBlock: "0.0.0.0/0",
        gatewayId: igw.id,
    }],
    tags: { Name: "public-rt" },
});

const publicSubnets = azSuffixes.map((az, i) => {
    const subnet = new aws.ec2.Subnet(`public-${i + 1}`, {
        vpcId: vpc.id,
        cidrBlock: `10.0.${i + 1}.0/24`,
        availabilityZone: `${aws.config.region}${az}`,
        mapPublicIpOnLaunch: true,
        tags: { Name: `public-${i + 1}` },
    });

    new aws.ec2.RouteTableAssociation(`public-${i + 1}`, {
        subnetId: subnet.id,
        routeTableId: publicRouteTable.id,
    });

    return subnet;
});

publicSubnets is a plain TypeScript array of aws.ec2.Subnet resources - publicSubnets[0], publicSubnets.map(s => s.id), and so on all work exactly like they would on any other array, since Pulumi resources are just regular objects your program controls directly (there’s no separate count/for_each meta-argument to reach for, the way there is in Terraform).

Private Subnets and NAT Gateway

A NAT Gateway needs an Elastic IP and must live in a public subnet, even though it routes traffic for the private subnets. A single NAT Gateway (placed in the first public subnet below) is enough to get private subnets online; see Best Practices for why production usually runs one per AZ instead:

const natEip = new aws.ec2.Eip("nat", { domain: "vpc" });

const natGateway = new aws.ec2.NatGateway("main", {
    allocationId: natEip.id,
    subnetId: publicSubnets[0].id,
    tags: { Name: "main-nat" },
});

const privateRouteTable = new aws.ec2.RouteTable("private", {
    vpcId: vpc.id,
    routes: [{
        cidrBlock: "0.0.0.0/0",
        natGatewayId: natGateway.id,
    }],
    tags: { Name: "private-rt" },
});

const privateSubnets = azSuffixes.map((az, i) => {
    const subnet = new aws.ec2.Subnet(`private-${i + 1}`, {
        vpcId: vpc.id,
        cidrBlock: `10.0.${i + 10}.0/24`,
        availabilityZone: `${aws.config.region}${az}`,
        tags: { Name: `private-${i + 1}` },
    });

    new aws.ec2.RouteTableAssociation(`private-${i + 1}`, {
        subnetId: subnet.id,
        routeTableId: privateRouteTable.id,
    });

    return subnet;
});

domain: "vpc" on the Elastic IP is the current, non-deprecated way to allocate an EIP for VPC use (the older boolean vpc: true argument is deprecated in the underlying provider, same as in the Terraform guides on this site).

Security Group

const appSecurityGroup = new aws.ec2.SecurityGroup("app", {
    vpcId: vpc.id,
    description: "Application instances - HTTPS in, everything out",
    ingress: [{
        protocol: "tcp",
        fromPort: 443,
        toPort: 443,
        cidrBlocks: [vpcCidr],
    }],
    egress: [{
        protocol: "-1",
        fromPort: 0,
        toPort: 0,
        cidrBlocks: ["0.0.0.0/0"],
    }],
    tags: { Name: "app-sg" },
});

Stack Outputs

export const vpcId = vpc.id;
export const publicSubnetIds = publicSubnets.map(s => s.id);
export const privateSubnetIds = privateSubnets.map(s => s.id);
export const appSecurityGroupId = appSecurityGroup.id;

Exporting IDs like this lets other Pulumi programs consume them via pulumi.StackReference, the Pulumi equivalent of a Terraform terraform_remote_state data source:

const network = new pulumi.StackReference("my-org/my-infra/dev");
const vpcId = network.getOutput("vpcId");

Best Practices

  1. Multi-AZ by default - production VPCs should repeat the subnet/route-table pattern above across at least two Availability Zones; a single-AZ NAT Gateway is a single point of failure.
  2. One NAT Gateway per AZ in production to avoid cross-AZ data transfer charges and the availability risk of a single NAT Gateway serving every private subnet.
  3. Scope security groups to CIDR blocks or other security groups, not 0.0.0.0/0, for anything other than the load balancer’s public-facing ingress rule.

Conclusion

This is the same VPC shape used throughout the rest of this Pulumi series - the EC2, EKS, and RDS guides all assume publicSubnets/privateSubnets arrays like the ones built here.

For more Pulumi topics, check out: