Built, measured and torn down on 5 August 2026 in us-east-1. Total cost $0.13. The infrastructure no longer exists — the test matrix and write-up are in the repo.
The setup
A custom VPC built component by component rather than through the wizard, so nothing is implicit. Two public subnets in different availability zones, one instance in each, one security group per instance. No NAT gateway — at ~$32/month it’s the most expensive thing you can create by accident, and this design doesn’t need one.
Ten connectivity tests, run once per phase, five phases. Every cell in the matrix is an observed result rather than an assumed one.
Request path: client → internet gateway → EC2 A, and the reply dies at the NACL egress deny (red). Inside the VPC: A → B on the private IP, where B's source-group rule matches. Admin: both instances connect outbound to Session Manager, with no inbound ports open.
The demonstration
Everything up to this point can be done with inbound rules, which a security group could also do. This is the experiment that separates the two mechanisms — deny outbound to one address only, leave inbound completely open:
NACL rule 50: DENY egress -> 99.230.7.23/32 (no inbound rule at all)
client: curl http://<instance> -> hangs, then times out
server: /var/log/httpd/access_log
99.230.7.23 - - [05/Aug/2026:03:38:05 +0000] "GET / HTTP/1.1" 200 55
The request arrived. Apache answered it, 200, 55 bytes. The client never received a single byte. The reply was dropped on the way out of the subnet.
An identical outbound deny in a security group would have changed nothing, because a security group automatically permits return traffic for an established flow. A NACL has no concept of a flow — it evaluates every packet in isolation, in both directions, independently.
That is the whole difference, and it’s why NACL rules must account for ephemeral ports on the return path: a NACL that allows inbound :80 but not outbound 1024–65535 will accept every request and answer none of them.
Three other things the matrix showed
Security groups are stateful per flow, not per host pair. At baseline, instance A could ping B but B could not ping A — the same two machines, opposite results. A’s ping and its reply are one flow; B initiating its own ping is a new flow needing its own inbound rule on A’s group. Rule direction is the entire story.
A source-group rule only matches on the private path. After restricting B’s group to “HTTP from group A”, the same request from A behaved differently depending on the address used:
| From A to B | Result |
|---|---|
10.20.2.29 (private) | 200 — stays inside the VPC, so AWS still sees the source group |
44.203.117.47 (public) | failed — leaves via the internet gateway and returns as an address, matching no group |
This is the answer to “but I allowed that security group, why is it still refused?”
A NACL deny outranks any security group allow. One subnet-level rule darkened both instances while one of them still permitted HTTP from anywhere. NACLs are evaluated first and apply to the whole subnet.
Where the guide was wrong
The source material says “proceed without a key pair” and, in the same breath, “allow SSH on port 22 from your IP.” Those instructions contradict each other — with no key pair there is no way to use SSH, so the rule only widens the attack surface for nothing.
I used SSM Session Manager instead. The agent ships with Amazon Linux 2023, it needs only an instance profile, and it gives a shell with no inbound ports open at all. Port 22 is never opened anywhere in this project. It also made the entire test matrix scriptable, since commands run through the AWS API rather than an interactive session.
Worth noting for the same reason: SSM kept working through every restriction, because Session Manager is an outbound connection from the instance. Locking down inbound doesn’t lock you out — useful to know before tightening a rule on something you still need to administer.
Links
- s1-ec2-and-sgnacl — the full ten-test matrix across five phases
- aws-labs — all nine projects