When you attach multiple security groups to an EC2 instance, AWS applies a permissive OR logic to the rules. This means traffic is allowed if any of the attached security groups permits it. This behavior is fundamentally different from firewall rule processing in traditional networks where rules are typically evaluated sequentially.
For your specific use case of internal-only instances with HTTP access, you can indeed implement it using two separate security groups:
# Internal-only security group
resource "aws_security_group" "internal" {
name_prefix = "internal-"
description = "Allow all internal traffic"
ingress {
from_port = 0
to_port = 0
protocol = "-1"
self = true
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# HTTP-only security group
resource "aws_security_group" "http" {
name_prefix = "http-"
description = "Allow HTTP traffic from anywhere"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
The OR behavior has important implications:
- Least Privilege Principle: You can create specialized security groups for different access patterns
- Combinatorial Permissions: Be aware that rules from different groups combine to create the effective permissions
- Troubleshooting: Monitoring tools will show all applicable security groups when inspecting traffic flows
While you could combine rules into a single security group, maintaining separate groups offers benefits:
# Combined approach (less flexible)
resource "aws_security_group" "combined" {
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 0
to_port = 0
protocol = "-1"
self = true
}
}
Separate groups allow for better role-based access management and cleaner security posture auditing.
When you attach multiple security groups to an EC2 instance, AWS applies a permissive union of all rules. This means traffic is allowed if any of the attached security groups permits it. The rules don't need to overlap or agree - any single allow rule will grant access.
Your proposed approach with two separate security groups is exactly how AWS recommends handling complex access patterns:
# Internal communication security group
aws ec2 authorize-security-group-ingress \
--group-id sg-12345678 \
--protocol all \
--port -1 \
--source-group sg-12345678
# HTTP access security group
aws ec2 authorize-security-group-ingress \
--group-id sg-87654321 \
--protocol tcp \
--port 80 \
--cidr 0.0.0.0/0
With both SGs attached to an instance:
- Internal instances can communicate via all ports (first SG rule)
- External clients can only reach port 80 (second SG rule)
- No need to combine rules into a single complex security group
For production environments, I recommend using Terraform to manage these relationships:
resource "aws_security_group" "internal" {
name_prefix = "internal-"
ingress {
from_port = 0
to_port = 0
protocol = "-1"
self = true
}
}
resource "aws_security_group" "web" {
name_prefix = "web-"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
# ... other config ...
vpc_security_group_ids = [
aws_security_group.internal.id,
aws_security_group.web.id
]
}
While the union behavior is powerful, remember:
- AWS evaluates all security group rules before network ACLs
- Stateful nature means reply traffic is automatically allowed
- Always specify the minimum required ports and sources
- Use security group references instead of IP ranges when possible
This architecture gives you both flexibility and security - internal services can communicate freely while external access remains tightly controlled.