Skip to main content

Ensure VPC Has Both Public and Private Subnets

Overview

This check verifies that your VPCs have proper network segmentation with at least one public subnet and one private subnet. A public subnet has a route to the internet through an Internet Gateway, while a private subnet does not have direct internet access.

Separating workloads into public and private subnets is a fundamental security best practice that limits exposure of internal resources to the internet.

Risk

Without proper subnet separation, your VPC configuration can create security vulnerabilities:

  • Public-only subnets: All workloads are directly exposed to the internet, making them targets for reconnaissance, brute-force attacks, and exploitation
  • Private-only subnets: Resources cannot be patched or updated from the internet, and you lose control over outbound traffic patterns
  • No subnets: The VPC is misconfigured and cannot host any workloads

Proper segmentation ensures that only internet-facing resources (load balancers, bastion hosts) are in public subnets, while databases, application servers, and internal services remain protected in private subnets.

Remediation Steps

Prerequisites

You need:

  • AWS Console access with permissions to create and modify VPC resources
  • An existing VPC (or permission to create one)
  • Understanding of your network requirements (CIDR ranges, availability zones)
Required IAM permissions (for administrators)

Your IAM user or role needs these permissions:

  • ec2:CreateSubnet
  • ec2:DeleteSubnet
  • ec2:ModifySubnetAttribute
  • ec2:CreateRouteTable
  • ec2:CreateRoute
  • ec2:AssociateRouteTable
  • ec2:CreateInternetGateway
  • ec2:AttachInternetGateway
  • ec2:CreateNatGateway
  • ec2:AllocateAddress (for NAT Gateway EIP)
  • ec2:DescribeSubnets
  • ec2:DescribeRouteTables
  • ec2:DescribeVpcs

AWS Console Method

Step 1: Create Public Subnets

  1. Open the VPC Console

  2. Create a public subnet

    • Click Subnets in the left sidebar
    • Click Create subnet
    • Select your VPC from the dropdown
    • Enter a Subnet name (e.g., my-vpc-public-subnet-1)
    • Select an Availability Zone (e.g., us-east-1a)
    • Enter a CIDR block (e.g., 10.0.1.0/24)
    • Click Create subnet
  3. Enable auto-assign public IP

    • Select the newly created subnet
    • Click Actions > Edit subnet settings
    • Check Enable auto-assign public IPv4 address
    • Click Save

Step 2: Create Private Subnets

  1. Create a private subnet

    • Click Create subnet
    • Select your VPC
    • Enter a Subnet name (e.g., my-vpc-private-subnet-1)
    • Select an Availability Zone (e.g., us-east-1a)
    • Enter a CIDR block (e.g., 10.0.10.0/24)
    • Click Create subnet
  2. Leave auto-assign public IP disabled (this is the default)

Step 3: Create an Internet Gateway

  1. Create the gateway

    • Click Internet gateways in the left sidebar
    • Click Create internet gateway
    • Enter a Name (e.g., my-vpc-igw)
    • Click Create internet gateway
  2. Attach to your VPC

    • Select the new internet gateway
    • Click Actions > Attach to VPC
    • Select your VPC and click Attach internet gateway

Step 4: Configure Public Route Table

  1. Create a public route table

    • Click Route tables in the left sidebar
    • Click Create route table
    • Enter a Name (e.g., my-vpc-public-rt)
    • Select your VPC
    • Click Create route table
  2. Add internet route

    • Select the new route table
    • Click the Routes tab, then Edit routes
    • Click Add route
    • For Destination, enter 0.0.0.0/0
    • For Target, select Internet Gateway and choose your internet gateway
    • Click Save changes
  3. Associate with public subnets

    • Click the Subnet associations tab
    • Click Edit subnet associations
    • Select your public subnet(s)
    • Click Save associations

