By the end you'll know
- What a security group is, what a network ACL (access control list) is, and how they differ
- Why one is stateful and the other stateless, and what that means for replies
- How network ACL rule numbers decide which rule wins
- What ephemeral ports are, and why a network ACL needs a rule for them
- Why a dry run passing doesn't mean a launch will work on the free plan
Before you start
- You need: the
juniper-ryeprofile from Lesson 4, signed in, and Lesson 8's idea of a VPC, a subnet and a route table. - The script:
firewall-lab.shfrom the practice folder'slesson-09. Everything it creates is namedjr-lesson9-*and taggedlesson=9, and itsdeletestep removes it. - Where you run things: your own machine, labelled (Your machine). The script tests the firewalls from there, with
curl. - Your own IP address never appears in the output. The script shows it as
your IP/32. - Every output below is from a real run on November 9, 2026.
This lab runs a server, which bills by the hour
The server costs $0.0084 an hour, and its public IPv4 address $0.005 an hour. The lab takes about 15 minutes, so a few cents from credits. Left running, the server and its address are about $10 a month. Run delete the same day and check every line shows 0.
The short version
- A security group wraps each server. It allows only what you list, and it's stateful: a request that gets in can always get its reply out.
- A network ACL wraps each subnet. It has numbered allow and deny rules, checked lowest first, and it's stateless: replies need a rule of their own.
- Firewalls fail silently. A blocked request doesn't get an error; it times out.
- Most teams do their filtering in security groups and leave network ACLs open, using them only for broad denies.
- A dry run checks permission, not your plan. The free plan refused a server type that the dry run allowed.
What this lesson costs
| Part | Cost |
|---|---|
| Security groups, network ACLs, the VPC | $0 |
| One t4g.micro server for about 15 minutes | $0.0084 an hour |
| Its public IPv4 address and 8 GB disk | $0.005 and about $0.0009 an hour |
| This lesson | A few cents, from credits |
| If the server were left running | About $10 a month |
Two firewalls
Lesson 8 built the bakery's network and decided where traffic can go. Firewalls decide what's allowed. AWS gives you two, at two levels:
| Security group | Network ACL | |
|---|---|---|
| Wraps | Each server, or anything with a network interface | A whole subnet |
| Rules | Allow only | Allow and deny |
| Order | Every rule counts; if any allows it, it's allowed | Lowest number first; the first match decides |
| Replies | Stateful: allowed back automatically | Stateless: need their own rule |
| A new one | Nothing in, everything out | Nothing in, nothing out |
| The default | Each VPC's default group lets its members talk to each other | Each VPC's default ACL allows everything |
In the bakery's building from Lesson 8: a network ACL is the guard at a room's door, checking a list in order; a security group is the lock on each cupboard, which only opens for the people on its list. Traffic to a server passes both.
Is it allowed, and what will it cost?
In the console: EC2 > Instance types, filtered on Free tier eligible, shows the same list.
(Your machine)
cd ~/Apps/aws-practice/lesson-09
chmod +x firewall-lab.sh
./firewall-lab.sh fence
== Is this allowed in us-east-2? (dry runs: nothing is created)
create a security group allowed
create a network ACL allowed
launch a t4g.micro server allowed
== Instance types this account's plan can launch (Free Tier eligible)
c7i-flex.large x86_64 2 CPUs 4.0 GB
m7i-flex.large x86_64 2 CPUs 8.0 GB
t3.micro x86_64 2 CPUs 1.0 GB
t3.small x86_64 2 CPUs 2.0 GB
t4g.micro arm64 2 CPUs 1.0 GB
t4g.small arm64 2 CPUs 2.0 GB
t8i.micro x86_64 2 CPUs 1.0 GB
t8i.small x86_64 2 CPUs 2.0 GB
the lab uses: t4g.micro
== What the lab pays for, in us-east-2 (AWS Price List)
server, t4g.micro: $0.0084 an hour, $6.13 for a 730-hour month
its public IPv4: $0.005 an hour (Lesson 1)
its 8 GB disk: about $0.0009 an hour
security groups, network ACLs, the VPC: $0
That list of eight is there because of what happened on the first try.
The dry run said yes, and the launch said no
The first version of the lab used a t4g.nano, the smallest Arm server, at $0.0042 an hour. Its dry run said allowed. Then the real launch:
== Building a small network and one web server
VPC 10.9.0.0/16, public subnet 10.9.0.0/24, internet gateway, route table
security group jr-lesson9-web-sg, with no inbound rules
aws: [ERROR]: An error occurred (InvalidParameterCombination) when calling the RunInstances operation: The specified instance type is not eligible for Free Tier. For a list of Free Tier instance types, run 'describe-instance-types' with the filter 'free-tier-eligible=true'.
A dry run only checks whether you're allowed to make the call. It doesn't check the rules of your plan. Accounts on the free plan can only launch instance types AWS marks as Free Tier eligible, and t4g.nano isn't one. The error even says how to find the ones that are, so the lab now asks:
aws ec2 describe-instance-types --filters Name=free-tier-eligible,Values=true
and uses the first Arm one, t4g.micro. It costs twice as much as the t4g.nano, still under a cent an hour.
On the free plan, check the instance type first
The free plan refuses instance types that aren't Free Tier eligible, even when a dry run passes. Run describe-instance-types with free-tier-eligible=true before you plan a lab around a particular size. On the paid plan, any type works and every one bills.
The server to protect
In the console: EC2 > Launch instance. Under Network settings > Edit, pick the VPC, the subnet and an existing security group.
(Your machine)
./firewall-lab.sh build
build makes a small network like Lesson 8's (VPC 10.9.0.0/16, one public subnet, an internet gateway, a route table), an empty security group, and one t4g.micro server running Amazon Linux 2023. A launch script, like Lesson 7's, installs nginx and the bakery's page at first boot. The server has no key pair and no SSH (Secure Shell): nobody needs to log in, and there's nothing to break into that way. Lesson 10 is all about servers; here it's just something to knock on.
The lab knocks with curl from your machine, three possible answers:
| Answer | What it means |
|---|---|
| page (HTTP 200) | Through every firewall, and the web server answered |
| timed out after 5 seconds | A firewall dropped it. AWS firewalls don't answer blocked traffic; they ignore it. |
| connection refused | The firewalls let it through, but nothing is listening on that port |
Security groups
In the console: EC2 > Security groups > pick the group > Inbound rules or Outbound rules > Edit.
(Your machine)
./firewall-lab.sh sg
== 1. The security group as created
in (no rules: nothing allowed)
out all port all to 0.0.0.0/0
knock on http://3.146.152.238/ : timed out after 5 seconds
== 2. Allow the web, port 80, from your IP only
in tcp port 80 from your IP/32
out all port all to 0.0.0.0/0
knock on http://3.146.152.238/ : page (HTTP 200)
== 3. Remove every outbound rule. Can the page still get back to you?
in tcp port 80 from your IP/32
out (no rules: nothing allowed)
knock on http://3.146.152.238/ : page (HTTP 200)
== 4. Put the outbound rule back, as it was
done
1. A new security group lets nothing in and everything out. The knock timed out: the server was there and nginx was running, but the security group dropped the request.
2. One rule opens the page, for one address. This is the call that added it:
aws ec2 authorize-security-group-ingress --group-id sg-... --protocol tcp --port 80 --cidr "$MY_IP/32"
| Part | What it does |
|---|---|
| authorize-security-group-ingress | Adds an inbound rule. ingress is in, egress is out. |
| --protocol tcp --port 80 | Web traffic over HTTP (Hypertext Transfer Protocol). HTTPS (HTTP Secure) would be 443. |
| --cidr "$MY_IP/32" | From your address only. /32 means exactly one address (Lesson 8's slash). |
3. No outbound rules at all, and the page still loads. That's stateful. The security group remembers the connection your request opened, and lets its reply back out whatever the outbound rules say. Outbound rules only matter for connections the server starts itself, such as downloading updates.
Two more things about security groups that the lab doesn't show:
- A rule can name another security group instead of an address. "Allow port 3306 from the web servers' group" lets in every server in that group, and nothing else, even as servers come and go. It's how a database is opened to an app, not to an address range.
- Changes apply at once, to every server in the group.
Network ACLs
In the console: VPC > Network ACLs > Create network ACL, then Inbound rules, Outbound rules and Subnet associations, each with Edit.
(Your machine)
./firewall-lab.sh nacl
== 1. A new network ACL on the subnet
in rule * deny all port all from 0.0.0.0/0
out rule * deny all port all to 0.0.0.0/0
knock: timed out after 5 seconds
== 2. Allow port 80 in, from your IP
in rule 100 allow tcp port 80 from your IP/32
in rule * deny all port all from 0.0.0.0/0
out rule * deny all port all to 0.0.0.0/0
knock: timed out after 5 seconds
== 3. Allow the replies out, to your IP's temporary ports
in rule 100 allow tcp port 80 from your IP/32
in rule * deny all port all from 0.0.0.0/0
out rule 100 allow tcp port 1024-65535 to your IP/32
out rule * deny all port all to 0.0.0.0/0
knock: page (HTTP 200)
== 4. Add a deny for port 80 from everyone, numbered 50
in rule 50 deny tcp port 80 from 0.0.0.0/0
in rule 100 allow tcp port 80 from your IP/32
in rule * deny all port all from 0.0.0.0/0
out rule 100 allow tcp port 1024-65535 to your IP/32
out rule * deny all port all to 0.0.0.0/0
knock: timed out after 5 seconds
== 5. Renumber the deny to 150
in rule 100 allow tcp port 80 from your IP/32
in rule 150 deny tcp port 80 from 0.0.0.0/0
in rule * deny all port all from 0.0.0.0/0
out rule 100 allow tcp port 1024-65535 to your IP/32
out rule * deny all port all to 0.0.0.0/0
knock: page (HTTP 200)
The security group still allows port 80 from your address throughout. Every change here is the network ACL's.
1. A new network ACL denies everything, both ways. Rule * is the catch-all at the bottom of every network ACL. You can't edit or remove it; you add numbered rules above it. Swapping the subnet onto this ACL cut the page off.
2. Allowing the request in isn't enough. Rule 100 lets your request reach the server, and the server answers, but the reply hits the outbound * deny on its way back. That's stateless: the network ACL checks every packet against its rules, with no memory that this one is a reply.
3. The reply needs its own rule, to a range of ports. When your machine connects to port 80, it picks a temporary port of its own for the answer to come back to, an ephemeral port. Which one depends on the operating system: Linux uses 32768 to 60999, Windows and macOS 49152 to 65535. AWS's advice is to allow 1024 to 65535, which covers them all. With outbound rule 100 in place, the page loads.
4. Rule numbers decide, not which rule is more specific. Rule 50, deny port 80 from everyone, is checked before rule 100, which allows your address. The first match wins, so you're denied, even though a later rule names you exactly.
5. Renumbered to 150, the same deny loses. Your request matches rule 100 first and is allowed. Everyone else still hits 150 and is denied. Same rules, different numbers, opposite result. Number rules in steps of 10 or 100, so there's room to slot one in later.
A network ACL change can cut off a whole subnet
A network ACL applies to everything in its subnet, and a new one denies everything in both directions. Forgetting the outbound rule for replies, or numbering a deny too low, breaks every server in the subnet at once. Change them carefully, and test straight away.
Which one to use
Security groups do almost all the work in real systems: they're per server, can't deny by mistake, and handle replies themselves. Network ACLs are for broad rules about a whole subnet, mostly denies: block a known bad address range, or make sure a database subnet can never be reached from a public one, whatever anyone does to a security group. Most teams leave network ACLs at their default, allow everything, until they need one.
Take it down
In the console: EC2 > Instances > Instance state > Terminate instance, then VPC > Your VPCs > Actions > Delete VPC.
(Your machine)
./firewall-lab.sh delete
== Terminated jr-lesson9-web. Billing for it stops now.
{
"Return": true,
"GroupId": "sg-0ed5f91080b48c531"
}
== Deleted jr-lesson9-vpc and everything in it
== Left over from this lab (tagged lesson=9)
servers: 0
VPCs: 0
security groups: 0
== Things that bill by the hour, anywhere in us-east-2
running servers: 0
NAT gateways: 0
The block in braces is AWS confirming the security group was deleted: newer versions of the AWS CLI print that for delete-security-group. The server went first and the script waited for it, because AWS won't delete a security group a server is still using, or a subnet with a server in it. The ID is safe to show: the group no longer exists.
In real systems
- Security groups reference each other in layers: the load balancer's group allows 443 from the internet, the web servers' group allows traffic only from the load balancer's group, and the database's group only from the web servers'. No addresses anywhere.
- SSH open to 0.0.0.0/0 is the most common finding in a security review. Teams replace SSH with AWS Systems Manager (Lesson 11) and close port 22 entirely.
- Unused security groups pile up. Tags and a regular clean-up keep the list readable.
- "It times out" is a firewall until proved otherwise. Check the security group, then the network ACL, then the route table, in that order.
- VPC Flow Logs record which traffic was accepted or rejected, so you can see which firewall dropped it. Lesson 32 turns them on.
Spot the problem
Work out what's wrong before you read the explanation.
"The database is only reachable from our app, honestly." A team runs its app servers in a public subnet and its database in a private one. The database's security group has one inbound rule: port 5432 (PostgreSQL) from 0.0.0.0/0. "It's in a private subnet, so only our servers can reach it anyway." What's wrong? The rule allows every address, and the only thing keeping the internet out is the route table. Anyone who gets onto any server in the VPC, or any network later connected to it (Lesson 13), can try the database's password directly. One routing change, and it's on the internet. The fix: replace the rule with one that names the app servers' security group as the source, so only servers in that group can connect, whatever their addresses. Then check: does describe-security-group-rules for the database's group show a group ID as the only source, and no 0.0.0.0/0?
Keep this
| Remember | Why |
|---|---|
| Security groups are stateful | Replies always get back out; the page loaded with no outbound rules |
| Network ACLs are stateless | Replies need an outbound rule for ports 1024 to 65535 |
| Lowest network ACL rule number wins | A deny at 50 beat an allow at 100; at 150 it lost |
| Blocked means timed out | Firewalls drop traffic silently; refused means nothing is listening |
| Name security groups, not addresses | Only the servers in that group get in, wherever they are |
| Dry runs don't check your plan | The free plan only launches Free Tier eligible types |
Where you are now
The bakery's page sat behind both firewalls. A security group kept it closed until one rule let in a single address, and kept working with no outbound rules at all. A network ACL needed a rule for the replies, and its rule numbers decided which of two rules won. You found that the free plan only launches some instance types, and everything was deleted with nothing left billing. Next, Lesson 10 looks properly at the server you've just been knocking on: EC2 instance types, images, disks and what you pay for each.
Check yourself
A security group allows port 80 in and has no outbound rules. Does a visitor get the page?
A network ACL allows port 80 in from your address. Why did the page still time out?
Inbound rule 50 denies port 80 from everyone; rule 100 allows it from your address. What happens to your request?
A request to a server times out. What does that usually mean?
The dry run for a t4g.nano said allowed, but the launch failed. Why?
Which source best limits a database to the app servers?
Key terms
- Firewall: rules that decide which network traffic is allowed.
- Security group: a stateful firewall around each server or network interface, with allow rules only.
- Network ACL (access control list): a stateless firewall around a subnet, with numbered allow and deny rules.
- Stateful: remembers connections, so replies are allowed automatically.
- Stateless: checks every packet on its own, so replies need their own rule.
- Inbound and outbound, or ingress and egress: traffic arriving at, and leaving, a server or subnet.
- Ephemeral port: a temporary port a client picks for a reply to come back to.
- Catch-all rule (*): the last rule in every network ACL, denying anything no other rule matched.
- Free Tier eligible: an instance type that accounts on the free plan can launch.
- Dry run: a call that checks your permission to do something without doing it.
Go deeper
- Networking track: Firewalls and security groups, firewalls without AWS.
- AWS: Security groups (opens in a new tab), network ACLs (opens in a new tab), and ephemeral ports (opens in a new tab).
- Try: in
firewall-lab.sh, change the outbound network ACL rule to ports 32768 to 60999 and run the lab again, knocking a few times. Does the page load every time? Your home router may change your port on the way out, which is one more reason AWS suggests 1024 to 65535.