Phase 3 · CLOUD
VPC 2 — NAT, public vs private, bastion patterns
By the end of today
- Explain public vs private subnets and where a NAT gateway sits
- Launch a private instance and reach it through a bastion host
- Delete the NAT gateway promptly so it stops billing you
Public vs private subnets, NAT, and the bastion pattern
Day 50 built the VPC — a private network carved into subnets, wired with route tables, and given a door to the internet through an internet gateway. Today answers the question every real architecture has to: which machines get a public address, which stay hidden, and how do the hidden ones still reach out?
A subnet is “public” or “private” purely because of its route table. A public subnet has a route sending 0.0.0.0/0 to the internet gateway (IGW), so an instance there with a public IP can be reached from the internet and reach back out. A private subnet has no route to the IGW — so nothing outside the VPC can start a connection to it, and it can’t reach the internet on its own. That is exactly where a database or an internal app server belongs: unreachable from the internet by design.
But private instances still need outbound internet — to run apt update, pull a container image, or call an API. Letting the internet in would defeat the point. That one-way need is the NAT gateway’s whole job.
A NAT gateway lets private instances reach out without letting the internet reach in. It lives in a public subnet, and the private subnet’s route table sends 0.0.0.0/0 to the NAT gateway instead of the IGW. Outbound traffic is translated to the NAT’s public address and the replies flow back — but nothing outside can initiate a connection inward. One-way glass.
A NAT gateway is not free — and there’s no free tier for it. AWS bills it per hour it exists plus per gigabyte processed (~$0.045/hour in
us-east-1, about $32/month even idle). It is the single most common “why isn’t my VPC bill zero?” surprise. Create it only for the lab, and delete it the moment you’re done.
The bastion — or jump host — is how you administer the private machines. You can’t SSH straight to a private instance; there’s no route from the internet to it. So you put one small, hardened instance in a public subnet — the bastion — SSH to it, then hop from there to the private instance over the VPC’s internal network. The bastion is the only box exposed to the internet, so it’s the only one you have to defend: lock its security group to your IP on port 22, and let the private instances accept SSH only from the bastion’s security group.
Real world: A private subnet is a bank vault with no street door. The bastion is the single guarded reception desk out front — every visitor is checked there, and only people who clear reception reach the back rooms. The NAT gateway is the mailroom: it sends letters out to the world on the vault’s behalf, but nobody outside can post themselves in through it.
Netflix and virtually every serious AWS shop run this shape: app and database tiers sealed in private subnets, with only a thin public edge — load balancers, bastions, or increasingly AWS Systems Manager Session Manager, which replaces the bastion with an API-driven shell and no open port at all. Modern teams often skip the bastion for SSM, but you must understand the pattern SSM replaces.
Today you build the full shape by hand — public and private subnets, a NAT gateway (billing the whole time, so move briskly), a bastion, and a private instance you SSH into through the bastion — then tear every piece of it down.
Hands-On Lab
Budget about 30 minutes, and do it in one sitting — the NAT gateway bills from the second you create it. Drive this from the AWS CLI v2 as your Day 47 IAM user (not root) in us-east-1. Every ID, IP and ARN below is an example — yours will differ.
What this costs: Not quite ₹0 — the NAT gateway is the main billable piece and has no free tier. AWS charges it about $0.045/hour (~₹4/hr) plus ~$0.045/GB processed, from the second it’s created until you delete it — roughly ₹5 for a 30-minute lab, but ~₹2,700/month if you forget it. On top of that, every public IPv4 address in use now costs $0.005/hour each — that’s the NAT gateway’s Elastic IP and the bastion’s public IP, a couple of cents/hour more. The two
t2.microinstances (bastion + private) are free-tier eligible, and the VPC, subnets, route tables and security groups are free while in use. So the NAT gateway dominates the cost: finish in one go and run the teardown (steps 10–12) the moment you’re done — deleting the NAT gateway is the step that stops the meter.
# 1. Confirm you're your Day 47 IAM user (not root), set the region, create a VPC.
aws sts get-caller-identity --query 'Arn' --output text
export AWS_DEFAULT_REGION=us-east-1
VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--query 'Vpc.VpcId' --output text)
echo "$VPC"
# Output (your ARN and VPC ID will differ):
# arn:aws:iam::123456789012:user/devops-you
# vpc-0abc123def4567890
# 2. Create an internet gateway and attach it to the VPC — the VPC's door to the internet.
IGW=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW" --vpc-id "$VPC"
echo "$IGW"
# Output (yours will differ):
# igw-0abc123def4567890
# 3. Carve one public and one private subnet from the VPC's range.
PUB=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.1.0/24 \
--query 'Subnet.SubnetId' --output text)
PRIV=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.2.0/24 \
--query 'Subnet.SubnetId' --output text)
# Auto-assign public IPs in the public subnet so the bastion gets one.
aws ec2 modify-subnet-attribute --subnet-id "$PUB" --map-public-ip-on-launch
echo "$PUB $PRIV"
# Output (yours will differ):
# subnet-0pub111 subnet-0priv222
# 4. Public route table: send 0.0.0.0/0 to the IGW, then associate the public subnet.
RTPUB=$(aws ec2 create-route-table --vpc-id "$VPC" \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id "$RTPUB" \
--destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW"
aws ec2 associate-route-table --route-table-id "$RTPUB" --subnet-id "$PUB"
echo "public route table: $RTPUB"
# Output (yours will differ):
# public route table: rtb-0pub333
# 5. Allocate an Elastic IP and create the NAT gateway in the PUBLIC subnet.
# Billing starts NOW — this is the resource that dominates the cost. Move fast.
EIP=$(aws ec2 allocate-address --domain vpc --query 'AllocationId' --output text)
NAT=$(aws ec2 create-nat-gateway --subnet-id "$PUB" --allocation-id "$EIP" \
--query 'NatGateway.NatGatewayId' --output text)
aws ec2 wait nat-gateway-available --nat-gateway-ids "$NAT"
echo "$NAT"
# Output (takes ~1-2 min to become available; yours will differ):
# nat-0abc123def4567890
# 6. Private route table: send 0.0.0.0/0 to the NAT gateway, associate the private subnet.
RTPRIV=$(aws ec2 create-route-table --vpc-id "$VPC" \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id "$RTPRIV" \
--destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT"
aws ec2 associate-route-table --route-table-id "$RTPRIV" --subnet-id "$PRIV"
echo "private route table: $RTPRIV"
# Output (yours will differ):
# private route table: rtb-0priv444
# 7. One SSH key, and two security groups: bastion open to YOUR IP, private open to the bastion.
aws ec2 create-key-pair --key-name m90-vpc \
--query 'KeyMaterial' --output text > ~/m90-vpc.pem && chmod 400 ~/m90-vpc.pem
MY_IP=$(curl -s https://checkip.amazonaws.com)
SGB=$(aws ec2 create-security-group --group-name m90-bastion \
--description "SSH from my IP" --vpc-id "$VPC" --query 'GroupId' --output text)
SGP=$(aws ec2 create-security-group --group-name m90-private \
--description "SSH from bastion only" --vpc-id "$VPC" --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id "$SGB" \
--protocol tcp --port 22 --cidr "${MY_IP}/32"
aws ec2 authorize-security-group-ingress --group-id "$SGP" \
--ip-permissions IpProtocol=tcp,FromPort=22,ToPort=22,UserIdGroupPairs="[{GroupId=$SGB}]"
echo "$SGB $SGP"
# Output (yours will differ):
# sg-0bastion55 sg-0private66
# 8. Get the latest Ubuntu 24.04 AMI, launch the bastion (public) and the private instance.
AMI=$(aws ssm get-parameters \
--names /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \
--query 'Parameters[0].Value' --output text)
BAST=$(aws ec2 run-instances --image-id "$AMI" --instance-type t2.micro \
--key-name m90-vpc --security-group-ids "$SGB" --subnet-id "$PUB" \
--query 'Instances[0].InstanceId' --output text)
PRIVI=$(aws ec2 run-instances --image-id "$AMI" --instance-type t2.micro \
--key-name m90-vpc --security-group-ids "$SGP" --subnet-id "$PRIV" \
--query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-running --instance-ids "$BAST" "$PRIVI"
echo "bastion=$BAST private=$PRIVI"
# Output (yours will differ):
# bastion=i-0bastion777 private=i-0private888
# 9. Read the bastion's PUBLIC IP and the private instance's PRIVATE IP, then SSH THROUGH
# the bastion with ProxyJump (-J) and prove outbound works via the NAT gateway.
BIP=$(aws ec2 describe-instances --instance-ids "$BAST" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
PIP=$(aws ec2 describe-instances --instance-ids "$PRIVI" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
ssh -i ~/m90-vpc.pem -J ubuntu@"$BIP" ubuntu@"$PIP" \
'hostname -I && curl -s https://checkip.amazonaws.com'
# Output (private IP is 10.0.2.x; the checkip result is the NAT's public IP — yours differ):
# 10.0.2.10
# 54.226.10.20
# 10. TEARDOWN starts here. Terminate both instances first and wait.
aws ec2 terminate-instances --instance-ids "$BAST" "$PRIVI" \
--query 'TerminatingInstances[].CurrentState.Name' --output text
aws ec2 wait instance-terminated --instance-ids "$BAST" "$PRIVI"
echo "instances terminated"
# Output:
# shutting-down shutting-down
# instances terminated
# 11. Delete the NAT gateway — THE step that stops the meter — then release its Elastic IP.
aws ec2 delete-nat-gateway --nat-gateway-id "$NAT"
aws ec2 wait nat-gateway-deleted --nat-gateway-ids "$NAT"
aws ec2 release-address --allocation-id "$EIP"
echo "NAT gateway deleted, EIP released — billing stopped"
# Output (deletion takes ~1 min; an unreleased EIP also bills, so this line matters):
# NAT gateway deleted, EIP released — billing stopped
# 12. Delete everything else so nothing is left behind — order matters (inside-out).
aws ec2 delete-security-group --group-id "$SGP"
aws ec2 delete-security-group --group-id "$SGB"
aws ec2 delete-key-pair --key-name m90-vpc && rm -f ~/m90-vpc.pem
aws ec2 delete-subnet --subnet-id "$PRIV"
aws ec2 delete-subnet --subnet-id "$PUB"
aws ec2 delete-route-table --route-table-id "$RTPRIV"
aws ec2 delete-route-table --route-table-id "$RTPUB"
aws ec2 detach-internet-gateway --internet-gateway-id "$IGW" --vpc-id "$VPC"
aws ec2 delete-internet-gateway --internet-gateway-id "$IGW"
aws ec2 delete-vpc --vpc-id "$VPC"
echo "VPC and all components deleted"
# Output:
# VPC and all components deleted
Read the last outputs back: step 9’s curl returning the NAT’s public IP proves the private box reached the internet outbound only, and step 11’s “billing stopped” is the line that matters — deleting the NAT gateway (and releasing its Elastic IP) is the difference between a ₹5 lab and a ₹2,700 monthly surprise.
Common Errors & Fixes
These three trip up almost everyone building their first private-subnet setup. Read the error text slowly — parsing it is the actual skill.
Common error: Trying to SSH straight to the private instance’s address instead of going through the bastion:
ssh: connect to host 10.0.2.10 port 22: Connection timed outWhy: The private subnet has no route from the internet — that’s the entire point of it. Your laptop can’t reach
10.0.2.10directly; only hosts inside the VPC, like the bastion, can.Fix: Reach it through the bastion with ProxyJump:
ssh -i ~/m90-vpc.pem -J ubuntu@<bastion-public-ip> ubuntu@10.0.2.10. The bastion is in the public subnet, so it’s reachable, and it sits inside the VPC, so it can reach the private instance.How you’d spot it in prod: A timeout (not “connection refused”) to a
10.x/172.16-31.x/192.168.xaddress from outside the VPC is a routing/reachability problem, not authentication — the target simply has no path back to you. Jump through a bastion or use SSM.
Common error: SSH-ing to the bastion first, then trying to SSH on to the private instance from the bastion’s own shell:
ubuntu@ip-10-0-1-20:~$ ssh ubuntu@10.0.2.10 ubuntu@10.0.2.10: Permission denied (publickey).Why: The private key lives on your laptop, not on the bastion, so the bastion has nothing to authenticate with. Copying the key onto the bastion would work but leaves a private key sitting on an internet-facing box — exactly what you don’t want.
Fix: Don’t copy the key. Use ProxyJump from your laptop (
ssh -J ubuntu@<bastion> ubuntu@10.0.2.10) or agent forwarding (ssh -A); both keep the key on your laptop and tunnel the hop through the bastion.How you’d spot it in prod: “Permission denied (publickey)” on the second hop, when the first hop worked, almost always means the credential didn’t travel — set up ProxyJump or agent forwarding rather than scattering private keys across jump hosts.
Common error: Trying to delete the VPC or a subnet while the NAT gateway or instances still exist:
An error occurred (DependencyViolation) when calling the DeleteSubnet operation: The subnet 'subnet-0pub111' has dependencies and cannot be deleted.Why: AWS won’t delete a VPC component while something still lives in it. A NAT gateway or a not-yet-terminated instance in the public subnet, or the still-attached internet gateway, all block the delete so you can’t strand a running, billing resource.
Fix: Tear down inside-out, exactly the order steps 10–12 use: terminate instances → delete the NAT gateway and release its EIP → delete subnets and route tables → detach and delete the IGW → delete the VPC.
How you’d spot it in prod:
DependencyViolationon aterraform destroyor cleanup script means a child resource outlived its parent in the plan — usually a NAT gateway, ENI or instance. Delete the children first; never force the parent.
VPC Networking Interview Questions
Public vs private subnets, what a NAT gateway is (and why it bills), and the bastion pattern are staple Phase-3 cloud-networking questions — say your own version out loud before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Skim AWS Systems Manager Session Manager: it opens a shell into a private instance through the AWS API with no bastion and no open port 22 at all — the pattern most teams now prefer. Note why it beats a bastion on attack surface.
- 10 min — Read the NAT gateway pricing and VPC endpoints pages: a gateway VPC endpoint for S3 lets private instances reach S3 without a NAT gateway at all — often the cheaper design.
- 15 min — Rebuild today’s shape but SSH with a
~/.ssh/configProxyJump entry instead of the-Jflag: define the bastion as a jump host once, andssh private-boxjust works. This is how real teams keep the hop invisible.
What is the difference between a public and a private subnet? Both
The only technical difference is the route table. A public subnet has a route sending 0.0.0.0/0 to an internet gateway, so an instance there with a public IP can both reach the internet and be reached from it. A private subnet has no route to the internet gateway, so nothing on the internet can start a connection to it, and it can't reach out directly either. You put anything that shouldn't be internet-facing — databases, app servers, internal services — in private subnets, and only load balancers, bastions and NAT gateways in the public ones. Same subnet mechanics; the route table is what makes one public and one private.
What does a NAT gateway do, and why does it cost money? Both
A NAT gateway lets instances in a private subnet make outbound connections to the internet — apt updates, pulling images, calling APIs — without letting the internet start a connection back. It sits in a public subnet, and the private subnet's route table sends 0.0.0.0/0 to it; it translates outbound traffic to its own public IP and lets the replies back, but it's strictly one-way. It costs money because, unlike a security group or a route table, it's a managed, always-on appliance: AWS bills it per hour it exists plus per gigabyte processed, with no free tier — roughly $32 a month even idle. A forgotten NAT gateway is a classic silent VPC charge.
What is a bastion host and why would you use one? Both
A bastion, or jump host, is a small hardened instance in a public subnet that you SSH into first, then hop from it to instances in private subnets over the VPC's internal network. The point is to shrink the attack surface: instead of exposing every instance to the internet, only the bastion is reachable, so it's the one box you harden and monitor. You lock its security group to your own IP on port 22, and configure the private instances to accept SSH only from the bastion's security group. Many teams now replace the bastion with AWS Systems Manager Session Manager, which gives an API-driven shell with no open inbound port at all.
How do you SSH into an instance in a private subnet? Product
You can't reach it directly — there's no internet route into a private subnet — so you go through the bastion. I SSH to the bastion's public IP, then on to the private instance's private IP. In practice I don't copy my key onto the bastion; I use ProxyJump: ssh -J ubuntu@bastion ubuntu@10.0.2.10, which tunnels through the bastion in one command and keeps the key on my laptop (agent forwarding with -A also works). The private instance's security group must allow port 22 from the bastion's security group. If the hop fails with 'Permission denied (publickey)' the key isn't being forwarded; if it times out, the security group isn't allowing the bastion.
Mark Day 51 complete
Tomorrow you move from compute to storage — S3 buckets, bucket policies, static website hosting, and lifecycle rules.
Stuck on today’s lab? Ask in Mission 90 Q&A