This content originally appeared on DEV Community and was authored by Ashraf Minhaj
Introduction
So far you know that you need to attach a public IP address to an ec2 instance to SSH into it or say port forward (access ec2 port from local machine).
But, there are multiple use cases where we might need a private ec2 instance. It can be the application we deployed running on a private subnet (saves cost and best for security too). Or we have a bastion host for mission critical applications but we are worried about that bastion with public IP addresses.
What if I tell you that you don’t actually need to have public IP addresses to access the ec2 instances?
Idea
In general, our private instances are routed to public internet using a NAT gateway. We just need to add a ec2 instance connect endpoint
to our vpc
to access to the VM.
So it’s gonna look like this –
In this blog we will see it with Terraform.
Create ecc2 instance connect endpoint
When creating a VPC, we can define instance connect endpoint and attach the subnet
where the ec2 instance will be deployed, also allow the security groups
of the desired instances you want to connect to.
resource "aws_ec2_instance_connect_endpoint" "instance_connect_endpoint" {
subnet_id = local.instance_connect_subnet
security_group_ids = [module.bastion_sg.id, module.jenkins_sg.id]
}
The full vpc.tf now looks like this –
# select the private subnet
locals {
instance_connect_subnet = aws_subnet.private_subnets[0].id
}
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr
instance_tenancy = "default"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = local.vpc_name
}
}
resource "aws_internet_gateway" "vpc_int_gw" {
vpc_id = aws_vpc.vpc.id
tags = {
Name = local.igw_name
}
}
# public subnets
resource "aws_subnet" "public_subnets" {
count = length(var.public_subnet_cidr_blocks)
vpc_id = aws_vpc.vpc.id
cidr_block = var.public_subnet_cidr_blocks[count.index]
availability_zone = element(var.availability_zones, count.index)
map_public_ip_on_launch = true
tags = {
Name = "${var.component_prefix}-pubsub-${count.index}-${terraform.workspace}"
}
}
# private subnets
resource "aws_subnet" "private_subnets" {
count = length(var.private_subnet_cidr_blocks)
vpc_id = aws_vpc.vpc.id
cidr_block = var.private_subnet_cidr_blocks[count.index]
availability_zone = element(var.availability_zones, count.index)
map_public_ip_on_launch = false
tags = {
Name = "${var.component_prefix}-prvsub-${count.index}-${terraform.workspace}"
}
}
resource "aws_nat_gateway" "nat_gateway" {
depends_on = [aws_internet_gateway.vpc_int_gw]
count = length(var.private_subnet_cidr_blocks) > 0 ? 1 : 0
allocation_id = aws_eip.nat_eip[0].id
subnet_id = aws_subnet.public_subnets[0].id
tags = {
Name = "${var.component_prefix}-nat-${count.index}-${terraform.workspace}"
}
}
resource "aws_route_table" "public_route_table" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0" # all IPs
gateway_id = aws_internet_gateway.vpc_int_gw.id
}
tags = {
Name = local.pub_rt_name
}
}
resource "aws_route_table" "private_route_table" {
depends_on = [aws_nat_gateway.nat_gateway]
count = length(var.private_subnet_cidr_blocks) > 0 ? 1 : 0 # create only if there is more than 0 private subnets
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat_gateway[0].id
}
tags = {
Name = "${var.component_prefix}-prvrt-${count.index}-${terraform.workspace}"
}
}
# routing association between routetable and subnets
resource "aws_route_table_association" "public_subnets_association" {
count = length(var.public_subnet_cidr_blocks)
subnet_id = aws_subnet.public_subnets[count.index].id
route_table_id = aws_route_table.public_route_table.id
}
# Routing association between private route table and private subnets
resource "aws_route_table_association" "private_subnets_association" {
count = length(var.private_subnet_cidr_blocks)
subnet_id = aws_subnet.private_subnets[count.index].id
route_table_id = aws_route_table.private_route_table[0].id
}
resource "aws_ec2_instance_connect_endpoint" "instance_connect_endpoint" {
subnet_id = local.instance_connect_subnet
security_group_ids = [module.bastion_sg.id, module.jenkins_sg.id]
}
Now we can create the ec2 instance, remember to add that specific private instance we defined using local instance_connect_subnet
. So the ec2.tf will be –
module "bastion_server" {
count = var.create_bastion == true ? 1 : 0
source = "./modules/ec2_instance"
name = local.bastion_name
ami_id = var.bastion_ami_id
instance_type = var.bastion_instance_class
subnet_id = local.instance_connect_subnet
security_group_ids = [module.bastion_sg.id]
key_name = data.aws_key_pair.bastion_key.key_name
volume_size = var.bastion_disk_size
volume_name = local.bastion_volume_name
tags = local.default_tags
}
Connect to the private instance
Now the fun part, we will connect to the instances from CLI, don’t worry, if you have something running on the instance, you can also access it via port forwarding. Let me show you –
1. SSH into private ec2 instance –
a. Connect using ec2 instance id
Use your ec2 instance id and the private key file .pem
to connect to it. Remember to change the region as per your region –
INSTANCE_ID=<add your ec2 instance id here>
ssh -i bastion-private-key.pem ubuntu@$INSTANCE_ID \
-o ProxyCommand="aws ec2-instance-connect open-tunnel --instance-id $INSTANCE_ID \
--region ap-southeast-1"
But finding the instance id is a manual process and I hate manual processes, I can remember ec2 names, let’s access using only the name –
a. Connect using ec2 instance name
Or, find the ec2 instance id by name, that’s what I did to make my life easier –
INSTANCE_NAME=my-bastion-instance
INSTANCE_ID=$(aws ec2 describe-instances \
--region ap-southeast-1 \
--filters "Name=tag:Name,Values=$INSTANCE_NAME, running" \
"Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].InstanceId" --output text)
ssh -i bastion-private-key.pem ubuntu@$INSTANCE_ID \
-o ProxyCommand="aws ec2-instance-connect open-tunnel --instance-id $INSTANCE_ID \
--region ap-southeast-1"
2. Access services running on private ec2 instance –
Say you have Jenkins running on port 8080, or nginx on 80. Let’s see how we can open not only 1, but also multiple ports and access using localhost
–
a. Port forward single port
Let’s port forward jenkins port 8080 to our localhost port 8080 –
INSTANCE_NAME=my-bastion-instance
INSTANCE_ID=$(aws ec2 describe-instances \
--region ap-southeast-1 \
--filters "Name=tag:Name,Values=$INSTANCE_NAME, running" \
"Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].InstanceId" --output text)
ssh -i bastion-private-key.pem ubuntu@$INSTANCE_ID \
-o ProxyCommand="aws ec2-instance-connect open-tunnel --instance-id $INSTANCE_ID \
--region ap-southeast-1" \
-L 8080:localhost:8080
Here’s what each part means – -L [local_port]:[remote_host]:[remote_port]
, forget about it and remember local_port:localhost:ec2_instance_port
.
Now from the browser, if we type localhost:8080
, we will be able to access jenkins.
*a. Access multiple ports *
The same way we can tunnel multiple ports just by adding another -L
portion, let’s access port 80
and 8080
–
INSTANCE_NAME=my-bastion-instance
INSTANCE_ID=$(aws ec2 describe-instances \
--region ap-southeast-1 \
--filters "Name=tag:Name,Values=$INSTANCE_NAME, running" \
"Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].InstanceId" --output text)
ssh -i bastion-private-key.pem ubuntu@$INSTANCE_ID \
-o ProxyCommand="aws ec2-instance-connect open-tunnel --instance-id $INSTANCE_ID \
--region ap-southeast-1" \
-L 8080:localhost:8080 \
-L 80:localhost:80 \
Now you can access ec2 instance’s 80 port using localhost:80
. It’s that simple.
Remarks
Keeping your instance in private subnet is a very good choice since public IPv4 addresses cost nowadays. Also, traffic flowing out of the VPC also costs. So if the instances run using private subnets, the data never leaves the VPC itself, and that saves a lot.
Obviously there might be a case when the ec2 instance has to reside on public IP, that’s a different case. Till then, keep your private resources in private subnets and save costs smartly, access securely.
This content originally appeared on DEV Community and was authored by Ashraf Minhaj