In one of my earlier posts, we designed a website hosted on EC2 instances in a Multi-AZ environment behind an application load balancer. The EC2 instances were placed in public subnet therefore reachable from outside and they were accessing internate directly from Internet Gateway. In this post, I'll focus on placing EC2 instances in a private subnet and ensuring they have internet access via a NAT gateway using Terraform.
A NAT (Network Address Translation) gateway plays a crucial role in scenarios where instances in private subnets need access to the internet for updates, patches, or communication with external services, but you want to keep these instances secure from inbound internet traffic. The NAT gateway allows outbound connections to the internet while ensuring that inbound connections initiated from the internet are blocked.
Architecture
Step 1: Creating the VPC and Network Components
First, we need to create the essential network components:
A VPC (Virtual Private Cloud) to contain all our resources.
Public and Private Subnets in two different availability zones to achieve high availability.
An Internet Gateway attached to the VPC to provide internet access to resources in the public subnets.
A NAT Gateway in each public subnet to route traffic from the private subnet to the internet.
Route tables and routes configured to direct traffic appropriately between the subnets, NAT gateway, and internet gateway.
################################################################################ # Get list of available AZs ################################################################################ data "aws_availability_zones" "available_zones" { state = "available" } ################################################################################ # Create the VPC ################################################################################ resource "aws_vpc" "app_vpc" { cidr_block = var.vpc_cidr_block enable_dns_hostnames = var.enable_dns_hostnames tags = merge(var.common_tags, { Name = "${var.naming_prefix}-${var.name}" }) } ################################################################################ # Create the internet gateway ################################################################################ resource "aws_internet_gateway" "igw" { vpc_id = aws_vpc.app_vpc.id tags = merge(var.common_tags, { Name = "${var.naming_prefix}-igw" }) } ################################################################################ # Create the public subnets ################################################################################ resource "aws_subnet" "public_subnets" { vpc_id = aws_vpc.app_vpc.id count = 2 cidr_block = cidrsubnet(var.vpc_cidr_block, 8, count.index) availability_zone = data.aws_availability_zones.available_zones.names[count.index] map_public_ip_on_launch = true # This makes public subnet tags = merge(var.common_tags, { Name = "${var.naming_prefix}-pubsubnet-${count.index + 1}" }) } ################################################################################ # Create the private subnets ################################################################################ resource "aws_subnet" "private_subnets" { vpc_id = aws_vpc.app_vpc.id count = 2 cidr_block = cidrsubnet(var.vpc_cidr_block, 8, 2 + count.index) availability_zone = data.aws_availability_zones.available_zones.names[count.index] map_public_ip_on_launch = false # This makes private subnet tags = merge(var.common_tags, { Name = "${var.naming_prefix}-privsubnet-${count.index + 1}" }) } ################################################################################ # Create the public route table ################################################################################ resource "aws_route_table" "public_route_table" { vpc_id = aws_vpc.app_vpc.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.igw.id } tags = merge(var.common_tags, { Name = "${var.naming_prefix}-pub-rtable" }) } ################################################################################ # Assign the public route table to the public subnet ################################################################################ resource "aws_route_table_association" "public_rt_asso" { count = 2 subnet_id = element(aws_subnet.public_subnets[*].id, count.index) route_table_id = aws_route_table.public_route_table.id } ################################################################################ # Set default route table as private route table ################################################################################ resource "aws_route_table" "private_route_table" { vpc_id = aws_vpc.app_vpc.id count = 2 route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.natgateway[count.index].id } tags = merge(var.common_tags, { Name = "${var.naming_prefix}-priv-rtable" }) } ################################################################################ # Assign the private route table to the private subnet ################################################################################ resource "aws_route_table_association" "private_rt_asso" { count = 2 subnet_id = element(aws_subnet.private_subnets[*].id, count.index) route_table_id = aws_route_table.private_route_table[count.index].id } ################################################################################ # Create EIP for NAT Gateways ################################################################################ resource "aws_eip" "eip_natgw" { count = 2 } ################################################################################ # Create NAT Gateways in each public subnets ################################################################################ resource "aws_nat_gateway" "natgateway" { count = 2 allocation_id = aws_eip.eip_natgw[count.index].id subnet_id = aws_subnet.public_subnets[count.index].id }
Step 2: Creating 2 Linux EC2 Web Server Instances in Separate AZs
Now, let's deploy our EC2 instances in private subnets in separate AZs for high availability. These instances will be our web servers. Since they are in a private subnet, they wonβt have direct access to the internet unless we configure routing through the NAT gateway. Associate the instances with the private subnet route table that routes traffic through the NAT gateway.
EC2 instances will be created only after NAT gateways are functional because they require internet access to install httpd and other pacakges.
################################################################################ # Get latest Amazon Linux 2023 AMI ################################################################################ data "aws_ami" "amazon-linux-2023" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["al2023-ami-2023.*-x86_64"] } } ################################################################################ # Create the security group for EC2 Webservers ################################################################################ resource "aws_security_group" "ec2_security_group" { description = "Allow traffic for EC2 Webservers" vpc_id = var.vpc_id dynamic "ingress" { for_each = var.sg_ingress_ports iterator = sg_ingress content { description = sg_ingress.value["description"] from_port = sg_ingress.value["port"] to_port = sg_ingress.value["port"] protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = merge(var.common_tags, { Name = "${var.naming_prefix}-sg-webserver" }) } ################################################################################ # Create the Linux EC2 Web server ################################################################################ resource "aws_instance" "web" { ami = data.aws_ami.amazon-linux-2023.id instance_type = var.instance_type key_name = var.instance_key vpc_security_group_ids = [aws_security_group.ec2_security_group.id] count = length(var.private_subnets) subnet_id = element(var.private_subnets, count.index) user_data = <<-EOF #!/bin/bash yum update -y yum install -y httpd.x86_64 systemctl start httpd.service systemctl enable httpd.service TOKEN=$(curl --request PUT "http://169.254.169.254/latest/api/token" --header "X-aws-ec2-metadata-token-ttl-seconds: 3600") instanceId=$(curl -s http://169.254.169.254/latest/meta-data/instance-id --header "X-aws-ec2-metadata-token: $TOKEN") instanceAZ=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone --header "X-aws-ec2-metadata-token: $TOKEN") privHostName=$(curl -s http://169.254.169.254/latest/meta-data/local-hostname --header "X-aws-ec2-metadata-token: $TOKEN") privIPv4=$(curl -s http://169.254.169.254/latest/meta-data/local-ipv4 --header "X-aws-ec2-metadata-token: $TOKEN") echo "<font face = "Verdana" size = "5">" > /var/www/html/index.html echo "<center><h1>AWS Linux VM Deployed with Terraform</h1></center>" >> /var/www/html/index.html echo "<center> <b>EC2 Instance Metadata</b> </center>" >> /var/www/html/index.html echo "<center> <b>Instance ID:</b> $instanceId </center>" >> /var/www/html/index.html echo "<center> <b>AWS Availablity Zone:</b> $instanceAZ </center>" >> /var/www/html/index.html echo "<center> <b>Private Hostname:</b> $privHostName </center>" >> /var/www/html/index.html echo "<center> <b>Private IPv4:</b> $privIPv4 </center>" >> /var/www/html/index.html echo "</font>" >> /var/www/html/index.html EOF tags = merge(var.common_tags, { Name = "${var.naming_prefix}-ec2-${count.index + 1}" }) }
Step 3: Creating an Application Load Balancer with HTTP Listener
Create an Application Load Balancer (ALB) to distribute traffic evenly between them. The ALB will be placed in the public subnets and will listen on port 80 (HTTP). The load balancer forwards traffic to your EC2 instances in the private subnets.
################################################################################ # Define the security group for the Load Balancer ################################################################################ resource "aws_security_group" "aws-sg-load-balancer" { description = "Allow incoming connections for load balancer" vpc_id = var.vpc_id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "Allow incoming HTTP connections" } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = merge(var.common_tags, { Name = "${var.naming_prefix}-sg-alb" }) } ################################################################################ # Create application load balancer ################################################################################ resource "aws_lb" "aws-application_load_balancer" { internal = false load_balancer_type = "application" security_groups = [aws_security_group.aws-sg-load-balancer.id] //subnets = [var.public_subnets[0],var.public_subnets[1] ,var.public_subnets[2],var.public_subnets[3]] subnets = tolist(var.public_subnets) enable_deletion_protection = false tags = merge(var.common_tags, { Name = "${var.naming_prefix}-alb" }) } ################################################################################ # create target group for ALB ################################################################################ resource "aws_lb_target_group" "alb_target_group" { target_type = "instance" port = 80 protocol = "HTTP" vpc_id = var.vpc_id health_check { enabled = true interval = 300 path = "/" timeout = 60 matcher = 200 healthy_threshold = 5 unhealthy_threshold = 5 } lifecycle { create_before_destroy = true } tags = merge(var.common_tags, { Name = "${var.naming_prefix}-alb-tg" }) } ################################################################################ # create a listener on port 80 with redirect action ################################################################################ resource "aws_lb_listener" "alb_http_listener" { load_balancer_arn = aws_lb.aws-application_load_balancer.id port = 80 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.alb_target_group.id } } ################################################################################ # Target Group Attachment with Instance ################################################################################ resource "aws_alb_target_group_attachment" "tgattachment" { count = length(var.instance_ids) target_group_arn = aws_lb_target_group.alb_target_group.arn target_id = element(var.instance_ids, count.index) }
Steps to Run Terraform
Follow these steps to execute the Terraform configuration:
terraform init terraform plan terraform apply -auto-approve
Upon successful completion, Terraform will provide relevant outputs.
terraform
Apply complete! Resources: 26 added, 0 changed, 0 destroyed.
Testing
Public Route Table with route to Internet Gateway and associated public subnets.
Private Route Tables with route to NAT Gateway and associated private subnets
NAT Gateways with associated EIPs in each public subnet
EC2 instances in private subnets (without Public IP)
WebServers
Cleanup
Remember to stop AWS components to avoid large bills.
terraform destroy -auto-approve
Conclusion
By placing your EC2 instances in a private subnet and enabling internet access via a NAT gateway, you've added an additional layer of security to your infrastructure. The instances remain isolated from direct internet exposure, yet they can still communicate with external services when needed.
Resources
GitHub Repo: https://github.com/chinmayto/terraform-aws-private-EC2-website-alb-nat
AWS Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-example-private-subnets-nat.html
Top comments (0)