Ravindra BagaleCourses & study guides मराठी

Chapter 46: Terraform 3-Tier Architecture Multi-AZ with ALBs Auto Scaling Bastion NAT and RDS

46.3 Security group chain for all tiers and the bastion

Story. Suppose we are passing the shop's security review. The rule is simple: each layer accepts traffic only from the layer in front of it, and SSH is accepted only from the bastion, and the bastion only from one admin IP. Six security groups express this, and no private group ever names 0.0.0.0/0 as a source.

internet → ext ALB :80 ext ALB → web :80 web → int ALB :80 int ALB → app :8080 app → db :3306
The traffic path, one hop at a time. A separate path allows admin IP → bastion :22 → web and app :22.

Steps

  1. Write security.tf.
  2. Run terraform validate.
  3. Search the file for 0.0.0.0/0 and confirm it is only on ext_alb ingress and on egress.
# ---- security.tf ----
resource "aws_security_group" "ext_alb" {
  name   = "${var.project}-ext-alb"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "web" {
  name   = "${var.project}-web"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "int_alb" {
  name   = "${var.project}-int-alb"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "app" {
  name   = "${var.project}-app"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "db" {
  name   = "${var.project}-db"
  vpc_id = aws_vpc.main.id
}

resource "aws_security_group" "bastion" {
  name   = "${var.project}-bastion"
  vpc_id = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "ext_alb_http" {
  security_group_id = aws_security_group.ext_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" "web_from_ext_alb" {
  security_group_id            = aws_security_group.web.id
  referenced_security_group_id = aws_security_group.ext_alb.id
  ip_protocol                  = "tcp"
  from_port                    = 80
  to_port                      = 80
}

resource "aws_vpc_security_group_ingress_rule" "int_alb_from_web" {
  security_group_id            = aws_security_group.int_alb.id
  referenced_security_group_id = aws_security_group.web.id
  ip_protocol                  = "tcp"
  from_port                    = 80
  to_port                      = 80
}

resource "aws_vpc_security_group_ingress_rule" "app_from_int_alb" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.int_alb.id
  ip_protocol                  = "tcp"
  from_port                    = 8080
  to_port                      = 8080
}

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                    = 3306
  to_port                      = 3306
}

resource "aws_vpc_security_group_ingress_rule" "bastion_ssh" {
  security_group_id = aws_security_group.bastion.id
  cidr_ipv4         = var.my_ip_cidr
  ip_protocol       = "tcp"
  from_port         = 22
  to_port           = 22
}

resource "aws_vpc_security_group_ingress_rule" "web_ssh_from_bastion" {
  security_group_id            = aws_security_group.web.id
  referenced_security_group_id = aws_security_group.bastion.id
  ip_protocol                  = "tcp"
  from_port                    = 22
  to_port                      = 22
}

resource "aws_vpc_security_group_ingress_rule" "app_ssh_from_bastion" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.bastion.id
  ip_protocol                  = "tcp"
  from_port                    = 22
  to_port                      = 22
}

resource "aws_vpc_security_group_egress_rule" "all" {
  for_each          = toset(["ext_alb", "web", "int_alb", "app", "bastion"])
  security_group_id = local.sg_ids[each.key]
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

locals {
  sg_ids = {
    ext_alb = aws_security_group.ext_alb.id
    web     = aws_security_group.web.id
    int_alb = aws_security_group.int_alb.id
    app     = aws_security_group.app.id
    bastion = aws_security_group.bastion.id
  }
}

What each line does

  • aws_security_group "ext_alb" ... "bastion" — Six groups: one per load balancer, one per tier, one for the bastion.
  • ext_alb_http cidr_ipv4 = "0.0.0.0/0" — The only rule open to the internet.
  • web_from_ext_alb — Web servers accept port 80 only from the external ALB.
  • int_alb_from_web — The internal ALB accepts only the web servers.
  • app_from_int_alb port 8080 — App servers accept only the internal ALB.
  • db_from_app port 3306 — The database accepts only app servers.
  • bastion_ssh cidr_ipv4 = var.my_ip_cidr — SSH to the bastion from your /32 only.
  • web_ssh_from_bastion / app_ssh_from_bastion — SSH to private servers only from the bastion.
  • aws_vpc_security_group_egress_rule "all" for_each — One outbound rule per group except db, created in a loop.
  • locals { sg_ids = {...} } — Maps names to ids so the loop can find each group.

When it is green.

$ terraform validate
Success! The configuration is valid.

When it is red.

Error: Cycle: aws_security_group.web, aws_security_group.app

Fix. A cycle happens when rules are written inline inside two groups that point at each other. Keep the groups empty and put every rule in its own aws_vpc_security_group_ingress_rule resource, as shown.

What: every term in plain English

  • security group — A stateful virtual firewall attached to a network interface. It allows traffic; anything not allowed is dropped.
  • stateful — If a request is allowed in, the reply is allowed out automatically. You do not write return rules.
  • ingress rule — A rule for traffic coming into the resource.
  • egress rule — A rule for traffic leaving the resource. AWS adds allow-all egress by default; Terraform removes it unless you write it.
  • source security group — A rule that allows traffic from members of another group instead of an IP range. This is how tiers are chained.
  • aws_vpc_security_group_ingress_rule — The newer one-rule-per-resource style. Each rule can be changed without touching the others.
  • least privilege — Open only the ports and sources a tier really needs, nothing more.
  • chain — ext ALB to web, web to internal ALB, internal ALB to app, app to DB. Each link allows only the previous tier.

Example 1: Write the chain as a table first

At Capgemini security reviews the first artefact is a port table. If a row is not in the table, it does not get a rule.

  1. List each group.
  2. Write its allowed source and port.
  3. Turn each row into one rule.
Group     Allows in from          Port
ext_alb   0.0.0.0/0               80
web       ext_alb SG              80
web       bastion SG              22
int_alb   web SG                  80
app       int_alb SG              8080
app       bastion SG              22
db        app SG                  3306
bastion   var.my_ip_cidr          22

Output

8 ingress rules, 6 groups. Only ext_alb is open to the internet, and only on port 80.

What it shows: Notice that no rule uses an IP range except the public ALB and the bastion. Every other link uses a security group as the source.

Example 2: Allow web only from the external ALB

If web servers accepted traffic from anywhere in the VPC, a compromised app server could attack them. Referencing the ALB group keeps the door narrow.

  1. Create the web group.
  2. Add one ingress rule with referenced_security_group_id.
  3. Plan and read the rule.
resource "aws_security_group" "web" {
  name   = "${var.project}-web"
  vpc_id = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "web_from_alb" {
  security_group_id            = aws_security_group.web.id
  referenced_security_group_id = aws_security_group.ext_alb.id
  from_port                    = 80
  to_port                      = 80
  ip_protocol                  = "tcp"
}

Output

# aws_vpc_security_group_ingress_rule.web_from_alb will be created
    + from_port                    = 80
    + referenced_security_group_id = (known after apply)

What it shows: (known after apply) means the ALB group id will be filled in once it exists. Terraform orders the creation for you.

Example 3: Let the database accept only the app tier

Data at HDFC Bank sits behind exactly one rule: port 3306 from the app group. Even the bastion cannot reach the database directly.

  1. Create the db group.
  2. Allow 3306 from the app group.
  3. Check there is no other ingress.
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
  from_port                    = 3306
  to_port                      = 3306
  ip_protocol                  = "tcp"
}
aws ec2 describe-security-groups --group-ids $(terraform output -raw db_sg_id) \
  --query "SecurityGroups[0].IpPermissions[].[FromPort,UserIdGroupPairs[0].GroupId]"

Output

[
    [
        3306,
        "sg-0app1234567890abc"
    ]
]

What it shows: One rule, one source. That is what an auditor wants to see.

Example 4: Add egress rules on purpose

Terraform removes the default allow-all egress. Without an egress rule, servers cannot run dnf install through the NAT and user_data fails quietly.

  1. Add an allow-all egress rule for web, app and bastion.
  2. Keep the DB group without egress.
  3. Plan and count.
resource "aws_vpc_security_group_egress_rule" "all_out" {
  for_each          = { web = aws_security_group.web.id, app = aws_security_group.app.id, bastion = aws_security_group.bastion.id, ext_alb = aws_security_group.ext_alb.id, int_alb = aws_security_group.int_alb.id }
  security_group_id = each.value
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

Output

# aws_vpc_security_group_egress_rule.all_out["app"] will be created
# aws_vpc_security_group_egress_rule.all_out["bastion"] will be created
# aws_vpc_security_group_egress_rule.all_out["ext_alb"] will be created
# aws_vpc_security_group_egress_rule.all_out["int_alb"] will be created
# aws_vpc_security_group_egress_rule.all_out["web"] will be created

What it shows: ip_protocol = "-1" means all protocols. The DB group needs no egress because replies are stateful.

Example 5: Prove the chain with a port test

A rule on paper is not proof. Teams at Swiggy test each link with nc (netcat) from the previous tier during go-live.

  1. SSH to a web server through the bastion.
  2. Test the internal ALB on 80.
  3. Test the database on 3306 and see it time out.
# on a web server
nc -zv -w 3 $INT_ALB_DNS 80
nc -zv -w 3 $DB_ENDPOINT 3306

Output

Connection to internal-shop3t-int-alb-123.ap-south-1.elb.amazonaws.com 80 port [tcp/http] succeeded!
nc: connect to shop3t-mysql.abc.ap-south-1.rds.amazonaws.com port 3306 (tcp) timed out

What it shows: The second test must fail. Web servers have no business talking to the database; only the app tier can.

Example 6: Find open-to-world rules with a query

Security teams at Infosys run this query weekly. It lists every rule open to 0.0.0.0/0 so you can confirm only the public ALB has one.

  1. Query rules with cidr 0.0.0.0/0 in the VPC.
  2. Read the group names.
  3. Expect only ext_alb ingress.
aws ec2 describe-security-group-rules \
  --filters Name=group-id,Values=$(terraform output -raw ext_alb_sg_id) \
  --query "SecurityGroupRules[?CidrIpv4=='0.0.0.0/0' && !IsEgress].[GroupId,FromPort]"

Output

[
    [
        "sg-0extalb123456789a",
        80
    ]
]

What it shows: Run the same query without the group filter to scan the whole account.

Command cheat sheet

Command What it does When to use it
aws_​security_​group Creates a group Each tier
aws_​vpc_​security_​group_​ingress_​rule One inbound rule Chain links
referenced_​security_​group_​id Source is a group Tier to tier
aws_​vpc_​security_​group_​egress_​rule One outbound rule Allow updates
nc -​zv -​w 3 HOST PORT Port test Prove a link
aws ec2 describe-​security-​group-​rules Lists rules Audit

Verify: did it work?

  1. Six groups

    aws ec2 describe-security-groups --filters Name=vpc-id,Values=$(terraform output -raw vpc_id) --query "length(SecurityGroups)"
    

    Output

    7 (6 plus the VPC default)
    
  2. DB allows only 3306

    terraform state show aws_vpc_security_group_ingress_rule.db_from_app | grep from_port
    

    Output

    from_port = 3306
    
  3. Web cannot reach DB

    nc -zv -w 3 $DB_ENDPOINT 3306
    

    Output

    timed out
    

When it is red 1

Error: InvalidPermission.Duplicate: the specified rule already exists

Cause. The same rule is written both inline and as a separate rule resource.

Fix, step by step

  1. Use only the separate rule resources.
  2. Remove inline ingress blocks.

When it is red 2

dnf: Curl error (28): Timeout was reached for https://cdn.amazonlinux.com

Cause. The server group has no egress rule, so it cannot reach the NAT.

Fix, step by step

  1. Add the all_out egress rule.
  2. Replace the instance to rerun user_data.

When it is red 3

Error: DependencyViolation: resource sg-... has a dependent object

Cause. A group is still attached to an ENI or used as a source by another rule.

Fix, step by step

  1. Let terraform destroy remove dependents first.
  2. Delete hand-made ENIs in the console.

Cleanup

terraform destroy -auto-approve

Ravindra Bagale's Tip

Never mix inline ingress blocks with separate rule resources for the same group. They fight each other and every plan shows changes.

Practice tasks

Easy Draw the arrows

Why: Know the chain.

What to do: Draw six boxes and arrows with ports.

How:
  1. Use the table example.

Check: Eight arrows.

Easy Find the only public rule

Why: Read rules.

What to do: Run the 0.0.0.0/0 query.

How:
  1. Filter IsEgress false.

Check: Only ext_alb port 80.

Medium Add HTTPS

Why: Extend the chain.

What to do: Allow 443 on ext_alb from 0.0.0.0/0.

How:
  1. New ingress rule.

Check: Plan adds 1.

Hard Remove bastion SSH to web

Why: Tighten access.

What to do: Delete web_from_bastion and use Session Manager.

How:
  1. Add an instance role.

Check: SSH fails, SSM works.

Easy Say it in two lines

Why: If you can say it simply, you understand it.

What to do: Write two short lines about this lesson for a fresher.

How:
  1. Write the problem it solves.
  2. Write the one command or file you use.
  3. Read it aloud.

Check: A fresher understands it without a question.

Easy Start a safe lab folder

Why: Practice needs a throwaway place.

What to do: Make a new folder with versions.tf and a random_pet resource, so no AWS cost.

How:
  1. mkdir tf-lab and cd into it.
  2. Add the hashicorp/random provider and one random_pet.
  3. Run terraform init and terraform apply.

Check: Apply complete! with 1 added.

Medium Break it on purpose

Why: You must know the red output by face.

What to do: Cause one error from the red part of this lesson and fix it.

How:
  1. Follow one red section.
  2. Copy the exact message to your notes.
  3. Write the fix in one line.

Check: You fixed it without searching the web.

Medium Run it twice

Why: A second run must be quiet.

What to do: Run the same plan two times with no edits.

How:
  1. Run terraform plan.
  2. Run it again.
  3. Explain any change it still shows.

Check: The second plan says No changes, or you can explain each line.

Medium Write a runbook page

Why: At 2 a.m. nobody remembers flags.

What to do: Write a one-page runbook for this lesson.

How:
  1. Title, when to use, exact commands, how to check, how to undo.

Check: A teammate can follow it alone.

Medium Run it from a pipeline

Why: Real teams run Terraform from CI, not from laptops.

What to do: Run the main command of this lesson inside a GitHub Actions or Jenkins job.

How:
  1. Add a job with hashicorp/setup-terraform or a Terraform Docker image.
  2. Give it AWS access through an IAM role, never a key in the file.
  3. Run init, then the command from this lesson.

Check: The job log shows the same output you saw on your laptop.

Medium Check the cost

Why: Labs left running cost real money.

What to do: Find what this lesson would cost per day if you forgot to destroy it.

How:
  1. Open the AWS Pricing Calculator.
  2. Add each resource from your plan.
  3. Write the daily cost in your notes.

Check: You know the daily cost and you ran terraform destroy.

Hard Draw the flow top to bottom

Why: A drawing shows the order of work.

What to do: Draw this lesson as boxes from top to bottom.

How:
  1. Start at your code, go through plan and state, end at AWS.
  2. Mark where each command reads or writes.

Check: Each box matches a real step you ran.

Hard Explain it like an interview

Why: Interviewers want real stories.

What to do: Record a 60 second answer about this lesson.

How:
  1. Say the problem, the command, and one error you hit.

Check: Your answer has a real command and a real error message.

Homework: try at home

Try at home

Write the security group chain for a 3-tier HR portal where the app listens on 9000 and the database is PostgreSQL on 5432.

Recap

  • Six groups, eight ingress rules.
  • Tiers reference groups, not IPs.
  • Only the public ALB is open to the world.
  • Egress must be written in Terraform.
  • Prove each link with nc.

FAQ

Why not use subnet CIDRs as sources?

A group follows the instance wherever it is; a CIDR allows every server in the subnet.

Do I need return rules?

No. Security groups are stateful.

Network ACLs too?

Optional extra layer; groups are enough for this lab.

Why separate rule resources?

Each rule changes independently and plans stay clean.

Interview questions

  1. How do you allow only the web tier to reach the app tier?

    Add an ingress rule on the app group with the web or internal ALB group as the source.

  2. Are security groups stateful or stateless?

    Stateful; replies are allowed automatically.

  3. What happens to egress when Terraform creates a group?

    Terraform removes the default allow-all egress, so you must add it.

  4. How would you audit open ports?

    Query rules with 0.0.0.0/0 using describe-security-group-rules or AWS Config.

Quick quiz

Q1. Port from app to MySQL?

3306

Q2. Port from internal ALB to app?

8080

Q3. Field that references another group?

referenced_security_group_id

Q4. Protocol value for all traffic?

-1

Q5. Tool to test a port?

nc

Samajla ka?

Ravi Sir's Cards for this lesson

मराठी cards