The most surprising thing about building VPCs with CDK is that you’re not actually building VPCs directly; you’re orchestrating infrastructure code that describes the VPC, and the CDK handles the complex translation to CloudFormation.

Let’s look at a common scenario: a web application needing a public-facing subnet for a load balancer and private subnets for application servers, all within a VPC.

Here’s a CDK snippet that defines this:

from aws_cdk import aws_ec2 as ec2
from constructs import Construct

class MyVpcStack(Stack):
    def __init__(self, scope: Construct, id: str) -> None:
        super().__init__(scope, id)

        vpc = ec2.Vpc(self, "MyVpc",
            max_azs=2,  # Use 2 Availability Zones for high availability
            cidr="10.0.0.0/16", # A /16 CIDR block provides 65,536 IP addresses
            gateway_endpoints={ # Enable S3 access without NAT Gateways
                "S3": ec2.GatewayEndpointOptions(
                    service=ec2.GatewayService.S3,
                    subnets=[ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS)]
                )
            },
            nat_gateways=1, # Provision 1 NAT Gateway for private subnets to access the internet
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    name="Public",
                    subnet_type=ec2.SubnetType.PUBLIC,
                    cidr_mask=24 # Each public subnet gets a /24 CIDR (256 IPs)
                ),
                ec2.SubnetConfiguration(
                    name="PrivateWithEgress",
                    subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
                    cidr_mask=24 # Each private subnet gets a /24 CIDR (256 IPs)
                )
            ]
        )

This code defines a VPC named "MyVpc" with a /16 CIDR block (10.0.0.0/16). It specifies max_azs=2, meaning the VPC’s subnets will be spread across two Availability Zones for resilience. Crucially, subnet_configuration dictates how the IP address space is carved up:

  • Public subnets: These are intended for internet-facing resources like Application Load Balancers. They get a /24 CIDR mask, resulting in subnets like 10.0.0.0/24, 10.0.1.0/24, etc., across the AZs.
  • PrivateWithEgress subnets: These are for your application instances. They also get a /24 mask, creating subnets like 10.0.2.0/24, 10.0.3.0/24, etc. The "WithEgress" part signifies that these subnets are configured to route internet-bound traffic through a NAT Gateway.

The nat_gateways=1 setting tells CDK to provision a NAT Gateway in one of the public subnets. This allows instances in the private subnets to initiate outbound connections to the internet (e.g., for software updates or calling external APIs) without being directly accessible from the internet.

The gateway_endpoints for S3 are a cost-saving and security measure. Instead of sending S3 traffic through the NAT Gateway (which incurs charges and adds latency), this configuration creates a direct, private connection from your VPC to the S3 service within AWS. This is ideal for EC2 instances that need to download or upload data to S3.

When you deploy this CDK code, it generates a CloudFormation template. CloudFormation then orchestrates the creation of the VPC, the specified number of public and private subnets across the AZs, the Internet Gateway, the NAT Gateway, the necessary route tables, and the S3 Gateway Endpoint.

The VPC construct is a powerful abstraction. It intelligently handles the creation of multiple subnets across AZs, route table associations, and NAT Gateway configurations based on your subnet_configuration and nat_gateways parameters. You don’t have to manually create each subnet, route table entry, or NAT Gateway instance. This significantly reduces the boilerplate and potential for misconfiguration.

A common pattern is to then use the created vpc object to define other resources. For example, to place an EC2 instance in a private subnet:

    # ... inside MyVpcStack class ...

    # Retrieve the private subnet that was automatically created
    private_subnet = vpc.select_subnets(
        subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS
    ).subnets[0] # Assuming we want the first private subnet found

    # Example: Launching an EC2 instance in the private subnet
    instance = ec2.Instance(self, "MyInstance",
        vpc=vpc,
        instance_type=ec2.InstanceType("t3.micro"),
        machine_image=ec2.MachineImage.latest_amazon_linux(
            generation=ec2.AmazonLinuxGeneration.AMAZON_LINUX_2
        ),
        subnet_selection=ec2.SubnetSelection(subnets=[private_subnet])
    )

Here, vpc.select_subnets is used to programmatically get a reference to the subnets created by the Vpc construct. This allows you to attach other resources like EC2 instances, RDS databases, or ECS services to specific subnet types within your VPC. The subnet_selection parameter on the ec2.Instance ensures it’s launched into the desired subnet.

What many people miss is how subnet_configuration interacts with nat_gateways and max_azs. If you specify nat_gateways=2 but max_azs=1, CloudFormation will provision two NAT Gateways, but they will both reside within the single AZ, negating some of the high-availability benefits of having multiple NAT Gateways. The CDK construct prioritizes fulfilling your request, but understanding the underlying AWS resource relationships is key to architecting resilient systems.

The next concept to explore is how to integrate services like Application Load Balancers and ECS tasks with these VPC configurations, particularly managing security groups and network ACLs.

Want structured learning?

Take the full Cdk course →