Mission 2.3 · Stage 2 — Network
Tiered Security Groups
Goal: Three security groups — load balancer, app, database — where each tier only accepts traffic from the tier in front of it, written with standalone rule resources.
By the end of this mission
- Create security groups and rules as separate resources
- Reference one security group from another's rules
- Understand why inline rules and rule resources must never be mixed
- Recognise and break a dependency cycle
Part 1
Understand it first
Security groups reference each other, not IPs
The AWS course (Topic 2.2) built the chain ALB → app → DB by letting each group allow traffic from the group in front of it. In Terraform that's a reference: the app's ingress rule has referenced_security_group_id = aws_security_group.alb.id. No IP ranges need maintaining, and it keeps working however many tasks or instances the tiers scale to.
Rule resources vs inline rules
Rules can be written inline (ingress { ... } blocks inside aws_security_group) or as separate aws_vpc_security_group_ingress_rule / egress_rule resources, one rule per resource. Prefer separate resources: each rule has its own address, so adding one rule is a small, clear plan instead of a diff of the whole group, and rules can reference groups that reference back without creating a cycle.
Never mix the two styles for the same group. An inline block claims ownership of ALL rules on the group, so on every plan it removes rules created by the separate resources, and the separate resources then add them back, giving a permanent flip-flop. See Break it.
Default egress
AWS gives every new security group an 'allow all outbound' rule. When Terraform creates a group, the AWS provider REMOVES that default, so the group has no rules except the ones in your code. ShopLite's app tier needs outbound access (pull images, call APIs through NAT), so you add egress explicitly. The database gets none, since it never initiates connections.
Part 2
Your project after this mission · 3 files change
- bootstrap/
- backend.tf
- main.tf
- infra/
- backend.tf
- locals.tf
- logs.tf
- network.tf
- outputs.tfmodified
- providers.tf
- refactors.tf
- security.tfnew
- storage.tf
- terraform.tfvars
- variables.tfmodified
- versions.tf
- .gitignore
Part 3
Build it, step by step
- 1
Add the app port variable
The ShopLite container listens on 8080. It's a variable because the ALB target group, the security groups, and the ECS task definition must all agree on it. One source of truth avoids mismatches.
infra/variables.tfadd to filehcl variable "app_port" { description = "Port the ShopLite container listens on." type = number default = 8080 } - 2
Create the three groups
Groups first, with no rules.
name_prefixpluscreate_before_destroymeans that if a group ever has to be replaced, the new one is created (with a new generated name) and attached before the old one is deleted. Mission 2.4 shows why that matters.infra/security.tfwhole filehcl resource "aws_security_group" "alb" { name_prefix = "${local.name_prefix}-alb-" description = "ShopLite load balancer" vpc_id = aws_vpc.main.id tags = { Name = "${local.name_prefix}-alb" } lifecycle { create_before_destroy = true } } resource "aws_security_group" "app" { name_prefix = "${local.name_prefix}-app-" description = "ShopLite API tasks" vpc_id = aws_vpc.main.id tags = { Name = "${local.name_prefix}-app" } lifecycle { create_before_destroy = true } } resource "aws_security_group" "db" { name_prefix = "${local.name_prefix}-db-" description = "ShopLite Postgres" vpc_id = aws_vpc.main.id tags = { Name = "${local.name_prefix}-db" } lifecycle { create_before_destroy = true } } - 3
Add the rules as separate resources
Public HTTP/HTTPS into the ALB; the ALB may only send to the app port on app tasks; app tasks accept only from the ALB and may send anywhere (through NAT); the database accepts 5432 only from app tasks.
ip_protocol = "-1"means all protocols.infra/security.tfadd to filehcl # ── ALB ───────────────────────────────────────────── resource "aws_vpc_security_group_ingress_rule" "alb_http" { security_group_id = aws_security_group.alb.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "tcp" from_port = 80 to_port = 80 } resource "aws_vpc_security_group_ingress_rule" "alb_https" { security_group_id = aws_security_group.alb.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "tcp" from_port = 443 to_port = 443 } resource "aws_vpc_security_group_egress_rule" "alb_to_app" { security_group_id = aws_security_group.alb.id referenced_security_group_id = aws_security_group.app.id ip_protocol = "tcp" from_port = var.app_port to_port = var.app_port } # ── App ───────────────────────────────────────────── resource "aws_vpc_security_group_ingress_rule" "app_from_alb" { security_group_id = aws_security_group.app.id referenced_security_group_id = aws_security_group.alb.id ip_protocol = "tcp" from_port = var.app_port to_port = var.app_port } resource "aws_vpc_security_group_egress_rule" "app_all" { security_group_id = aws_security_group.app.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "-1" } # ── Database ──────────────────────────────────────── resource "aws_vpc_security_group_ingress_rule" "db_from_app" { security_group_id = aws_security_group.db.id referenced_security_group_id = aws_security_group.app.id ip_protocol = "tcp" from_port = 5432 to_port = 5432 } - 4
Output the group IDs, then apply
Stage 3 attaches
albto the load balancer andappto the ECS service; Stage 4 attachesdbto RDS.infra/outputs.tfadd to filehcl output "security_group_ids" { value = { alb = aws_security_group.alb.id app = aws_security_group.app.id db = aws_security_group.db.id } }terminal$ terraform apply── expected output ──...Plan: 9 to add, 0 to change, 0 to destroy....Apply complete! Resources: 9 added, 0 changed, 0 destroyed.Outputs:...security_group_ids = {"alb" = "sg-0a1b2c3d4e5f60718""app" = "sg-0b2c3d4e5f6071829""db" = "sg-0c3d4e5f607182930"} - 5
Verify the database rule from the CLI
The DB group's only inbound rule references the app group, not a CIDR. That's the property you want to be able to prove to an auditor.
terminal$ aws ec2 describe-security-group-rules \--filters Name=group-id,Values=$(terraform output -json security_group_ids | jq -r .db) \--query 'SecurityGroupRules[].[IsEgress,IpProtocol,FromPort,ReferencedGroupInfo.GroupId,CidrIpv4]' --output table── expected output ──------------------------------------------------------------| DescribeSecurityGroupRules |+-------+-------+-------+-------------------------+--------+| False| tcp | 5432 | sg-0b2c3d4e5f6071829 | None |+-------+-------+-------+-------------------------+--------+
Checkpoint — you should now have
- ✓Three security groups exist; the DB group has exactly one rule (5432 from the app group) and no egress.
- ✓All rules are separate
aws_vpc_security_group_*_ruleresources; no inlineingress/egressblocks. - ✓All three groups use
name_prefixandcreate_before_destroy. - ✓
security_group_idsoutput lists all three.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Mix an inline rule with rule resources
Add an inline block to the app group: ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["10.20.0.0/16"] }. Apply, then run terraform plan again, and again.
Break #2
Create a cycle with inline rules
Rewrite alb and app with inline rules that reference each other: the ALB's inline egress uses security_groups = [aws_security_group.app.id] and the app's inline ingress uses security_groups = [aws_security_group.alb.id]. Run terraform validate.
Part 5
Interview questions from this mission
Why prefer aws_vpc_security_group_ingress_rule resources over inline ingress blocks?
What does create_before_destroy do on a security group, and why pair it with name_prefix?
How does Terraform handle AWS's default 'allow all' egress rule?