A firewall on every instance
A fresh server on the public internet starts collecting break-in attempts within minutes: automated scanners sweep the entire address space around the clock, probing every machine for open ports. A firewall is the defense: a filter that inspects network traffic and drops what is not allowed. In AWS, every EC2 instance gets one called a security group.
The rules of security groups:
- Inbound traffic is denied by default. A rule must explicitly allow a port and a source. That should feel familiar, it is IAM's default deny from lesson 2-2 applied to the network.
- Each rule names a port (443 for HTTPS, 22 for SSH, 5432 for PostgreSQL) and a source: an IP range like
0.0.0.0/0(the whole internet), or another security group. - They are stateful: if an inbound request is allowed, its response is allowed back out automatically. No matching outbound rule needed.
A sane web server: allow 443 from 0.0.0.0/0, allow 22 from your office IP only, nothing else.
Simulating a security group as a list of allowed ports
This models a security group: a list of ports with allow rules, and a check that answers the way the firewall would.
allowed_ports="22 80 443" check() { for p in $allowed_ports; do if [ "$p" = "$1" ]; then echo "port $1: ALLOWED" return fi done echo "port $1: BLOCKED (security groups deny by default)" } check 443 check 3306
Output
port 443: ALLOWED port 3306: BLOCKED (security groups deny by default)
Port 443 is in the list, so the loop finds a match and returns early with ALLOWED. Port 3306, the MySQL port, is not in the list, so the loop runs to completion and falls through to the BLOCKED line.
That fall-through is the important structural detail. There is no rule anywhere that says "deny 3306". The block happens because nothing allowed it, which is precisely how default deny works in a real security group.
Hardening the group to SSH and HTTPS only
This server should allow only SSH on 22 and HTTPS on 443, and three probes verify it: 22, then 80, then 5432.
allowed_ports="22 443" check() { for p in $allowed_ports; do if [ "$p" = "$1" ]; then echo "port $1: ALLOWED" return fi done echo "port $1: BLOCKED (security groups deny by default)" } check 22 check 80 check 5432
Output
port 22: ALLOWED port 80: BLOCKED (security groups deny by default) port 5432: BLOCKED (security groups deny by default)
Reading the three results
- Removing 80 from
allowed_portsleaves only"22 443", and the probe on 80 is now blocked. Dropping plain HTTP is normal practice, since an HTTPS-only site has nothing to serve on 80 except a redirect. - Port 5432 is PostgreSQL. It was never allowed, and it should stay that way: a database exposed to the internet is one of the fastest paths to a breach.
- The
checkfunction is unchanged. Only the rule list and the probes moved, which mirrors real work, where hardening means editing rules rather than editing the firewall.
Whether a response needs its own outbound rule
Yes, the response gets out. Security groups are stateful, so replies to allowed traffic are automatically allowed back out.
Stateful means the firewall remembers the conversation. When a request on 443 is allowed in, the security group records that connection, and the packets flowing back as part of it are permitted without any matching outbound rule.
The practical consequence is that you only write rules for who may start a conversation. That halves the number of rules you need and removes a whole category of confusing bug, where a service appears to receive traffic but never seems to answer.
It also explains why the default outbound rule on a new security group allowing all traffic is less alarming than it first looks. Inbound is where the exposure lives, and inbound denies everything until you say otherwise.