Who this is for
You can launch an EC2 instance from the console and you are not sure why it can or cannot reach the internet. No Terraform required — though once this makes sense, describing it in code is the next thing worth doing.
The VPC
VPC 10.0.0.0/16 65,536 addresses
├─ public-a 10.0.1.0/24 (eu-west-1a) → route 0.0.0.0/0 to Internet Gateway
├─ public-b 10.0.2.0/24 (eu-west-1b)
├─ private-a 10.0.11.0/24 (eu-west-1a) → route 0.0.0.0/0 to NAT Gateway
└─ private-b 10.0.12.0/24 (eu-west-1b)
Three properties to get right at creation, because they are awkward later:
The CIDR is close to permanent. You can add secondary ranges, but you
cannot shrink or renumber the primary one. Pick something large and
non-overlapping with any network you might one day peer with or connect by VPN.
10.0.0.0/16 is a reasonable default precisely because it leaves room.
Subnets are tied to one availability zone. This is how you get fault-tolerance: two subnets in two AZs, not one big one.
AWS reserves five addresses in every subnet — the first four and the last.
A /24 gives you 251 usable addresses, not 256. On a /28 that reservation is
five out of sixteen, which is the size where people run out unexpectedly.
The CIDR calculator is quicker than doing this twice.
Public and private, properly
An internet gateway is attached to the VPC and gives two-way reachability to instances that have a public IP and a route to it.
A NAT gateway lets private instances make outbound connections without being reachable from outside. The classic (zonal) kind lives in a public subnet in one AZ; the newer regional kind spans AZs by itself and needs no public subnet.
public subnet route table 0.0.0.0/0 → igw-xxxx
private subnet route table 0.0.0.0/0 → nat-xxxx
That is the entire distinction. If a "private" instance is reachable from the internet, its subnet's route table points at the internet gateway.
The NAT gateway is the bill people do not expect. It is charged for every hour it exists, whether or not anything uses it, and for every gigabyte it processes. High availability multiplies the hourly part by the number of AZs — one zonal gateway per AZ, or a regional one billed per AZ it runs in. For traffic to S3 and DynamoDB, a gateway VPC endpoint routes privately and costs nothing per gigabyte — which is often most of the data a private subnet was sending through NAT.
Security groups and NACLs
| Security group | Network ACL | |
|---|---|---|
| Attached to | An instance's network interface | A subnet |
| State | Stateful — replies are automatic | Stateless — you need both directions |
| Rules | Allow only | Allow and deny |
| Evaluation | All rules together | In number order, first match wins |
Use security groups. Reach for NACLs only when you need an explicit deny at the subnet boundary — and remember that stateless means a rule permitting inbound 443 does nothing until the ephemeral port range is allowed outbound too. That asymmetry is responsible for a great deal of wasted time.
The practice worth adopting immediately is referencing a security group instead of a CIDR:
web-sg inbound 443 from 0.0.0.0/0
app-sg inbound 8080 from web-sg ← not from 10.0.1.0/24
db-sg inbound 5432 from app-sg
The rule now describes intent, keeps working when subnets change, and cannot accidentally include something else that happens to be in that range.
Launching an instance
Choices that matter more than the instance type:
The key pair is unrecoverable. AWS keeps the public half. Lose the private key and you cannot SSH in — recovery means detaching the root volume and attaching it elsewhere. Better: skip SSH keys and use Session Manager, which needs no key, no open port 22, and no bastion.
Root volume deletion. DeleteOnTermination defaults to true for the root
volume. Data on it goes when the instance does. Anything you want to keep
belongs on a separate EBS volume, or not on the instance at all.
Require IMDSv2. The instance metadata service hands out the instance's IAM
credentials. Under IMDSv1 that is a plain HTTP GET, so any server-side request
forgery in your application can fetch them. IMDSv2 requires a PUT to obtain a
token first, which a naive SSRF cannot do. Set the metadata option to
required rather than optional.
Elastic IPs are charged whether or not they are in use. AWS bills every public IPv4 address by the hour, attached or idle, so an allocated address doing nothing is pure cost. They are also a finite per-region allocation, so stranded ones are worth tidying.
User data
#!/bin/bash
set -euo pipefail
dnf install -y nginx
systemctl enable --now nginx
User data runs once at first boot, as root, and its output goes to
/var/log/cloud-init-output.log — which is the first place to look when an
instance comes up and the application is not there. It does not re-run on
reboot, so it is bootstrap, not configuration management.
Stop, start and reboot are not the same
Added 30 October 2026. The console puts Reboot, Stop and Terminate side by side, and they differ in exactly the ways that break things later. Per the EC2 User Guide:
- Reboot keeps everything except what was in memory: same host, same public and private IP, instance store data intact.
- Stop, then start keeps the EBS volumes and the private IP, but the instance usually lands on a new host, gets a new public IPv4 address unless an Elastic IP is associated, and loses anything on instance store disks. Billing for the instance stops while it is stopped; its EBS volumes are still billed.
- Terminate deletes the instance and, by default, its root volume.
So anything that points at the public address, a DNS record, a firewall rule, somebody's SSH config, breaks after the first stop. Use an Elastic IP or a load balancer for addresses that must not move, and keep data on EBS rather than instance store. Each state, with what survives it, is laid out in the EC2 explorer.
Common mistakes
- Expecting a subnet to be private because it is named private. The route table decides. Check it.
- Opening 22 or 3389 to
0.0.0.0/0. Automated scanning finds these in minutes. Session Manager removes the need entirely. - One subnet, one AZ. It works right up until that AZ has a bad day.
- Paying NAT charges for S3 traffic. A gateway endpoint is free and takes a few minutes to add.
- Clicking it together and never writing it down. A console-built VPC has no history and no second copy — Terraform state drift is what happens when you adopt it into code later and someone keeps clicking.
If this did not work
- Cannot SSH to a public instance. In order: does it have a public IP, does
its subnet route
0.0.0.0/0to an internet gateway, does the security group allow 22 from your address, and is the OS user right (ec2-user,ubuntu,admin— they differ by AMI). - A private instance cannot reach the internet. For a zonal NAT gateway, check it is in a public subnet (a zonal NAT gateway in a private subnet routes nowhere); for either kind, check the private route table points at it.
- Two instances cannot talk. Security groups are the usual answer — add a rule on the destination referencing the source's group.
- "InsufficientInstanceCapacity". That instance type is unavailable in that AZ right now. Try another AZ or type; it is not a quota error.
Next steps
Build the four-subnet layout above once by hand so the routing is concrete, then throw it away and rebuild it with Terraform. The second time takes twenty minutes and you get to keep it.
Related on this site
- CIDR calculator — Subnet ranges, without the arithmetic mistakes.
- Why artifacts should be immutable — Why you replace an instance rather than patch it.
- Terraform state drift — What happens when somebody changes it in the console.
Mentioned in this article
- Amazon VPC explainedAWS guide
- AWS IAM explainedAWS guide
Tagged with
Enjoyed this article?
Get more DevOps insights delivered to your inbox.
Get new posts by email
Subscribe to get an email when a new blog post is published. Skip anytime.
No spam, unsubscribe anytime.
Related Posts
Discussion
0 comments
Sign in to join the conversation.
Be the first to comment
Start a conversation about this post



