Creating an EC2 NAT Instance in AWS
Private subnets in an AWS VPC must be associated with a NAT gateway or a NAT instance to be able to access the internet. A NAT gateway will always be the best choice for a production environment - they are managed, highly scalable, and highly available. But NAT instances are still a good option for hobbyists who want to minimize their monthly cloud bill.
A NAT gateway costs $0.045/hr after it is provisioned, which adds up to about $32 per month. That's nothing in terms of a large organization's cloud budget - but for individuals who want to run their personal projects in AWS, it might be a lot. Personally, my own monthly cloud bill rarely exceeds thirty dollars, so doubling that just to have a NAT gateway for my VPC isn't really worth it to me when there is a cheaper alternative. So far I have been able to get away with using a t3.nano EC2 as a NAT and it has been working just fine for my purposes.
Creating a NAT instance with Terraform
I decided to create my own NAT instance using the latest Ubuntu 20 AMI as a starting point. All it took was a security group, a network interface, and an EC2 instance with a userdata script that configures it as a NAT using iptables.
The code snippets below are from my Terraform NAT instance repo on github. Go check it out if you're looking for a complete and working example.
Security group and network interface
The security group of a NAT instance needs to allow ingress only from the VPC's CIDR block. It should not allow connections from outside the VPC. A NAT instance needs to be in a public subnet, so this is important. It should allow outbound traffic to any destination.
resource "aws_security_group" "security_group" { name = "nat_instance_security_group" description = "Security group for NAT instance" vpc_id = var.vpc_id ingress = [ { description = "Ingress CIDR" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = [var.vpc_cidr] ipv6_cidr_blocks = [] prefix_list_ids = [] security_groups = [] self = true } ] egress = [ { description = "Default egress" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] ipv6_cidr_blocks = [] prefix_list_ids = [] security_groups = [] self = true } ] }
For my NAT instance, I also explicitly created a network interface for the NAT instance as a separate resource. The route tables of the private subnets in the VPC will have a route to the NAT instance's private IP address for internet-bound traffic. If you create a network interface separately and attach it to the instance, it won't be necessary to update these routes if the instance is replaced since its private IP will remain the same.
resource "aws_network_interface" "network_interface" { subnet_id = var.subnet_id source_dest_check = false security_groups = [aws_security_group.security_group.id] tags = { Name = "nat_instance_network_interface" } }
Also, note that the network interface has the source_dest_check attribute set to false. The NAT instance will not work if this attribute is not set to false.
EC2 instance
The configuration for the NAT instance itself looks like this. The userdata script configures it to forward traffic from the VPC's CIDR to the internet.
resource "aws_instance" "nat_instance" { ami = var.ami_id instance_type = "t3.nano" count = 1 key_name = var.ssh_key_name network_interface { network_interface_id = aws_network_interface.network_interface.id device_index = 0 } user_data = <<EOT #!/bin/bash sudo /usr/bin/apt update sudo /usr/bin/apt install ifupdown /bin/echo '#!/bin/bash if [[ $(sudo /usr/sbin/iptables -t nat -L) != *"MASQUERADE"* ]]; then /bin/echo 1 > /proc/sys/net/ipv4/ip_forward /usr/sbin/iptables -t nat -A POSTROUTING -s ${var.vpc_cidr} -j MASQUERADE fi ' | sudo /usr/bin/tee /etc/network/if-pre-up.d/nat-setup sudo chmod +x /etc/network/if-pre-up.d/nat-setup sudo /etc/network/if-pre-up.d/nat-setup EOT tags = { Name = "nat_instance" Role = "nat" } }
Configuring routes for private subnets
After the NAT instance has been provisioned, a route to its network interface needs to be added to the private subnets' route table(s). After this is done, the private subnets will have internet access through the NAT instance.
resource "aws_route_table" "private_route_table" { vpc_id = aws_vpc.vpc.id route = [ { cidr_block = "0.0.0.0/0", network_interface_id = aws_network_interface.network_interface.id } ] }