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:CreateSubnetec2:DeleteSubnetec2:ModifySubnetAttributeec2:CreateRouteTableec2:CreateRouteec2:AssociateRouteTableec2:CreateInternetGatewayec2:AttachInternetGatewayec2:CreateNatGatewayec2:AllocateAddress(for NAT Gateway EIP)ec2:DescribeSubnetsec2:DescribeRouteTablesec2:DescribeVpcs
AWS Console Method
Step 1: Create Public Subnets
-
Open the VPC Console
- Go to VPC Console in us-east-1
-
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
-
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
-
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
-
Leave auto-assign public IP disabled (this is the default)
Step 3: Create an Internet Gateway
-
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
-
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
-
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
-
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
-
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)
-
Allocate an Elastic IP
- Click Elastic IPs in the left sidebar
- Click Allocate Elastic IP address
- Click Allocate
-
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
-
Wait for the NAT Gateway to become Available (takes 1-2 minutes)
Step 6: Configure Private Route Table
-
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
-
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
-
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:
-
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)
-
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/0pointing toigw-xxxxx - Private route tables should have
0.0.0.0/0pointing tonat-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
- AWS Documentation: VPC with Public and Private Subnets (NAT)
- AWS Documentation: Subnets for Your VPC
- AWS Documentation: Internet Gateways
- AWS Documentation: NAT Gateways
- AWS Documentation: Route Tables
- AWS Best Practices: VPC Design
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.