Step 5: Create a NAT Gateway (for private subnet internet access)

  1. Allocate an Elastic IP

    • Click Elastic IPs in the left sidebar
    • Click Allocate Elastic IP address
    • Click Allocate
  2. Create the NAT Gateway

    • Click NAT gateways in the left sidebar
    • Click Create NAT gateway
    • Enter a Name (e.g., my-vpc-nat)
    • Select a public subnet (important: NAT gateways must be in public subnets)
    • Select the Elastic IP you just allocated
    • Click Create NAT gateway
  3. Wait for the NAT Gateway to become Available (takes 1-2 minutes)

Step 6: Configure Private Route Table

  1. Create a private route table

    • Click Route tables
    • Click Create route table
    • Enter a Name (e.g., my-vpc-private-rt)
    • Select your VPC
    • Click Create route table
  2. Add NAT Gateway route

    • Select the new route table
    • Click the Routes tab, then Edit routes
    • Click Add route
    • For Destination, enter 0.0.0.0/0
    • For Target, select NAT Gateway and choose your NAT gateway
    • Click Save changes
  3. Associate with private subnets

    • Click the Subnet associations tab
    • Click Edit subnet associations
    • Select your private subnet(s)
    • Click Save associations
AWS CLI (optional)

Create the complete VPC infrastructure

Replace <your-vpc-id> with your VPC ID throughout these commands.

Step 1: Create subnets

# Create public subnet
aws ec2 create-subnet \
--vpc-id <your-vpc-id> \
--cidr-block 10.0.1.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=my-vpc-public-subnet-1}]' \
--region us-east-1

# Create private subnet
aws ec2 create-subnet \
--vpc-id <your-vpc-id> \
--cidr-block 10.0.10.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=my-vpc-private-subnet-1}]' \
--region us-east-1

Step 2: Enable auto-assign public IP for public subnet

aws ec2 modify-subnet-attribute \
--subnet-id <public-subnet-id> \
--map-public-ip-on-launch \
--region us-east-1

Step 3: Create and attach Internet Gateway

# Create internet gateway
aws ec2 create-internet-gateway \
--tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=my-vpc-igw}]' \
--region us-east-1

# Attach to VPC
aws ec2 attach-internet-gateway \
--internet-gateway-id <igw-id> \
--vpc-id <your-vpc-id> \
--region us-east-1

Step 4: Create public route table

# Create route table
aws ec2 create-route-table \
--vpc-id <your-vpc-id> \
--tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=my-vpc-public-rt}]' \
--region us-east-1

# Add internet route
aws ec2 create-route \
--route-table-id <public-rt-id> \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id <igw-id> \
--region us-east-1

# Associate with public subnet
aws ec2 associate-route-table \
--route-table-id <public-rt-id> \
--subnet-id <public-subnet-id> \
--region us-east-1

Step 5: Create NAT Gateway

# Allocate Elastic IP
aws ec2 allocate-address \
--domain vpc \
--tag-specifications 'ResourceType=elastic-ip,Tags=[{Key=Name,Value=my-vpc-nat-eip}]' \
--region us-east-1

# Create NAT Gateway (in public subnet)
aws ec2 create-nat-gateway \
--subnet-id <public-subnet-id> \
--allocation-id <eip-allocation-id> \
--tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=my-vpc-nat}]' \
--region us-east-1

Wait for NAT Gateway to become available:

aws ec2 describe-nat-gateways \
--nat-gateway-ids <nat-gateway-id> \
--query 'NatGateways[0].State' \
--region us-east-1

Step 6: Create private route table

# Create route table
aws ec2 create-route-table \
--vpc-id <your-vpc-id> \
--tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=my-vpc-private-rt}]' \
--region us-east-1

# Add NAT Gateway route
aws ec2 create-route \
--route-table-id <private-rt-id> \
--destination-cidr-block 0.0.0.0/0 \
--nat-gateway-id <nat-gateway-id> \
--region us-east-1

# Associate with private subnet
aws ec2 associate-route-table \
--route-table-id <private-rt-id> \
--subnet-id <private-subnet-id> \
--region us-east-1
CloudFormation (optional)

