Create VPC with IPv6 subnets in AWS CDK

This post describes how you can create a VPC with subnets that support IPv6 with AWS CDK in Python.

At the moment of writing, the VPC L2 (layer 2) construct in AWS CDK does not support IPv6 subnets, therefore I have created the VPC from scratch using L1 constructs (aka CFN Resources).

The code

First, I define a properties object that holds configuration.

import typing

import aws_cdk as cdk
from aws_cdk import aws_ec2 as ec2
from constructs import Construct

from pydantic import BaseModel


class VpcIpv6Props(BaseModel):
    vpc_name: str
    vpc_ipv4_cidr_block: str
    number_of_azs: int

I define a VpcIpv6 construct and, within it, I start by creating a VPC using the L1 constrcut.

class VpcIpv6(Construct):
    def __init__(
        self,
        scope: Construct,
        construct_id: str,
        props: VpcIpv6Props,
        **kwargs: typing.Any,
    ) -> None:
        super().__init__(scope, construct_id, **kwargs)

        vpc = ec2.CfnVPC(
            self,
            "vpc",
            cidr_block=props.vpc_ipv4_cidr_block,
            enable_dns_support=True,
            enable_dns_hostnames=True,
            tags=[cdk.CfnTag(key="Name", value=props.vpc_name)],
        )
        self.vpc = vpc

What follows, is code that is defined within __init__() function within VpcIpv6.

I associate an IPv6 CIDR range to the VPC.

ec2.CfnVPCCidrBlock(self, "ipv6cidr", vpc_id=vpc.attr_vpc_id, amazon_provided_ipv6_cidr_block=True)

I create an Internet Gateway, and I attach it to the VPC.

internet_gateway = ec2.CfnInternetGateway(
    self, "igw", tags=[cdk.CfnTag(key="Name", value=f"{props.vpc_name}-igw")]
)

ec2.CfnVPCGatewayAttachment(
    self,
    "igw-attachment",
    vpc_id=vpc.attr_vpc_id,
    internet_gateway_id=internet_gateway.attr_internet_gateway_id,
)

I create an Egress-Only Internet Gateway and attach it to the VPC. This is the equivalent of a NAT Gateway in IPv4, and the good news is that the Egress-Only Internet Gateway has no fixed hourly cost like NAT Gateway.

egress_only_internet_gateway = ec2.CfnEgressOnlyInternetGateway(self, "egress-only-igw", vpc_id=vpc.attr_vpc_id)

I create a Route Table for the public subnets. I create a default route to the Internet Gateway for IPv4 and IPv6.

public_subnet_route_table = ec2.CfnRouteTable(
    self,
    "public-subnet-route-table",
    vpc_id=vpc.attr_vpc_id,
    tags=[cdk.CfnTag(key="Name", value=f"{props.vpc_name}-public")],
)

ec2.CfnRoute(
    self,
    "public-subnet-default-route-ipv4",
    destination_cidr_block="0.0.0.0/0",
    route_table_id=public_subnet_route_table.attr_route_table_id,
    gateway_id=internet_gateway.attr_internet_gateway_id,
)

ec2.CfnRoute(
    self,
    "public-subnet-default-route-ipv6",
    destination_ipv6_cidr_block="::/0",
    route_table_id=public_subnet_route_table.attr_route_table_id,
    gateway_id=internet_gateway.attr_internet_gateway_id,
)

I create a Route Table for private subnets. I create a default route for only IPv6. The private subnet in this example is a IPv6-only subnet.

private_subnet_route_table = ec2.CfnRouteTable(
    self,
    "private-subnet-route-table",
    vpc_id=vpc.attr_vpc_id,
    tags=[cdk.CfnTag(key="Name", value=f"{props.vpc_name}-private")],
)

ec2.CfnRoute(
    self,
    "private-subnet-default-route-ipv6",
    destination_ipv6_cidr_block="::/0",
    route_table_id=private_subnet_route_table.attr_route_table_id,
    egress_only_internet_gateway_id=egress_only_internet_gateway.attr_id,
)

