Terraform AWS security group separate rules — versus network ACL
Stateful filter on the ENI path. Separate rule resources so one port change is one plan row.
<!-- hal:authoritative:yaml -->
Stateful filter on the ENI path. Separate rule resources so one port change is one plan row. A NACL is a different shelf.
§I — Frame
09-17 wrote passwords through Azure write-only arguments. 09-14 imported a GCS bucket and moved its address. 09-11 injected S3 backend fields at init. Those fires stay filed.
Today the cloud referent is AWS networking filters. The concrete objects are aws_security_group plus separate aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources. Inline ingress / egress blocks still work. They also force Terraform to treat the whole rule list as one attribute, which turns a single port edit into a noisy diff. Separate rule resources make each 5-tuple its own address in state.
Yesterday's Python Azure NSG census stays on its shelf. Do not redo association enumeration here. Today is declare and plan on AWS.
§II — Three shelves
| Shelf | What it is | What it is not |
|---|---|---|
| Security group | Stateful allow/deny evaluated with connection tracking; attaches to ENIs | A subnet-stateless ordered ACL |
| Separate rule resource | One ingress or egress 5-tuple with its own Terraform address | An inline list attribute on the parent SG |
| Network ACL | Stateless ordered rules on a subnet | A synonym for security group |
AWS docs and the provider registry both still ship inline blocks. Prefer separate rule resources for anything you expect to change one port at a time in CI.
§III — Mechanism
resource "aws_security_group" "app" {
name = "app-sg"
description = "app ENI filter"
vpc_id = aws_vpc.main.id
tags = { Name = "app-sg" }
}
resource "aws_vpc_security_group_ingress_rule" "https" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "10.0.0.0/8"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "https from corp"
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Fact one. The parent SG owns identity and attachment points. Rules own the 5-tuple.
Fact two. Adding aws_vpc_security_group_ingress_rule.ssh creates one new resource in plan. Editing an inline ingress list often rewrites the whole set.
Fact three. A NACL rule (aws_network_acl_rule) is numbered, stateless, and subnet-scoped. Do not answer an SG question with a NACL rule number, and do not expect SG statefulness from a NACL.
Lab 19 trains the identity pain when count indexes shift under you. That pain is why tonight's Dev lesson expands ingress from a map with for_each / dynamic, and why Ops prefers separate rule addresses over a brittle list.
§IV — Ops drill
- 1. Declare VPC + SG parent (no inline ingress).
- 2. Add two ingress rule resources (443 corp CIDR, 22 bastion CIDR) and one egress.
- 3.
terraform plan: confirm three rule addresses, not one SG attribute churn. - 4. Change only the SSH CIDR. Plan should touch one rule resource.
- 5. Optional contrast: one
aws_network_acl_ruleon a public subnet with rule_no 100. Read both state addresses. Leave NACL deny-all defaults alone unless the ticket says otherwise.
Success: plan rows name rule resources; a single CIDR edit does not replace unrelated ports.
§V — Close instruction
Apply the drill in a throwaway VPC. Paste the plan summary that shows separate rule addresses. Pair: Dev expands an ingress map with dynamic / for_each; Cert names dynamic blocks, for_each vs count, and splat on the Associate objectives.