
At a high level, there are two types of subnets, public and private.
- A public subnet has access to the internet
- A private subnet does NOT have access to the internet
At a high level, there are two ways to access the internet.
- Using an Internet Gateway - An Internet Gateway allows EC2 instances that have a public IP address to access the internet.
- Using a NAT Gateway - A Network Address Translation (NAT) Gateway translates a private IP address to a public IP address which allows EC2 instances that do not have a public IP address to access the internet by translating the EC2 instances private IP address to a public IP address
- NAT Gateway - AWS NAT Gateway Service
- NAT Instance (this article) - a dedicated EC2 Instance serves as the NAT Gateway
At a high level, it can look like this.
It is also noteworthy that Virtual Private Cloud (VPC) Endpoints can be used for communication between different AWS Services in one of your Virtual Private Clouds (VPC). The communication only occurs within Amazon, within your VPC - it never goes outside the Amazon network, never goes outside of your VPC, never gets onto the Internet. In this way, this is good from a privacy and security perspective. For example, a Virtual Private Cloud (VPC) Endpoint can be created for services such as
- API Gateway Execute API - com.amazonaws.us-east-1.execute-api
- S3 Bucket - com.amazonaws.us-east-1.s3
- Simple Notification Service (SNS) - com.amazonaws.us-east-1.sns
- Simple Queue Service (SQS) - com.amazonaws.us-east-1.sqs
- et cetera
Virtual Private Cloud (VPC) CIDRs
In this walkthrough, we are going to have two subnets in the same Virtual Private Cloud (VPC), one with an Internet Gateway, the other with a public NAT Gateway, each in their own CIDRs.
- public subnet - the 172.31.0.0/16 subnet will have an EC2 instance with a public IP address and the EC2 instance will be able to access the Internet via an Internet Gateway
- private subnet - the 172.0.0.0/24 subnet will have an EC2 instance that does not have a public IP address and the EC2 instance will be able to access the Internet via the NAT Instance
The Virtual Private Cloud is going to need to be configured with both the 172.31.0.0/16 and 172.0.0.0/24 subnet. I go with this approach for clear separation between the public subnet vs. the private subnet.
INTERNET GATEWAY SUBNET
Let's start with an EC2 instance that has a public IP address and is using a Subnet with a Route Table that has an Internet Gateway Route, just for proof of concept to ensure the EC2 instance is able to connect to the Internet.
The aws ec2 create-subnet command can be used to create a subnet. Let's create a subnet in 172.31.0.0/16 CIDR.
aws ec2 create-subnet --vpc-id vpc-0a9d4cb29e2748444 --cidr-block 172.31.0.0/16 --availability-zone us-east-1a
By default, the subnet will have target local which allows communication between systems in the Virtual Private Cloud (VPC) and will NOT have a route to one of your Internet Gateways or Network Address Translation (NAT) Gateways. At this point, this is a private subnet that will not allow connections to the internet. Let's update the Route Table in the Subnet to have destination 0.0.0.0/0 and target one of your Internet Gateways in your Virtual Private Cloud (VPC). Now the Subnet is a public subnet.
Let's create an EC2 instance using the aws ec2 run-instances command and attach the EC2 instance to the subnet.
aws ec2 run-instances \
--image-id ami-0cf10cdf9fcd62d37 \
--count 1 \
--key-name default \
--instance-type t2.micro \
--subnet-id <subnet ID public subnet> \
--associate-public-ip-address \
--security-group-ids sg-11122233344455566677 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=public-instance}]'
SSH onto the EC2 instance and the ip address command should show that the instance has an IP address in the subnet (in this example, the EC2 instance has IP address 172.31.45.86 which is in the 172.31.0.0/16 subnet).
~]$ ip address
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000
link/ether 0e:d1:fa:42:5f:37 brd ff:ff:ff:ff:ff:ff
altname enp0s5
altname eni-0d39a7ba2fef60763
altname device-number-0
inet 172.31.45.86/20 metric 512 brd 172.31.47.255 scope global dynamic ens5
valid_lft 2645sec preferred_lft 2645sec
inet6 fe80::cd1:faff:fe42:5f37/64 scope link
valid_lft forever preferred_lft forever
The route --numeric command should contain a route that has destination 0.0.0.0. This is the Internet Gateway route.
~]$ route --numeric
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 172.31.32.1 0.0.0.0 UG 512 0 0 ens5 <- internet gateway (flag UG stands for Up Gateway, the Gateway Route is Up)
172.31.0.2 172.31.32.1 255.255.255.255 UGH 512 0 0 ens5 <- local (flag UGH stands for Up Gateway Host, the Gateway Host Route is Up)
172.31.32.0 0.0.0.0 255.255.240.0 U 512 0 0 ens5 <- ? (flag U stands for Up, the route is Up)
You should be able to ping remote URLs, such as www.example.com. Hooray - so far, so good!
[ec2-user@ip-172-31-45-86 ~]$ ping -c4 www.example.com
PING www.example.com (93.184.216.34) 56(84) bytes of data.
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=4 ttl=54 time=23.4 ms
--- www.example.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
rtt min/avg/max/mdev = 23.444/23.453/23.472/0.187 ms
PRIVATE SUBNET - (no internet access)
Let's create an EC2 instance that will be in a Subnet with a Route Table that does not have an Internet Gateway or a NAT Gateway. We are doing this for proof of concept, to prove that the EC2 instance in the private subnet has no Internet access. Then, we'll update EC2 instance to use the NAT Instance, like this.
Let's use the the aws ec2 create-subnet command to create a subnet. The default VPC that get's created when you create your AWS account has CIDR 172.31.0.0/16 and the subnets in the default VPC contain a Route Table that includes a Route for the Internet Gateway, meaning EC2 instances that are associated with a subnet in the default VPC are in the public subnet and have Internet access. This is why we need to create a subnet with a different CIDR. In this example, I'm going with CIDR 172.0.0.0/24.
aws ec2 create-subnet --vpc-id vpc-0a9d4cb29e2748444 --cidr-block 172.0.0.0/24 --availability-zone us-east-1a
Let's use the aws ec2 create-route-table command to create a Route Table in your Virtual Private Cloud (VPC).
aws ec2 create-route-table --vpc-id vpc-0a9d4cb29e2748444
By default, the new Route Table should have target local which allows communication between systems in the Virtual Private Cloud (VPC) and does NOT have a route to one of your Internet Gateways or Network Address Translation (NAT) Gateways. This Route Table is private (no internet access).
Let's use the aws ec2 associate-route-table command to update the Route Table to be associated with Subnet you just created. The subnet is private with no internet access.
aws ec2 associate-route-table --route-table-id rtb-0e96e9343c4086863 --subnet-id subnet-0f015da3a1e164304
Now let's use the aws ec2 create-security-group command to create a Security Group that will be used by the EC2 Instance.
aws ec2 create-security-group --group-name my-security-group --description "my Security Group"
And then use the aws ec2 authorize-security-group-ingress command to update the Security Group to allow SSH connection from one of your public subnets, such as 172.31.0.0/16 (the default VPC that is created when you create your AWS account) so that we can SSH onto this EC2 instance for proof of concept.
aws ec2 authorize-security-group-ingress --group-id sg-0778124087b3d14d4 --protocol tcp --port 22 --cidr 0.0.0.0/0
Let's use the aws ec2 run-instances command to create an EC2 instance ensuring the EC2 instance is associated with the private subnet and security group. Notice that I used the --no-associate-public-ip-address so that the EC2 instance will only have a private IP address.
aws ec2 run-instances \
--image-id ami-0cf10cdf9fcd62d37 \
--count 1 \
--key-name default \
--instance-type t2.micro \
--subnet-id <subnet ID private subnet> \
--security-group-ids sg-11122233344455566677 \
--no-associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=private-instance}]'
SSH onto the EC2 instance in your public subnet and you should still be able to ping remote URLs, such as www.example.com. Hooray - still all good!
[ec2-user@ip-172-31-45-86 ~] ping -c4 www.example.com
PING www.example.com (93.184.216.34) 56(84) bytes of data.
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=4 ttl=54 time=23.4 ms
--- www.example.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
rtt min/avg/max/mdev = 23.444/23.453/23.472/0.187 ms
From the EC2 instance in the public subnet SSH onto the EC2 instance in the private subnet.
Let's say you have a file named aws.ppk that is being used to connect to your EC2 instance in your public subnet using PuTTY. Assuming both EC2 instances are Linux system, you will need to convert the aws.ppk file to an OpenSSH private key file such as aws.key. This can be done using PuTTYgen. Check out my article PuTTYgen - Extract public certificate and private key from PPK file.
[ec2-user@ip-172-31-45-86 ~] ssh -i /home/ec2-user/.ssh/aws.key ec2-user@10.0.0.8
, #_
~\_ ####_ Amazon Linux 2
~~ \_#####\
~~ \###| AL2 End of Life is 2025-06-30.
~~ \#/ ___
~~ V~' '->
~~~ / A newer version of Amazon Linux is available!
~~._. _/
_/ _/ Amazon Linux 2023, GA and supported until 2028-03-15.
_/m/' https://aws.amazon.com/linux/amazon-linux-2023/
[ec2-user@ip-172-0-0-5 ~]$
On the EC2 instance in the private subnet, you should NOT be able to ping www.example.com since the EC2 instance in the private subnet only has a private IP address and is not using a NAT Gateway thus there is no way to forward traffic onto the Internet, and www.example.com is outside of the private subnet.
[ec2-user@ip-172-0-0-5 ~]$ ping -c4 www.example.com
PING www.example.com (93.184.216.34) 56(84) bytes of data.
--- www.example.com ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3072ms
By the way, even if you were to update the Route Table to have an Internet Gateway, you still would not be able to connect to systems outside of your VPC because an Internet Gateway does not translate a private IP address to a public IP address (that's what a NAT Gateway does!) thus there would still be no way to forward traffic onto the Internet.
NAT INSTANCE
Now let's use the aws ec2 create-security-group command to create a Security Group that will be used by the NAT Instance.
aws ec2 create-security-group --group-name nat-instance-security-group --description "NAT Instance Security Group"
And then use the aws ec2 authorize-security-group-ingress command to update the Security Group will the following inbound rules.
- allow HTTP port 80 from private subnet CIDR
- allow HTTPS port 443 from private subnet CIDR
- allow SSH port 22 from the CIDR of one of your public subnets in your VPC - This isn't required for the instance to work as a NAT Gateway and is only used so you can SSH onto the NAT Instance
aws ec2 authorize-security-group-ingress --group-id sg-0778124087b3d14d4 --protocol tcp --port 80 --cidr 172.0.0.0/24
aws ec2 authorize-security-group-ingress --group-id sg-0778124087b3d14d4 --protocol tcp --port 443 --cidr 172.0.0.0/24
aws ec2 authorize-security-group-ingress --group-id sg-0778124087b3d14d4 --protocol tcp --port 22 --cidr 172.31.0.0/16
And then use the aws ec2 authorize-security-group-egress command to update the Security Group with the following outbound rules to allow HTTP port 80 and HTTPS port 443.
- allow HTTP port 80 from private subnet CIDR
- allow HTTPS port 443 from private subnet CIDR
aws ec2 authorize-security-group-egress --group-id sg-0778124087b3d14d4 --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-egress --group-id sg-0778124087b3d14d4 --protocol tcp --port 443 --cidr 0.0.0.0/0
Let's create a temporary EC2 instance using the aws ec2 run-instances command that will be used to create an Amazon Machine Image for the NAT Instance.
aws ec2 run-instances
--image-id ami-0b0dcb5067f052a63 \
--count 1 \
--key-name default \
--security-group-ids sg-0778124087b3d14d4 \
--subnet-id subnet-03f11123480f6abcd \
--instance-type t2.micro \
--associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=tmp-instance}]'
Now SSH onto the tmp instance and install, start and enable iptables firewall.
sudo yum install iptables-services -y
sudo systemctl enable iptables
sudo systemctl start iptables
Use your preferred editor such as vi or vim or nano to create the /etc/sysctl.d/custom-ip-forwarding.conf file and append the following to the custom-ip-forwarding.conf file. This is used to ensure the IP forwarded is enabled if the NAT Instance is rebooted.
net.ipv4.ip_forward=1
And run this command to apply the IP forwarding configuration.
sudo sysctl -p /etc/sysctl.d/custom-ip-forwarding.conf
Use netstat to return the network interfaces.
netstat -i
Something like this may be returned. In this example, eth0 is the primary interface.
Iface MTU RX-OK RX-ERR RX-DRP RX-OVR TX-OK TX-ERR TX-DRP TX-OVR Flg
docker0 1500 0 0 0 0 0 0 0 0 BMU
eth0 9001 7276052 0 0 0 5364991 0 0 0 BMRU
lo 65536 538857 0 0 0 538857 0 0 0 LRU
Or perhaps this. In this example, enX0 is the primary interface.
Iface MTU RX-OK RX-ERR RX-DRP RX-OVR TX-OK TX-ERR TX-DRP TX-OVR Flg
enX0 9001 1076 0 0 0 1247 0 0 0 BMRU
lo 65536 24 0 0 0 24 0 0 0 LRU
Or perhaps this. In this example, ens5 is the primary interface.
Iface MTU RX-OK RX-ERR RX-DRP RX-OVR TX-OK TX-ERR TX-DRP TX-OVR Flg
ens5 9001 14036 0 0 0 2116 0 0 0 BMRU
lo 65536 12 0 0 0 12 0 0 0 LRU
And then issue these commands to configure iptables with NAT forwarding, using the primary interface (eth0 or enX0 or ens5).
sudo /sbin/iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
sudo /sbin/iptables -F FORWARD
sudo service iptables save
Then on any other system other than the tmp NAT instance, use the aws ec2 create-instance command to create an Amazon Machine Image (AMI) using the ID of the tmp instance
aws ec2 create-image --instance-id i-0295b7e0a92f1c0e1 --name "nat-instance"
You can now use the aws ec2 stop-instances command to stop the tmp instance.
aws ec2 stop-instances --instance-ids i-09112345cf1abcdd2
And then use the aws ec2 terminate-instances command to terminate the tmp instance.
aws ec2 terminate-instances --instance-id i-09112345cf1abcdd2
Now let's create the NAT Instance using the using the aws ec2 run-instances command with the Amazon Machine Image (AMI) that you just created and the NAT Instance Security Group. You must also include --associate-public-ip-address so that the NAT instance has a public IP address, so that the NAT instance can translate private IP addresses to the public IP address. You will also want the NAT Instance to be in a public subnet in your VPC since the NAT Instance will be forwarding traffic onto the Internet.
aws ec2 run-instances
--image-id ami-0b0dcb5067f052a63 \
--count 1 \
--key-name default \
--security-group-ids sg-0682eb7602ffe6b50 \
--subnet-id subnet-03f11123480f6abcd \
--instance-type t2.micro \
--associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=nat-instance}]'
Now let's use the aws ec2 modify-instance-attributes command to disable source/destination checks since the NAT instance will send/receive traffic that was not created on the NAT instance (source) and is not destined for the NAT instance (destination). The NAT instance is a bastion host.
aws ec2 modify-instance-attribute --instance-id i-1234567890abcdef0 --source-dest-check "{\"Value\": false}"
All right, our NAT Instance should be good to go.
Configure EC2 Instance to use NAT Instance
Now, you don't update an EC2 instance to use the NAT Instance. Instead, you update the Route Table being used by the EC2 Instance to include the NAT Instance.
For example, let's say Route Table rtb-055d28b0a8be465fa is being used by your EC2 instance in the private subnet. At this point, the Route Table probably just has a single local route, meaning the EC2 instance can only communicate with other EC2 instances in the same CIDR.
~]$ aws ec2 describe-route-tables --query 'RouteTables[?RouteTableId==`rtb-055d28b0a8be465fa`]'
[
{
"Associations": [
{
"Main": false,
"RouteTableAssociationId": "rtbassoc-0f85de0f305807208",
"RouteTableId": "rtb-055d28b0a8be465fa",
"SubnetId": "subnet-065d865b99f5d9147",
"AssociationState": {
"State": "associated"
}
}
],
"PropagatingVgws": [],
"RouteTableId": "rtb-055d28b0a8be465fa",
"Routes": [
{
"DestinationCidrBlock": "172.0.0.0/24",
"GatewayId": "local",
"Origin": "CreateRouteTable",
"State": "active"
}
],
"Tags": [
{
"Key": "Name",
"Value": "private"
}
],
"VpcId": "vpc-014d2fcfa335d3c01",
"OwnerId": "123456789012"
}
]
Or, in the AWS console, the Route Table should look something like this.
Let's update the Route Table to include the NAT Instance with a destination of 0.0.0.0/0 since the NAT Instance will be forwarding requests onto the Internet.
On the EC2 instance in the subnet that has the Network Address Translation (NAT) Gateway, you should be able to ping www.example.com, which shows the EC2 instance Internet access via the NAT Instance. Awesome!
[ec2-user@ip-10-0-0-8 ~]$ ping -c4 www.example.com
PING www.example.com (93.184.216.34) 56(84) bytes of data.
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=54 time=23.4 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=4 ttl=54 time=23.4 ms
--- www.example.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
rtt min/avg/max/mdev = 23.444/23.453/23.472/0.187 ms
Did you find this article helpful?
If so, consider buying me a coffee over at