I get the list of availability zones. I slice the VPC CIDR range in smaller ranges to be allocated to subnets. Note that for IPv6 subnets must be allocated a /64 range.

all_available_azs = cdk.Fn.get_azs()

vpc_ipv6_cidr_block = cdk.Fn.select(0, vpc.attr_ipv6_cidr_blocks)

ipv6_cidr_blocks = cdk.Fn.cidr(vpc_ipv6_cidr_block, 2**8, "64")
ipv4_cidr_blocks = cdk.Fn.cidr(props.vpc_ipv4_cidr_block, 2**4, "12")

Finally, I loop through the availability zones and I create a public and a private subnet in each. Public subnets are dual-stack, they are given both IPv4 and IPv6 CIDR blocks. Private subnets are IPv6-only. I associate each subnet with the corresponding route table.

for az_index in range(props.number_of_azs):
    az_no = az_index + 1

    public_subnet = ec2.CfnSubnet(
        self,
        f"public-subnet-{az_no}",
        vpc_id=vpc.attr_vpc_id,
        cidr_block=cdk.Fn.select(2 * az_index, ipv4_cidr_blocks),
        ipv6_cidr_block=cdk.Fn.select(2 * az_index, ipv6_cidr_blocks),
        availability_zone=cdk.Fn.select(az_index, all_available_azs),
        map_public_ip_on_launch=True,
        assign_ipv6_address_on_creation=True,
        tags=[cdk.CfnTag(key="Name", value=f"{props.vpc_name}-public-{az_no}")]
    )
    ec2.CfnSubnetRouteTableAssociation(
        self,
        f"public-subnet-{az_no}-route-table-association",
        route_table_id=public_subnet_route_table.attr_route_table_id,
        subnet_id=public_subnet.attr_subnet_id,
    )

    private_subnet = ec2.CfnSubnet(
        self,
        f"private-subnet-{az_no}",
        vpc_id=vpc.attr_vpc_id,
        ipv6_cidr_block=cdk.Fn.select(2 * az_index + 1, ipv6_cidr_blocks),
        availability_zone=cdk.Fn.select(az_index, all_available_azs),
        ipv6_native=True,
        tags=[cdk.CfnTag(key="Name", value=f"{props.vpc_name}-private-{az_no}")]
    )
    ec2.CfnSubnetRouteTableAssociation(
        self,
        f"private-subnet-{az_no}-route-table-association",
        route_table_id=private_subnet_route_table.attr_route_table_id,
        subnet_id=private_subnet.attr_subnet_id,
    )

This is how you instantiate the construct

VpcIpv6(
    self,
    "vpc",
    VpcIpv6Props(
        vpc_name="my-ipv6-vpc",
        vpc_ipv4_cidr_block="10.0.0.0/16",
        number_of_azs=3,
    ),
)

Connectivity tests

After you have included the above construct in your CDK project and have deployed it, it is time to test IPv6 connectivity from public and private subnets.

I create 2 EC2 instances of type t3 (you need the latest generation), one in the public subnet (with a public IP address for both IPv4 and IPv6) and one in the private subnet. I am using the same key pair for both instances.

First I add the SSH key to the authentication agent

ssh-add ~/path/to/your/key.pem

Then I remote to the public EC2 instance (make sure the Security Group allows SSH access). I do not have IPv6 connectivity at home, that's why I am using the IPv4 public IP address to access the public instance. The -A is important so that you can jump from the public instance onto the private instance.

ssh -A ec2-user@<public IPv4 address of public instance>

As a first test, I check that the public EC2 instance can access Google over IPv6.

wget http://ipv6.google.com

Then I copy the IPv6 address of the private EC2 instance, and on the same terminal, I jump from the public EC2 instance to the private EC2 instance via IPv6.

ssh ec2-user@<IPv6 address of private instance>

I repeat the test

wget http://ipv6.google.com

Congratulations! You have created public and private subnets with IPv6 support.