AWS · Networking

VPC +
Bastion Host

Designed and deployed a custom AWS Virtual Private Cloud with public and private subnets across two availability zones, an Internet Gateway, route tables, and a hardened bastion host as the sole SSH entry point into the private network.

VPC EC2 Subnets IGW Route Tables Security Groups NACLs SSH

Architecture Diagram

AWS REGION: us-east-1 VPC — 10.0.0.0/16 Public Subnet 10.0.1.0/24 · us-east-1a Private Subnet 10.0.2.0/24 · us-east-1b Internet Gateway igw-xxxxxxxx Route Table 0.0.0.0/0 → IGW Bastion Host EC2 t2.micro Public IP · Amazon Linux 2 SG: allow 22 from my IP Private EC2 t2.micro · No public IP SG: allow 22 from bastion SG only Route Table local only — no IGW Internet Your local machine SSH tunnel port 22 NACL boundary

Traffic from the public internet enters via the Internet Gateway, hits the public subnet's route table, and reaches the bastion host. The private subnet has no route to the IGW — the only way in is SSH through the bastion.

What Was Built

01

Create the VPC

Created a custom VPC with CIDR block 10.0.0.0/16 — giving 65,536 IP addresses to carve up across subnets. DNS hostnames and DNS resolution were both enabled so EC2 instances receive resolvable hostnames automatically.

$ aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=my-vpc}]'
# VpcId: vpc-0a1b2c3d4e5f
02

Create public and private subnets

Created two subnets in separate availability zones for realistic multi-AZ awareness. The public subnet (10.0.1.0/24) hosts the bastion. The private subnet (10.0.2.0/24) hosts any resources that must not be directly internet-accessible.

$ aws ec2 create-subnet --vpc-id vpc-0a1b2c3d4e5f \
  --cidr-block 10.0.1.0/24 --availability-zone us-east-1a
# public subnet → subnet-pub-xxxx

$ aws ec2 create-subnet --vpc-id vpc-0a1b2c3d4e5f \
  --cidr-block 10.0.2.0/24 --availability-zone us-east-1b
# private subnet → subnet-priv-xxxx
03

Attach an Internet Gateway

Created an Internet Gateway and attached it to the VPC. Without this, no traffic can leave or enter from the public internet — even a public subnet's EC2 instance would be unreachable.

$ aws ec2 create-internet-gateway
# igw-id: igw-0f1e2d3c4b

$ aws ec2 attach-internet-gateway \
  --internet-gateway-id igw-0f1e2d3c4b \
  --vpc-id vpc-0a1b2c3d4e5f
04

Configure route tables

Created a custom route table for the public subnet with a 0.0.0.0/0 → IGW route, then associated it with the public subnet. The private subnet uses the VPC's default route table which only has the local route — no path to the internet.

$ aws ec2 create-route-table --vpc-id vpc-0a1b2c3d4e5f
$ aws ec2 create-route --route-table-id rtb-xxxx \
  --destination-cidr-block 0.0.0.0/0 \
  --gateway-id igw-0f1e2d3c4b
$ aws ec2 associate-route-table \
  --subnet-id subnet-pub-xxxx --route-table-id rtb-xxxx
05

Configure Security Groups

Created two security groups. The bastion SG allows inbound SSH (port 22) only from my specific IP. The private instance SG allows inbound SSH only from the bastion's security group ID — not a CIDR, an SG reference — so even if the bastion's IP changes, the rule remains correct.

# Bastion SG
$ aws ec2 authorize-security-group-ingress \
  --group-id sg-bastion --protocol tcp --port 22 \
  --cidr <MY_IP>/32

# Private SG — source is the bastion SG, not a CIDR
$ aws ec2 authorize-security-group-ingress \
  --group-id sg-private --protocol tcp --port 22 \
  --source-group sg-bastion
06

Launch EC2 instances

Launched the bastion host in the public subnet with a public IP assigned, Amazon Linux 2, and the bastion SG. Launched the private instance in the private subnet with no public IP — only the bastion can reach it.