This template creates a complete VPC with public and private subnets, Internet Gateway, and NAT Gateway:

AWSTemplateFormatVersion: '2010-09-09'
Description: VPC with separated public and private subnets for proper network segmentation

Parameters:
VpcCidr:
Type: String
Description: CIDR block for the VPC
Default: 10.0.0.0/16
AllowedPattern: ^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$

PublicSubnet1Cidr:
Type: String
Description: CIDR block for public subnet 1
Default: 10.0.1.0/24

PublicSubnet2Cidr:
Type: String
Description: CIDR block for public subnet 2
Default: 10.0.2.0/24

PrivateSubnet1Cidr:
Type: String
Description: CIDR block for private subnet 1
Default: 10.0.10.0/24

PrivateSubnet2Cidr:
Type: String
Description: CIDR block for private subnet 2
Default: 10.0.11.0/24

AvailabilityZone1:
Type: AWS::EC2::AvailabilityZone::Name
Description: Availability Zone for subnets 1
Default: us-east-1a

AvailabilityZone2:
Type: AWS::EC2::AvailabilityZone::Name
Description: Availability Zone for subnets 2
Default: us-east-1b

Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-vpc

InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-igw

InternetGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref VPC

PublicSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Ref AvailabilityZone1
CidrBlock: !Ref PublicSubnet1Cidr
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-public-subnet-1

PublicSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Ref AvailabilityZone2
CidrBlock: !Ref PublicSubnet2Cidr
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-public-subnet-2

PrivateSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Ref AvailabilityZone1
CidrBlock: !Ref PrivateSubnet1Cidr
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-private-subnet-1

PrivateSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Ref AvailabilityZone2
CidrBlock: !Ref PrivateSubnet2Cidr
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-private-subnet-2

NatGatewayEIP:
Type: AWS::EC2::EIP
DependsOn: InternetGatewayAttachment
Properties:
Domain: vpc
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-nat-eip

NatGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatGatewayEIP.AllocationId
SubnetId: !Ref PublicSubnet1
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-nat

PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-public-rt

PublicRoute:
Type: AWS::EC2::Route
DependsOn: InternetGatewayAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway

PublicSubnet1RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnet1

PublicSubnet2RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnet2

PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-private-rt

PrivateRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGateway

PrivateSubnet1RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnet1

PrivateSubnet2RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnet2

Outputs:
VpcId:
Description: VPC ID
Value: !Ref VPC
Export:
Name: !Sub ${AWS::StackName}-VpcId

PublicSubnet1Id:
Description: Public Subnet 1 ID
Value: !Ref PublicSubnet1

PublicSubnet2Id:
Description: Public Subnet 2 ID
Value: !Ref PublicSubnet2

PrivateSubnet1Id:
Description: Private Subnet 1 ID
Value: !Ref PrivateSubnet1

PrivateSubnet2Id:
Description: Private Subnet 2 ID
Value: !Ref PrivateSubnet2

NatGatewayId:
Description: NAT Gateway ID
Value: !Ref NatGateway

Deploy with:

aws cloudformation deploy \
--template-file vpc-public-private-subnets.yaml \
--stack-name my-vpc-with-subnets \
--region us-east-1
Terraform (optional)
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0"
}
}
}

provider "aws" {
region = "us-east-1"
}

variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}

variable "name_prefix" {
description = "Prefix for resource names"
type = string
default = "my-app"
}

variable "availability_zones" {
description = "List of availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}

variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
default = ["10.0.10.0/24", "10.0.11.0/24"]
}

# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true

tags = {
Name = "${var.name_prefix}-vpc"
}
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id

tags = {
Name = "${var.name_prefix}-igw"
}
}

# Public Subnets
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true

tags = {
Name = "${var.name_prefix}-public-subnet-${count.index + 1}"
Type = "public"
}
}

# Private Subnets
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = false

tags = {
Name = "${var.name_prefix}-private-subnet-${count.index + 1}"
Type = "private"
}
}

# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"

tags = {
Name = "${var.name_prefix}-nat-eip"
}

