{"id":"l-aws-3-4","courseId":"aws-cloud-foundations","lang":"en","kind":"lesson","summary":"Lay out a VPC with public and private subnets in two Availability Zones, explain what makes a subnet public, and lock traffic down with security groups and network ACLs.","html":"<p>Lantern’s old server had a public IP address and a firewall that, honestly, nobody had looked at in two years. Its database listened on port 5432 on the same machine. On AWS the database will run on its own, and the first question is: who can even reach it? The answer is designed in <strong>Amazon VPC</strong>, your private network inside AWS.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">A VPC and its subnets</h2>\n<p>A <strong>VPC</strong> (virtual private cloud) is an isolated network in one Region with an IP range you choose, such as <code dir=\"ltr\">10.0.0.0/16</code> (65,536 addresses). Every Region gives each account a <strong>default VPC</strong> with public subnets, handy for experiments; production gets a VPC you design.</p>\n<p>A <strong>subnet</strong> is a slice of that range inside <strong>one Availability Zone</strong>, such as <code dir=\"ltr\">10.0.1.0/24</code> (256 addresses, of which AWS reserves five). Because subnets are zonal, resilience means pairs: Lantern uses two AZs, each with one public and one private subnet.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 700 330\" role=\"img\" aria-labelledby=\"l-aws-3-4-t1\"><defs><marker id=\"arrow-l-aws-3-4-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-aws-3-4-t1\">A VPC with CIDR 10.0.0.0/16 spans two Availability Zones, each with a public and a private subnet. The public subnets route to the internet gateway. Private subnets in both AZs hold the database and app and send outbound traffic through one NAT gateway in AZ a.</title>\n  <rect class=\"d-box-warn\" x=\"300\" y=\"8\" width=\"120\" height=\"36\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"360\" y=\"31\" text-anchor=\"middle\">Internet gateway</text>\n  <rect class=\"d-box\" x=\"20\" y=\"56\" width=\"660\" height=\"262\" rx=\"14\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"80\">VPC 10.0.0.0/16</text>\n  <rect class=\"d-box\" x=\"40\" y=\"92\" width=\"300\" height=\"214\" rx=\"10\"/>\n  <text class=\"d-label-muted\" x=\"190\" y=\"112\" text-anchor=\"middle\">AZ a</text>\n  <rect class=\"d-box\" x=\"360\" y=\"92\" width=\"300\" height=\"214\" rx=\"10\"/>\n  <text class=\"d-label-muted\" x=\"510\" y=\"112\" text-anchor=\"middle\">AZ b</text>\n  <rect class=\"d-box-accent\" x=\"60\" y=\"122\" width=\"260\" height=\"70\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"190\" y=\"146\" text-anchor=\"middle\">Public 10.0.1.0/24</text>\n  <text class=\"d-code\" x=\"190\" y=\"172\" text-anchor=\"middle\">NAT gateway</text>\n  <rect class=\"d-box-primary\" x=\"60\" y=\"212\" width=\"260\" height=\"80\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"190\" y=\"238\" text-anchor=\"middle\">Private 10.0.11.0/24</text>\n  <text class=\"d-code\" x=\"190\" y=\"266\" text-anchor=\"middle\">RDS primary, app</text>\n  <rect class=\"d-box-accent\" x=\"380\" y=\"122\" width=\"260\" height=\"70\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"510\" y=\"146\" text-anchor=\"middle\">Public 10.0.2.0/24</text>\n  <text class=\"d-code\" x=\"510\" y=\"172\" text-anchor=\"middle\">load balancer (later)</text>\n  <rect class=\"d-box-primary\" x=\"380\" y=\"212\" width=\"260\" height=\"80\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"510\" y=\"238\" text-anchor=\"middle\">Private 10.0.12.0/24</text>\n  <text class=\"d-code\" x=\"510\" y=\"266\" text-anchor=\"middle\">RDS standby, app</text>\n  <path class=\"d-arrow\" d=\"M190 122 L330 44\" marker-end=\"url(#arrow-l-aws-3-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M510 122 L390 44\" marker-end=\"url(#arrow-l-aws-3-4-1)\"/>\n  <path class=\"d-arrow d-dashed\" d=\"M150 212 L150 194\" marker-end=\"url(#arrow-l-aws-3-4-1)\"/>\n  <path class=\"d-arrow d-dashed\" d=\"M400 220 L280 192\" marker-end=\"url(#arrow-l-aws-3-4-1)\"/>\n</svg><figcaption>Lantern’s VPC: public and private subnets in two Availability Zones</figcaption></figure>\n<h2 id=\"sec-2\" tabindex=\"-1\">What makes a subnet public</h2>\n<p>Nothing about the subnet itself. It is the <strong>route table</strong>:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"text\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Text</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code>Public route table               Private route table\n10.0.0.0/16  -&gt; local            10.0.0.0/16  -&gt; local\n0.0.0.0/0    -&gt; igw-0abc...      0.0.0.0/0    -&gt; nat-0def...</code></pre></div>\n<p>A subnet whose route table sends <code dir=\"ltr\">0.0.0.0/0</code> (everything not local) to an <strong>internet gateway</strong> is public: a resource there with a public IP can talk to the internet and be reached from it. A private subnet has no route to the internet gateway, so nothing on the internet can start a connection to it. If private resources need to start outbound connections, say to download OS updates, they route through a <strong>NAT gateway</strong> that sits in a public subnet.</p>\n<p>Lantern’s database and the migration server go in <strong>private</strong> subnets. The public subnets hold only the NAT gateway and, later, a load balancer.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>The cheapest route to S3 is a gateway endpoint</span></p>\n<p>NAT gateways bill per hour and per gigabyte processed, and Lantern’s private server pulls a lot from S3. A <strong>gateway endpoint</strong> for S3 (and one for DynamoDB) adds a route that keeps that traffic inside AWS, with no hourly or per-gigabyte charge. Add both to every VPC with private subnets.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Security groups: stateful firewalls on resources</h2>\n<p>A <strong>security group</strong> is an allow-list attached to a resource’s network interface: an instance, an RDS database, a Lambda function inside the VPC. Three properties make them pleasant to work with:</p>\n<ul>\n<li><strong>Allow rules only.</strong> Anything not allowed is denied. New groups allow no inbound traffic and all outbound.</li>\n<li><strong>Stateful.</strong> If a request is allowed in, the reply is allowed out automatically.</li>\n<li><strong>They can reference each other.</strong> “Allow 5432 from the <code dir=\"ltr\">lantern-app-sg</code> group” means “from anything wearing that group”, however many instances that is and whatever their IPs.</li>\n</ul>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">aws</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ec2</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> authorize-security-group-ingress</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --group-id</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> sg-0db1111111111111a</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --protocol</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> tcp</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --port</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 5432</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --source-group</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> sg-0app222222222222b</span></span></code></pre></div>\n<p>That one rule says it all: the database accepts PostgreSQL from the app tier and nothing else.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Opening SSH to the world</span></p>\n<p>A rule allowing port 22 from <code dir=\"ltr\">0.0.0.0/0</code> gets scanned within minutes of creation, and every weak password or unpatched SSH daemon becomes your problem. You don’t need inbound SSH at all: <strong>AWS Systems Manager Session Manager</strong> opens a shell through the SSM agent with IAM permissions and logging, and no inbound port. If you must use SSH, allow it only from a known address range.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">Network ACLs: stateless rules on subnets</h2>\n<p>A <strong>network ACL</strong> filters traffic at the subnet boundary. It differs from a security group in every way:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th></th>\n<th>Security group</th>\n<th>Network ACL</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Attached to</td>\n<td>Resources (network interfaces)</td>\n<td>Subnets</td>\n</tr>\n<tr>\n<td>Rules</td>\n<td>Allow only</td>\n<td>Allow and deny</td>\n</tr>\n<tr>\n<td>State</td>\n<td>Stateful: replies allowed</td>\n<td>Stateless: replies need rules</td>\n</tr>\n<tr>\n<td>Order</td>\n<td>All rules considered</td>\n<td>Lowest rule number first, first match wins</td>\n</tr>\n</tbody>\n</table></div><p>Because ACLs are stateless, allowing inbound 443 is not enough: the reply leaves from port 443 to the client’s ephemeral port (1024–65535), and the outbound rules must allow that too. The default network ACL allows everything both ways, and most teams leave it that way and do the real work in security groups. Reach for a custom ACL when you need a subnet-wide <strong>deny</strong>, such as blocking an abusive address range.</p>\n<h2 id=\"sec-5\" tabindex=\"-1\">What this costs</h2>\n<p>The VPC, subnets, route tables, security groups and ACLs are free. What bills is what you put in them: <strong>NAT gateways</strong> per hour plus per gigabyte, <strong>public IPv4 addresses</strong> per hour, and <strong>cross-AZ traffic</strong> per gigabyte. One NAT gateway per AZ is the resilient choice; a single shared one saves money and accepts that an AZ failure cuts private outbound traffic. For Lantern’s size, the sensible default is one NAT gateway plus gateway endpoints, and revisit when traffic grows. (Since late 2025 AWS also offers a <em>regional</em> NAT gateway that spreads across AZs by itself and needs no public subnet; compare its price before you switch.)</p>\n<p>That completes the core services. Section four puts them together and ships the new Lantern.</p>\n","toc":[{"id":"sec-1","text":"A VPC and its subnets","level":2},{"id":"sec-2","text":"What makes a subnet public","level":2},{"id":"sec-3","text":"Security groups: stateful firewalls on resources","level":2},{"id":"sec-4","text":"Network ACLs: stateless rules on subnets","level":2},{"id":"sec-5","text":"What this costs","level":2}],"takeaways":["A VPC is your private network in one Region, and each subnet is an address range inside a single Availability Zone.","A subnet is public only because its route table sends 0.0.0.0/0 to an internet gateway; private subnets have no such route.","Security groups are stateful allow-lists attached to resources, and they can name another security group as the allowed source.","Network ACLs are stateless rule lists on subnets, evaluated in number order, and can deny as well as allow.","NAT gateways and public IPv4 addresses bill by the hour, while gateway endpoints let private subnets reach S3 and DynamoDB at no extra charge."],"further":[{"title":"How Amazon VPC works","url":"https://docs.aws.amazon.com/vpc/latest/userguide/how-it-works.html"},{"title":"Control traffic to your AWS resources using security groups","url":"https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html"},{"title":"Control subnet traffic with network access control lists","url":"https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html"},{"title":"Gateway endpoints","url":"https://docs.aws.amazon.com/vpc/latest/privatelink/gateway-endpoints.html"}],"quiz":[{"q":"<p>Two subnets in Lantern’s VPC are identical except that one has a route <code dir=\"ltr\">0.0.0.0/0 → igw-0abc…</code> in its route table. What does that route change?</p>","options":[{"text":"Nothing, until a security group allows traffic.","why":"The route is what makes the subnet public at all. Security groups then filter traffic for each resource, but they are a separate layer."},{"text":"It encrypts traffic leaving that subnet.","why":"Routes decide where packets go, not how they are protected. Encryption comes from TLS or other protocols."},{"text":"It lets resources in the other subnet reach the internet too.","why":"Each subnet uses its own route table. The other subnet still has no route to the internet gateway."},{"text":"It makes that subnet public, so resources there with public IP addresses can talk to the internet directly.","why":"Correct. A route to an internet gateway is the definition of a public subnet in AWS."}],"answer":3},{"q":"<p>Lantern’s database security group must accept PostgreSQL connections only from the app servers. Which inbound rule is best?</p>","options":[{"text":"TCP 5432 from the app servers’ security group.","why":"Correct. Referencing a security group keeps working when app servers are replaced or scaled, without tracking IP addresses."},{"text":"TCP 5432 from 0.0.0.0/0, because the database is in a private subnet anyway.","why":"A private subnet is one layer of protection. Allowing the whole internet removes the second layer and lets anything inside the VPC connect."},{"text":"All traffic from the VPC’s CIDR range.","why":"That lets every resource in the VPC reach every port on the database, which is far wider than the app needs."},{"text":"TCP 5432 from the current private IP address of each app server.","why":"It works until an instance is replaced and gets a new address. Then connections fail, or a stranger’s instance inherits the old IP."}],"answer":0},{"q":"<p>A network ACL allows inbound TCP 443 from anywhere, but HTTPS requests to an instance in the subnet time out, while its security group allows 443. What is the likely cause?</p>","options":[{"text":"Security groups override network ACLs, so the ACL is ignored.","why":"Both layers apply. Traffic must pass the network ACL and the security group."},{"text":"The instance needs a NAT gateway to answer requests.","why":"A NAT gateway is for private resources starting outbound connections. Replies to inbound requests in a public subnet go through the internet gateway."},{"text":"Network ACLs are stateless, so the outbound rules must also allow the reply traffic to ephemeral ports.","why":"Correct. Unlike security groups, a network ACL does not remember the connection, so return traffic needs its own allow rule."},{"text":"Port 443 is blocked by AWS on new accounts.","why":"AWS does not block 443 for your resources. Inbound traffic is controlled by your own network ACLs and security groups."}],"answer":2}],"exercise":{"kind":"spot-bug","language":"bash","code":"#!/usr/bin/env bash\nset -euo pipefail\nVPC_ID=\"vpc-0abc1234def567890\"\n\nAPP_SG=$(aws ec2 create-security-group --group-name lantern-app-sg \\\n  --description \"Lantern app servers\" --vpc-id \"$VPC_ID\" \\\n  --query GroupId --output text)\nDB_SG=$(aws ec2 create-security-group --group-name lantern-db-sg \\\n  --description \"Lantern PostgreSQL\" --vpc-id \"$VPC_ID\" \\\n  --query GroupId --output text)\n\n# Visitors reach the app over HTTPS during the migration\naws ec2 authorize-security-group-ingress --group-id \"$APP_SG\" \\\n  --protocol tcp --port 443 --cidr 0.0.0.0/0\n\n# Maintainers need a shell on the app servers\naws ec2 authorize-security-group-ingress --group-id \"$APP_SG\" \\\n  --protocol tcp --port 22 --cidr 0.0.0.0/0\n\n# Only the app servers may talk to PostgreSQL\naws ec2 authorize-security-group-ingress --group-id \"$DB_SG\" \\\n  --protocol tcp --port 5432 --cidr 0.0.0.0/0","bugLines":[18,22],"title":"Review the security group script","prompt":"<p>A volunteer wrote this script to create Lantern’s security groups during the migration. The comments describe the intent, and the intent is right. Select every line where the code does <strong>not</strong> match a safe version of that intent.</p>\n","hints":["Public HTTPS on the app servers is intended for now. Look at the other two rules.","Which rule exposes a port that attackers scan for constantly, to the whole internet?","The database comment says “only the app servers”. What does <code dir=\"ltr\">0.0.0.0/0</code> mean?"],"explanation":"<p>Line 18 opens SSH to every address on the internet. Maintainers should use AWS Systems Manager Session Manager, which needs no inbound rule at all; if SSH is unavoidable, restrict <code dir=\"ltr\">--cidr</code> to a known office or VPN range such as <code dir=\"ltr\">203.0.113.0/24</code>. Line 22 lets anything reach PostgreSQL. Replace the CIDR with a reference to the app tier’s group, so the rule follows the app servers wherever they run:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">aws</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ec2</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> authorize-security-group-ingress</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --group-id</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">$DB_SG</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --protocol</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> tcp</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --port</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 5432</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --source-group</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">$APP_SG</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"</span></span></code></pre></div>\n<p>Line 14 is fine: during the migration visitors do reach the app over HTTPS. Being in a private subnet would reduce the damage of line 22, but defence in depth means each layer should be correct on its own.</p>\n","checkLabels":{}},"words":777,"readMinutes":4}