# Bastion — public subnet, gets public IP
$ aws ec2 run-instances --image-id ami-xxxxxxxx \
  --instance-type t2.micro --subnet-id subnet-pub-xxxx \
  --associate-public-ip-address \
  --security-group-ids sg-bastion --key-name my-key

# Private instance — no public IP
$ aws ec2 run-instances --image-id ami-xxxxxxxx \
  --instance-type t2.micro --subnet-id subnet-priv-xxxx \
  --no-associate-public-ip-address \
  --security-group-ids sg-private --key-name my-key
07

SSH through the bastion

Used SSH agent forwarding to hop from the local machine into the bastion, then from the bastion into the private instance — without copying the private key onto the bastion. This is the correct, secure pattern.

# Add key to agent, then SSH with forwarding
$ ssh-add ~/.ssh/my-key.pem
$ ssh -A -J ec2-user@<BASTION_IP> ec2-user@10.0.2.x
# -A forwards the agent; -J is ProxyJump
# Connected to private EC2 — no public IP, no direct route

What I Learned & SAA-C03 Concepts

VPC Design & CIDR Planning

A VPC is your own isolated slice of the AWS network. Choosing a CIDR like /16 at the VPC level gives you flexibility to carve out many subnets without running out of IPs — important to get right before deploying anything.

SAA-C03: Design Secure Architectures

Internet Gateway vs NAT Gateway

An IGW enables bi-directional internet access for public subnets. A NAT Gateway allows private subnet instances to initiate outbound internet traffic (e.g. for updates) without exposing them to inbound connections. This project uses an IGW; adding a NAT Gateway is the next step.

SAA-C03: Design Resilient Architectures

Route Tables

Every subnet is associated with exactly one route table. The public subnet's table routes 0.0.0.0/0 to the IGW. The private subnet's table has only a local route — traffic stays inside the VPC. This is what makes a subnet "public" or "private."

SAA-C03: Design Secure Architectures

Security Groups vs NACLs

Security Groups are stateful, instance-level firewalls — return traffic is automatically allowed. NACLs are stateless, subnet-level — you must explicitly allow both inbound and outbound rules. Security Groups are the primary defense; NACLs add a second layer at the subnet boundary.

SAA-C03: Design Secure Architectures

Bastion Host Pattern

A bastion (jump host) is the single hardened entry point into a private network. It sits in the public subnet with a restricted SG (SSH from known IPs only). Private instances only allow SSH from the bastion's SG — never from the open internet. This dramatically shrinks your attack surface.

SAA-C03: Design Secure Architectures

Availability Zones

Subnets live in a single AZ. Spreading resources across AZs (e.g. public in us-east-1a, private in us-east-1b) is the foundation of high-availability AWS architectures. If one AZ goes down, workloads in the other AZ continue running.

SAA-C03: Design Resilient Architectures

SSH Agent Forwarding

Instead of copying the private key to the bastion (a security anti-pattern), SSH agent forwarding (ssh -A) lets the local SSH agent authenticate on behalf of the client when the bastion connects to private instances. The key never leaves the local machine.

Operational Security

Principle of Least Privilege

Each security group rule is as narrow as possible: the bastion SG allows port 22 only from my IP (/32), and the private SG references the bastion SG as the source rather than a CIDR. No resource has more access than it needs.

SAA-C03: Design Secure Architectures

// personal takeaways

Route tables, not the IGW, are what define public vs private. Attaching an IGW to a VPC doesn't automatically make subnets public — you have to explicitly route 0.0.0.0/0 through it in that subnet's route table. Spent time debugging this before it clicked.

Security Group chaining is cleaner than CIDR rules for internal traffic. Referencing one SG as the source in another SG's rule is more robust and idiomatic than using IP ranges — it survives instance replacements and IP changes.

NACLs require explicit outbound rules. My first attempt blocked all traffic because I forgot NACLs are stateless — return traffic from an inbound connection is not automatically allowed. Had to add outbound ephemeral port rules (1024–65535) explicitly.

This is the foundation for almost every real AWS architecture. Load balancers, RDS, ECS clusters — they all sit inside a VPC using this exact public/private split. Understanding this at the network layer makes every higher-level service make more sense.

← back to projects