Virtual Private Clouds on AWS
Introduction
A Virtual Private Cloud (VPC) is a logically isolated virtual network with a cloud provider.
AWS accounts come with a default VPC in each region that is good for getting started. That being said, it is always good to create custom VPCs based on specific application requirements, security needs and long-term scalability goals. Investing time to thoughtfully design VPC infrastructure can pay dividends in the form of a robust, secure and adaptable cloud networking foundation.
VPC Overview

A VPC in AWS spans an entire AWS region — with all its Availability Zones (AZs). AZs are physically isolated data centers within the AWS network. Within each AZ you can have one or more subnets — a subnet can only exist in one AZ.
Components of a VPC
The components of a VPC include;
Subnets
A subnet is a range of IP addresses that resides within a single Availability Zone(AZ) in a VPC. Examples of subnets include;
Public subnet: A public subnet has a direct route to the Internet Gateway. Instances deployed in the public subnet can directly access the internet and are also directly reachable from the internet.
Private subnet: A private subnet does not have a direct route to the Internet Gateway. Instances in a private subnet can access the internet through a NAT Gateway or NAT device.
VPN-only subnet: A VPN-only subnet is one that only has a route to a site-to-site VPN connection.
Isolated subnet: An isolated subnet is one with no routes to resources outside the VPC
Gateways
Gateways are connectivity points within a VPC. Below are the various types of gateways;
Internet Gateways: These allow instances in a public subnet to connect to the internet. Instances within a VPC are only aware of their private IP addresses. The internet gateway logically provides one-to-one NAT when communicating from the VPC to the internet and vice versa.
NAT and Egress-only Gateways: These allow instances in private subnets to connect to the internet while remaining unreachable from the internet. NAT gateways are used for IPv4 traffic whereas Egress-only gateways are used for IPv6 traffic.
When deployed in a private subnet, a NAT gateway is considered a private NAT gateway and just like any other instance deployed within the private subnet, it won’t have internet access.
Transit Gateways: These allow connecting two VPCs or a VPC and an on-premise network.
VPN Gateways: These allow creation of an IPsec VPN connection between a VPC and an on-premise network.
VPC peering connection: These allow secure and direct communication between two VPCs within the AWS infrastructure. The two VPCs can be in two different accounts or AWS regions however their CIDR blocks should not overlap. Traffic between peered VPCs never traverses the public internet and instances communicate using their private IP addresses as though they’re in the same network.
When connecting two VPCs within AWS, default to using a VPC peering connection because it introduces no extra piece of physical hardware or gateway — hence no additional failure points — and attracts no additional charge.
Route tables
Route tables contain a set of rules(routes) that determine where network traffic from a subnet or gateway is directed. When directing traffic, the route is chosen based on the longest prefix that matches its destination i.e the most specific route match.
Access Control Lists and Security Groups
Access Control Lists (ACLs) and Security Groups control traffic to and from a network interface. ACLs work at subnet level whereas Security Groups work at instance level. A VPC comes with a default ACL that allows all inbound and outbound traffic.
ACLs are stateless whereas Security Groups are stateful. By being stateless, it is possible that an instance can reach a destination however return traffic from the destination is rejected. When configuring an ACL care must be taken to ensure that there are explicit rules to allow return traffic for allowed outbound traffic. For Security Groups, return traffic is allowed regardless of the configured rules i.e they’re stateful.
Setting up a VPC
Prerequisites
This is going to be a hands-on lab. Before starting;
Install Terraform and Terragrunt on your computer. To easily switch between Terraform versions, I recommend installing tfenv. Installation and usage instructions for tfenv are found on the link https://github.com/tfutils/tfenv
Have an AWS account. AWS provides a generous free tier that can be used to complete this hands-on lab at a minimal cost.
Terraform is an Infrastructure as Code(IaC) tool. IaC is the act of declaratively configuring infrastructure and storing the declarative configuration in source control. One of the biggest benefits of IaC is that it makes it easy to review how our infrastructure is set up.
Planning the VPC CIDR blocks
The first key decision to make is the CIDR block to use for the VPC. The following are the supported private IP address ranges on AWS — RFC 1918.
10.0.0.0/8
172.16.0.0/12
192.168.0.0./16
An excellent cheatsheet for subnets and and other IP address CIDR network references is found at freecodecamp
The source code for all the steps described in the next sections is located at the Github repository https://github.com/ivanoronee/aws-vpc-overview
Note: You can follow along, creating one resource at a time by downloading the code and commenting out all sections — except the locals section — of the main.tf file, and only uncommenting and trying out the one’s being discussed. Instructions on how to run the code are found in the repository.
Below is the configuration that will be used for this walkthrough;
data "aws_availability_zones" "available" {}
locals {
vpc_cir_block = "172.16.0.0/16"
nat_gateway_subnet = aws_subnet.public[element(keys(local.public_subnets_cidr_az_mapping), 0)]
nat_gateway_az = local.nat_gateway_subnet["availability_zone"]
public_internet_cidr = "0.0.0.0/0"
availability_zones = slice(data.aws_availability_zones.available.names, 0, 3)
public_subnets_cidr_az_mapping = {
"172.16.10.0/24" = local.availability_zones[0],
"172.16.20.0/24" = local.availability_zones[1],
"172.16.30.0/24" = local.availability_zones[2]
}
private_subnets_cidr_az_mapping = {
"172.16.40.0/24" = local.availability_zones[0],
"172.16.50.0/24" = local.availability_zones[1],
"172.16.60.0/24" = local.availability_zones[2]
}
resource_tags = {
"ManagedBy" : "Terraform"
}
}
Creating the VPC
resource "aws_vpc" "main" {
cidr_block = local.vpc_cir_block
instance_tenancy = "default"
enable_dns_hostnames = false
tags = merge(
{ "Name" = var.vpc_name },
local.resource_tags,
)
}
Configuring the public subnets
As earlier stated, a public subnet is one with a route to the Internet Gateway. To better understand the steps for creating a public subnet, please take a look at the diagram shared in the overview and see how the following steps build it;
Create the public subnets
resource "aws_subnet" "public" {
for_each = local.public_subnets_cidr_az_mapping
vpc_id = aws_vpc.main.id
cidr_block = each.key
availability_zone = each.value
tags = merge(
{ Name = "${var.vpc_name}-public-${each.value}" },
local.resource_tags
)
}
Create a route table for the public subnets
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = merge(
{ Name = "${var.vpc_name}-public" },
local.resource_tags
)
}
Associate all public subnets with the public route table
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
Create an Internet Gateway for the public subnets
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(
{ Name = "${var.vpc_name}-igw" },
local.resource_tags
)
}
Add a route to the Internet Gateway in the public route table
resource "aws_route" "igw" {
route_table_id = aws_route_table.public.id
destination_cidr_block = local.public_internet_cidr
gateway_id = aws_internet_gateway.main.id
}
With the above, we have a VPC and public subnets in which resources — such as EC2 instances — that can reach the internet and also be reachable from the internet can be deployed.
Configuring the private subnets
As stated, private subnets do not have a direct route to the Internet Gateway. Instances in private subnets can access the internet through a NAT Gateway or NAT device. The following steps configure a public subnet to match what is shown in the diagram in the overview;
Create the private subnets
resource "aws_subnet" "private" {
for_each = local.private_subnets_cidr_az_mapping
vpc_id = aws_vpc.main.id
cidr_block = each.key
availability_zone = each.value
tags = merge(
{ Name = "${var.vpc_name}-private-${each.value}" },
local.resource_tags
)
}
Create a route table for the private subnets
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = merge(
{ Name = "${var.vpc_name}-private" },
local.resource_tags
)
}
Associate all private subnets with the private route table
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private.id
}
Configure internet access for the private subnets
Provision an Elastic IP address for use by the NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"
tags = merge(
{ Name = "${var.vpc_name}-natgw-${local.nat_gateway_az}" },
local.resource_tags
)
depends_on = [aws_internet_gateway.main]
}
Create public NAT gateway
resource "aws_nat_gateway" "public" {
allocation_id = aws_eip.nat.id
subnet_id = local.nat_gateway_subnet["id"]
tags = merge(
{ Name = "${var.vpc_name}-public-nat-${local.nat_gateway_az}" },
local.resource_tags
)
depends_on = [aws_internet_gateway.main]
}
Add a route to NAT gateway in the private subnets' route table
resource "aws_route" "nat" {
route_table_id = aws_route_table.private.id
destination_cidr_block = local.public_internet_cidr
nat_gateway_id = aws_nat_gateway.public.id
}
Visualization of the created VPC resources

Notes
Each of the route tables has two routes although we only explicitly configured one. The extra route is a default route that is automatically added by AWS for communication within the VPC subnets.
For a more resilient setup, create a NAT gateway for each private subnet. Because they come at a cost, we’ve only specified and used one for all private subnets.
We did not explicitly configure network ACLs. In this case, we are relying on the default ACLs configured by AWS — these allow all inbound and outbound traffic.
We did not explicitly configure Security Groups. We are relying on the default Security Groups configured by AWS — these only allow inbound and outbound traffic to/from instances within the VPC.
A shorter way to configure the VPC is to use Terraform AWS modules. This is an ideal option when you fully understand the concepts around VPCs in AWS. Below is the equivalent configuration to setup the VPC above using the Terraform AWS modules
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.12.0" name = var.vpc_name cidr = local.vpc_cir_block azs = local.availability_zones private_subnets = local.private_subnets_cidr_az_mapping public_subnets = local.public_subnets_cidr_az_mapping enable_nat_gateway = true single_nat_gateway = true tags = local.resource_tags }
Resources
Subnets Cheatsheet at freecodecamp.org
Source code at https://github.com/ivanoronee/aws-vpc-overview
tfenv at https://github.com/tfutils/tfenv