WorkSpaces VPC Best Practices: 1 Public Subnet, 2 Private Subnets with NAT Gateway
Overview
This check ensures your Amazon WorkSpaces are deployed in a properly segmented VPC with at least 1 public subnet and 2 private subnets, with a NAT Gateway providing controlled internet access. WorkSpaces themselves should reside only in the private subnets.
Risk
Without proper network segmentation:
- Direct internet exposure: WorkSpaces in public subnets are reachable from the internet, enabling credential attacks and session hijacking
- Reduced availability: Missing NAT Gateway egress can cause updates and directory connectivity failures
- Compliance gaps: Many security frameworks require network isolation for virtual desktop infrastructure
Remediation Steps
Prerequisites
You need:
- AWS account access with permissions to create VPC resources and WorkSpaces
- An existing WorkSpaces directory (AWS Managed Microsoft AD or Simple AD), or plan to create one
Required IAM permissions
Your IAM user or role needs these permissions:
ec2:CreateVpc,ec2:CreateSubnet,ec2:CreateInternetGatewayec2:CreateNatGateway,ec2:AllocateAddressec2:CreateRouteTable,ec2:CreateRoute,ec2:AssociateRouteTableworkspaces:RegisterWorkspaceDirectory,workspaces:CreateWorkspaces
AWS Console Method
Step 1: Create the VPC
- Open the VPC Console
- Click Create VPC
- Select VPC and more (this creates subnets automatically)
- Configure:
- Name tag:
workspaces-vpc - IPv4 CIDR block:
10.0.0.0/16 - Number of Availability Zones: 2
- Number of public subnets: 1
- Number of private subnets: 2
- NAT gateways: In 1 AZ (for cost savings) or 1 per AZ (for high availability)
- Name tag:
- Click Create VPC
Step 2: Verify the Architecture
After creation, confirm:
- 1 public subnet with a route to the Internet Gateway (
0.0.0.0/0->igw-xxxxx) - 2 private subnets with routes to the NAT Gateway (
0.0.0.0/0->nat-xxxxx) - NAT Gateway is in the public subnet
Step 3: Register or Update WorkSpaces Directory
- Open the WorkSpaces Console
- Go to Directories
- If registering a new directory:
- Click Register directory
- Select your directory
- Choose both private subnets (not the public one)
- If updating an existing directory:
- Select the directory and review its subnet configuration
- If using public subnets, you will need to re-register with private subnets
Step 4: Launch WorkSpaces in Private Subnets
When launching new WorkSpaces, they will automatically use the private subnets associated with your directory.
AWS CLI (optional)
Create the VPC and subnets:
# Create VPC
VPC_ID=$(aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--region us-east-1 \
--query 'Vpc.VpcId' \
--output text)
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-hostnames
# Create Internet Gateway
IGW_ID=$(aws ec2 create-internet-gateway \
--region us-east-1 \
--query 'InternetGateway.InternetGatewayId' \
--output text)
aws ec2 attach-internet-gateway --vpc-id $VPC_ID --internet-gateway-id $IGW_ID --region us-east-1
# Create public subnet
PUBLIC_SUBNET=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.1.0/24 \
--availability-zone us-east-1a \
--region us-east-1 \
--query 'Subnet.SubnetId' \
--output text)
# Create private subnets
PRIVATE_SUBNET_1=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.2.0/24 \
--availability-zone us-east-1a \
--region us-east-1 \
--query 'Subnet.SubnetId' \
--output text)
PRIVATE_SUBNET_2=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.3.0/24 \
--availability-zone us-east-1b \
--region us-east-1 \
--query 'Subnet.SubnetId' \
--output text)
Create NAT Gateway:
# Allocate Elastic IP
EIP_ALLOC=$(aws ec2 allocate-address \
--domain vpc \
--region us-east-1 \
--query 'AllocationId' \
--output text)
# Create NAT Gateway in public subnet
NAT_GW=$(aws ec2 create-nat-gateway \
--subnet-id $PUBLIC_SUBNET \
--allocation-id $EIP_ALLOC \
--region us-east-1 \
--query 'NatGateway.NatGatewayId' \
--output text)
# Wait for NAT Gateway to become available
aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_GW --region us-east-1
Configure route tables:
# Create and configure public route table
PUBLIC_RT=$(aws ec2 create-route-table \
--vpc-id $VPC_ID \
--region us-east-1 \
--query 'RouteTable.RouteTableId' \
--output text)
aws ec2 create-route \
--route-table-id $PUBLIC_RT \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $IGW_ID \
--region us-east-1
aws ec2 associate-route-table \
--subnet-id $PUBLIC_SUBNET \
--route-table-id $PUBLIC_RT \
--region us-east-1
# Create and configure private route table
PRIVATE_RT=$(aws ec2 create-route-table \
--vpc-id $VPC_ID \
--region us-east-1 \
--query 'RouteTable.RouteTableId' \
--output text)
aws ec2 create-route \
--route-table-id $PRIVATE_RT \
--destination-cidr-block 0.0.0.0/0 \
--nat-gateway-id $NAT_GW \
--region us-east-1
aws ec2 associate-route-table \
--subnet-id $PRIVATE_SUBNET_1 \
--route-table-id $PRIVATE_RT \
--region us-east-1
aws ec2 associate-route-table \
--subnet-id $PRIVATE_SUBNET_2 \
--route-table-id $PRIVATE_RT \
--region us-east-1
Output the subnet IDs for WorkSpaces registration:
echo "Private Subnet 1: $PRIVATE_SUBNET_1"
echo "Private Subnet 2: $PRIVATE_SUBNET_2"
CloudFormation (optional)
Save this template and deploy it via the CloudFormation console or CLI.
AWSTemplateFormatVersion: '2010-09-09'
Description: VPC with 1 public subnet, 2 private subnets, and NAT Gateway for WorkSpaces
Parameters:
VpcCidr:
Type: String
Default: 10.0.0.0/16
Description: CIDR block for the VPC
PublicSubnetCidr:
Type: String
Default: 10.0.1.0/24
Description: CIDR block for the public subnet
PrivateSubnet1Cidr:
Type: String
Default: 10.0.2.0/24
Description: CIDR block for the first private subnet
PrivateSubnet2Cidr:
Type: String
Default: 10.0.3.0/24
Description: CIDR block for the second private subnet
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCidr
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: workspaces-vpc
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: workspaces-igw
InternetGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref VPC
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [0, !GetAZs '']
CidrBlock: !Ref PublicSubnetCidr
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: workspaces-public-subnet
PrivateSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [0, !GetAZs '']
CidrBlock: !Ref PrivateSubnet1Cidr
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: workspaces-private-subnet-1
PrivateSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [1, !GetAZs '']
CidrBlock: !Ref PrivateSubnet2Cidr
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: workspaces-private-subnet-2
NatGatewayEIP:
Type: AWS::EC2::EIP
DependsOn: InternetGatewayAttachment
Properties:
Domain: vpc
Tags:
- Key: Name
Value: workspaces-nat-eip
NatGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatGatewayEIP.AllocationId
SubnetId: !Ref PublicSubnet
Tags:
- Key: Name
Value: workspaces-nat
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: workspaces-public-rt
DefaultPublicRoute:
Type: AWS::EC2::Route
DependsOn: InternetGatewayAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnet
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: workspaces-private-rt
DefaultPrivateRoute:
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: WorkspacesVpcId
PrivateSubnet1Id:
Description: Private Subnet 1 ID (for WorkSpaces)
Value: !Ref PrivateSubnet1
Export:
Name: WorkspacesPrivateSubnet1Id
PrivateSubnet2Id:
Description: Private Subnet 2 ID (for WorkSpaces)
Value: !Ref PrivateSubnet2
Export:
Name: WorkspacesPrivateSubnet2Id
Deploy the stack:
aws cloudformation create-stack \
--stack-name workspaces-vpc \
--template-body file://template.yaml \
--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 VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for public subnet"
type = string
default = "10.0.1.0/24"
}
variable "private_subnet_1_cidr" {
description = "CIDR block for first private subnet"
type = string
default = "10.0.2.0/24"
}
variable "private_subnet_2_cidr" {
description = "CIDR block for second private subnet"
type = string
default = "10.0.3.0/24"
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "workspaces" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "workspaces-vpc"
}
}
resource "aws_internet_gateway" "workspaces" {
vpc_id = aws_vpc.workspaces.id
tags = {
Name = "workspaces-igw"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.workspaces.id
cidr_block = var.public_subnet_cidr
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "workspaces-public-subnet"
}
}
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.workspaces.id
cidr_block = var.private_subnet_1_cidr
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = false
tags = {
Name = "workspaces-private-subnet-1"
}
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.workspaces.id
cidr_block = var.private_subnet_2_cidr
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = false
tags = {
Name = "workspaces-private-subnet-2"
}
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "workspaces-nat-eip"
}
depends_on = [aws_internet_gateway.workspaces]
}
resource "aws_nat_gateway" "workspaces" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
tags = {
Name = "workspaces-nat"
}
depends_on = [aws_internet_gateway.workspaces]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.workspaces.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.workspaces.id
}
tags = {
Name = "workspaces-public-rt"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.workspaces.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.workspaces.id
}
tags = {
Name = "workspaces-private-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_1" {
subnet_id = aws_subnet.private_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_2" {
subnet_id = aws_subnet.private_2.id
route_table_id = aws_route_table.private.id
}
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.workspaces.id
}
output "private_subnet_1_id" {
description = "Private Subnet 1 ID (for WorkSpaces)"
value = aws_subnet.private_1.id
}
output "private_subnet_2_id" {
description = "Private Subnet 2 ID (for WorkSpaces)"
value = aws_subnet.private_2.id
}
Deploy:
terraform init
terraform plan
terraform apply
Verification
After remediation, verify your setup:
-
In the VPC Console: Check that your VPC has:
- 1 public subnet with route
0.0.0.0/0pointing to an Internet Gateway - 2 private subnets with route
0.0.0.0/0pointing to a NAT Gateway - NAT Gateway deployed in the public subnet
- 1 public subnet with route
-
In the WorkSpaces Console: Confirm your directory is registered with the private subnets only
-
Re-run Prowler to confirm the check passes:
prowler aws --checks workspaces_vpc_2private_1public_subnets_nat
CLI verification commands
# List subnets and their route table associations
aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=<your-vpc-id>" \
--query 'Subnets[*].[SubnetId,CidrBlock,AvailabilityZone,MapPublicIpOnLaunch]' \
--output table \
--region us-east-1
# Check NAT Gateway status
aws ec2 describe-nat-gateways \
--filter "Name=vpc-id,Values=<your-vpc-id>" \
--query 'NatGateways[*].[NatGatewayId,SubnetId,State]' \
--output table \
--region us-east-1
# Verify WorkSpaces directory subnet configuration
aws workspaces describe-workspace-directories \
--query 'Directories[*].[DirectoryId,DirectoryName,SubnetIds]' \
--output table \
--region us-east-1
Additional Resources
- Amazon WorkSpaces VPC Requirements
- VPC with Public and Private Subnets (NAT)
- NAT Gateway Best Practices
- Prowler Check Documentation
Notes
- Cost consideration: NAT Gateways incur hourly charges plus data processing fees. For high availability, deploy NAT Gateways in multiple AZs, but this increases cost.
- Existing WorkSpaces: If you have WorkSpaces in public subnets, you cannot simply move them. You must terminate and recreate them in the private subnets after updating the directory registration.
- Directory Service: AWS Managed Microsoft AD and Simple AD both support deployment in private subnets with NAT Gateway for outbound connectivity.
- Two private subnets: The requirement for two private subnets ensures high availability across multiple Availability Zones, which is critical for production WorkSpaces deployments.