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.
Steps
- Write security.tf.
- Run terraform validate.
- 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.
- List each group.
- Write its allowed source and port.
- 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.
- Create the web group.
- Add one ingress rule with referenced_security_group_id.
- 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.
- Create the db group.
- Allow 3306 from the app group.
- 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.
- Add an allow-all egress rule for web, app and bastion.
- Keep the DB group without egress.
- 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.
- SSH to a web server through the bastion.
- Test the internal ALB on 80.
- 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.
- Query rules with cidr 0.0.0.0/0 in the VPC.
- Read the group names.
- 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?
-
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) -
DB allows only 3306
terraform state show aws_vpc_security_group_ingress_rule.db_from_app | grep from_portOutput
from_port = 3306 -
Web cannot reach DB
nc -zv -w 3 $DB_ENDPOINT 3306Output
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
- Use only the separate rule resources.
- 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
- Add the all_out egress rule.
- 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
- Let terraform destroy remove dependents first.
- 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.
- 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.
- 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.
- 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.
- 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.
- Write the problem it solves.
- Write the one command or file you use.
- 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.
- mkdir tf-lab and cd into it.
- Add the hashicorp/random provider and one random_pet.
- 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.
- Follow one red section.
- Copy the exact message to your notes.
- 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.
- Run terraform plan.
- Run it again.
- 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.
- 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.
- Add a job with hashicorp/setup-terraform or a Terraform Docker image.
- Give it AWS access through an IAM role, never a key in the file.
- 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.
- Open the AWS Pricing Calculator.
- Add each resource from your plan.
- 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.
- Start at your code, go through plan and state, end at AWS.
- 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.
- 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
-
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.
-
Are security groups stateful or stateless?
Stateful; replies are allowed automatically.
-
What happens to egress when Terraform creates a group?
Terraform removes the default allow-all egress, so you must add it.
-
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?
Revision card
Interview card