The error
You SSH into your EC2 instance and try to install a package, hit an external API, or just ping 8.8.8.8 โ and you get:
$ curl https://api.example.com
curl: (7) Failed to connect to api.example.com port 443: No route to host
$ ping 8.8.8.8
ping: connect: No route to host
$ yum update
Could not retrieve mirrorlist... connect: No route to host
It's 2 AM. The deploy is stalled. The instance is running, security groups look fine, but every outbound connection dies. Nothing is reaching the internet.
Root cause
EC2 instances inside a VPC have zero internet access by default. Traffic needs an explicit path out. Two scenarios cover 95% of cases:
- Public subnet, no Internet Gateway (IGW): The VPC has no IGW attached, or the route table is missing a
0.0.0.0/0 โ igw-xxxxxxxxentry. - Private subnet, no NAT Gateway: The instance has no public IP and no NAT Gateway to relay outbound traffic.
There's a third variant: the IGW or NAT Gateway exists, but the subnet's route table isn't pointing at it. Either way, the VPC is walled off from the internet.
Diagnose first
Don't touch anything yet. Figure out which failure you're actually dealing with.
1. Check if the instance has a public IP
# From inside the instance
curl -s http://169.254.169.254/latest/meta-data/public-ipv4
# Returns nothing or "404" = no public IP assigned
2. Check the route table from AWS CLI
# Find the subnet your instance is in
aws ec2 describe-instances \
--instance-ids i-0abc123def456 \
--query 'Reservations[].Instances[].SubnetId' \
--output text
# Find the route table for that subnet
aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=subnet-0xxxxxxx \
--query 'RouteTables[].Routes'
# Look for a route like:
# { "DestinationCidrBlock": "0.0.0.0/0", "GatewayId": "igw-..." }
# If it's missing, that's your problem.
3. Check if an IGW is attached to the VPC
aws ec2 describe-internet-gateways \
--filters Name=attachment.vpc-id,Values=vpc-0xxxxxxx \
--query 'InternetGateways[].InternetGatewayId'
# Empty array [] = no IGW attached
Fix A โ Public subnet: Attach an Internet Gateway
If your instance lives in a public subnet and should have a public IP, this is your fix.
Step 1: Create and attach an IGW
# Create the gateway
IGW_ID=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' \
--output text)
echo "Created IGW: $IGW_ID"
# Attach it to your VPC
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id vpc-0xxxxxxx
Step 2: Add the default route to the route table
# Get the route table ID for the public subnet
RTB_ID=$(aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=subnet-0xxxxxxx \
--query 'RouteTables[].RouteTableId' \
--output text)
# Add the 0.0.0.0/0 route pointing to the IGW
aws ec2 create-route \
--route-table-id $RTB_ID \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $IGW_ID
Step 3: Ensure the instance has a public IP
Launched without a public IP? You'll need to slap an Elastic IP on it:
# Allocate an Elastic IP
EIP_ALLOC=$(aws ec2 allocate-address --domain vpc \
--query 'AllocationId' --output text)
# Associate it with your instance
aws ec2 associate-address \
--instance-id i-0abc123def456 \
--allocation-id $EIP_ALLOC
Fix B โ Private subnet: Add a NAT Gateway
Private subnet instances โ app servers, background workers, Lambda in VPC โ never get public IPs. They need a NAT Gateway sitting in a public subnet to relay their outbound traffic.
Step 1: Create a NAT Gateway in a public subnet
# Allocate an Elastic IP for the NAT Gateway
EIP_ALLOC=$(aws ec2 allocate-address --domain vpc \
--query 'AllocationId' --output text)
# Create the NAT Gateway in the PUBLIC subnet (not the private one)
NAT_ID=$(aws ec2 create-nat-gateway \
--subnet-id subnet-PUBLIC-0xxxxxxx \
--allocation-id $EIP_ALLOC \
--query 'NatGateway.NatGatewayId' \
--output text)
echo "NAT Gateway: $NAT_ID"
# Takes ~60 seconds to become available
aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_ID
Step 2: Update the private subnet's route table
# Get the private subnet's route table
PRIVATE_RTB=$(aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=subnet-PRIVATE-0xxxxxxx \
--query 'RouteTables[].RouteTableId' \
--output text)
# Route all outbound traffic through the NAT Gateway
aws ec2 create-route \
--route-table-id $PRIVATE_RTB \
--destination-cidr-block 0.0.0.0/0 \
--nat-gateway-id $NAT_ID
Fix C โ Route exists but points at the wrong target
The route table already has a 0.0.0.0/0 entry โ but it's pointing at a deleted or detached gateway. Replace it rather than adding a duplicate:
aws ec2 replace-route \
--route-table-id $RTB_ID \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $IGW_ID # or --nat-gateway-id $NAT_ID for private subnet
Verify the fix
# SSH into the instance and test outbound connectivity
curl -I https://www.google.com
# Expected: HTTP/2 200
ping -c 3 8.8.8.8
# Expected: 3 packets transmitted, 3 received
# Confirm the route table looks right
aws ec2 describe-route-tables \
--route-table-ids $RTB_ID \
--query 'RouteTables[].Routes[?DestinationCidrBlock==`0.0.0.0/0`]'
Security group gotcha
Gateway wired up, routes correct, still seeing No route to host? Check the security group's outbound rules. They allow all egress by default, but it's common for someone to lock them down at some point and forget:
aws ec2 describe-security-groups \
--group-ids sg-0xxxxxxx \
--query 'SecurityGroups[].IpPermissionsEgress'
# Should contain a rule like:
# { "IpProtocol": "-1", "IpRanges": [{ "CidrIp": "0.0.0.0/0" }] }
Prevention
Nine times out of ten this happens because someone built a VPC manually โ or let Terraform/CloudFormation scaffold it โ and forgot to wire up the gateway or route table. A few habits that'll save you next time:
- VPC Reachability Analyzer in the AWS Console traces the full network path and tells you exactly where traffic is dropped. No actual packets sent, no guessing.
- Tag your subnets
tier=public/tier=privatein Terraform. When you're reviewing a VPC config weeks later, it's immediately obvious which subnets should route through IGW vs NAT. - In Terraform, always pair
aws_subnetwith an explicitaws_route_table_association. Relying on the main route table is a silent footgun โ every new subnet inherits it, so a change to the main table ripples out and breaks things you didn't touch. - CIDR planning matters. Overlapping or badly-sized subnets cause subtle routing problems that are painful to debug. A browser-based tool like ToolCraft's Subnet Calculator is handy for sizing VPC CIDRs upfront โ nothing leaves your machine.
Minimal Terraform that actually works
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}

