Access Private ec2 instances without Public IP using Instance Connect Endpoint



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 –

Architecture Diagram

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