Command Palette

Search for a command to run...

Hectal

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.

30 min Free 5 steps 2 break-it drills

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.

Who may talk to whomdiagram
Rendering diagram…

Part 2

Your project after this mission · 3 files change

shoplite/
  • 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. 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. 2

    Create the three groups

    Groups first, with no rules. name_prefix plus create_before_destroy means 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. 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. 4

    Output the group IDs, then apply

    Stage 3 attaches alb to the load balancer and app to the ECS service; Stage 4 attaches db to 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. 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_*_rule resources; no inline ingress/egress blocks.
  • ✓All three groups use name_prefix and create_before_destroy.
  • ✓security_group_ids output 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.

terminal
$ terraform plan
── what you'll see ──
# aws_security_group.app will be updated in-place
~ resource "aws_security_group" "app" {
~ egress = [
- {
- cidr_blocks = [ "0.0.0.0/0" ]
- protocol = "-1"
...
},
]
~ ingress = [
- {
- from_port = 8080
- security_groups = [ "sg-0a1b2c3d4e5f60718" ]
...
},
# (1 unchanged element hidden)
]
}
 
Plan: 0 to add, 1 to change, 0 to destroy. ← every single time

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.

terminal
$ terraform validate
── what you'll see ──
╷
│ Error: Cycle: aws_security_group.app, aws_security_group.alb
│
╵

Part 5

Interview questions from this mission

01

Why prefer aws_vpc_security_group_ingress_rule resources over inline ingress blocks?

02

What does create_before_destroy do on a security group, and why pair it with name_prefix?

03

How does Terraform handle AWS's default 'allow all' egress rule?

0/4 · 0%