← All cloud projects
CASE STUDY us-east-1 2026
CLOUD PROJECT · 2026

The server answered 200. The client never saw it.

Security groups and network ACLs do different things, and the difference is usually explained rather than shown. Denying only outbound traffic to one address produced an Apache log line reading 200 55 while the client hung and timed out. The same rule in a security group does nothing at all. Ten tests across five phases, every result observed.

VPCSecurity GroupsNACLsEC2SSM

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.

ClientcurlCustom VPCInternetgatewayNACLboth subnetsAZ aPublic subnet ASG AEC2 AAZ bPublic subnet BSG BEC2 BSSMSession ManagerHTTP :80reply droppedprivate IPoutbound

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.

requestfailure / failover Hover, tap or Tab to a component to see what it does and why. Swipe sideways for the whole diagram.

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 BResult
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.