depends_on = [aws_internet_gateway.main]
}

# NAT Gateway (in public subnet)
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id

tags = {
Name = "${var.name_prefix}-nat"
}

depends_on = [aws_internet_gateway.main]
}

# Public Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id

route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}

tags = {
Name = "${var.name_prefix}-public-rt"
}
}

# Private Route Table
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id

route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}

tags = {
Name = "${var.name_prefix}-private-rt"
}
}

# Associate public subnets with public route table
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}

# Associate private subnets with private route table
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}

# Outputs
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}

output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}

output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}

output "nat_gateway_id" {
description = "NAT Gateway ID"
value = aws_nat_gateway.main.id
}

Deploy with:

terraform init
terraform plan -var="name_prefix=my-app"
terraform apply -var="name_prefix=my-app"

Verification

After creating your subnets, verify the configuration:

  1. In the AWS Console:

    • Go to VPC > Subnets
    • Look for subnets with different types (check route table associations)
    • Public subnets should have a route to an Internet Gateway
    • Private subnets should have a route to a NAT Gateway (or no internet route)
  2. Check route tables:

    • Go to VPC > Route tables
    • Select each route table and view the Routes tab
    • Public route tables should have 0.0.0.0/0 pointing to igw-xxxxx
    • Private route tables should have 0.0.0.0/0 pointing to nat-xxxxx
CLI verification commands

List all subnets in a VPC:

aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=<your-vpc-id>" \
--query 'Subnets[*].{SubnetId:SubnetId,CidrBlock:CidrBlock,AZ:AvailabilityZone,Name:Tags[?Key==`Name`].Value|[0]}' \
--output table \
--region us-east-1

Check route tables and their associations:

aws ec2 describe-route-tables \
--filters "Name=vpc-id,Values=<your-vpc-id>" \
--query 'RouteTables[*].{RouteTableId:RouteTableId,Name:Tags[?Key==`Name`].Value|[0],Routes:Routes[*].{Dest:DestinationCidrBlock,Target:GatewayId||NatGatewayId}}' \
--region us-east-1

Verify a subnet is public (has route to Internet Gateway):

aws ec2 describe-route-tables \
--filters "Name=association.subnet-id,Values=<public-subnet-id>" \
--query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`].GatewayId' \
--region us-east-1

Expected output for a public subnet shows an Internet Gateway ID (igw-xxxxx).

Verify a subnet is private (has route to NAT Gateway or no internet route):

aws ec2 describe-route-tables \
--filters "Name=association.subnet-id,Values=<private-subnet-id>" \
--query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`].NatGatewayId' \
--region us-east-1

Expected output for a private subnet shows a NAT Gateway ID (nat-xxxxx).

Additional Resources

Notes

  • Costs: NAT Gateways incur hourly charges (~$0.045/hour in us-east-1) plus data processing fees. For cost-sensitive workloads, consider NAT instances or VPC endpoints for AWS services.

  • High availability: For production workloads, create subnets in multiple Availability Zones and consider using one NAT Gateway per AZ to avoid cross-AZ data transfer charges and single points of failure.

  • CIDR planning: Plan your CIDR blocks carefully. Use non-overlapping ranges for public and private subnets. A common pattern is to use lower ranges for public (e.g., 10.0.1.0/24, 10.0.2.0/24) and higher ranges for private (e.g., 10.0.10.0/24, 10.0.11.0/24).

  • Existing VPCs: If you already have a VPC with only public or only private subnets, you can add the missing subnet type without affecting existing resources.

  • Default VPCs: AWS creates a default VPC in each region with public subnets only. Consider creating custom VPCs with proper segmentation for production workloads.

  • VPC endpoints: For private subnets that need access to AWS services (S3, DynamoDB, etc.), consider using VPC endpoints instead of routing through NAT Gateways to reduce costs and improve security.

  • Security groups and NACLs: Subnet separation is just one layer of defense. Also configure security groups and network ACLs to control traffic between and within subnets.