Opening Hook
Security groups are often treated as an afterthought in infrastructure-as-code projects, leading to overly permissive rules and maintenance headaches down the line. When you're managing dozens of resources across multiple environments, manually adjusting firewall rules becomes a bottleneck—and worse, a security risk. This guide walks through security groups with Terraform: best practices explained, focusing on patterns that keep your infrastructure secure, auditable, and maintainable at scale.
Why Terraform for Security Groups?
Managing security groups through the AWS console is a path to chaos. Each rule change lives outside version control, approval workflows fall apart, and you lose track of who changed what and when. Terraform gives you firewall rules as code—versioned, reviewable, and repeatable. But writing security group code carelessly defeats the purpose. We've seen production incidents where a single overly-permissive rule let attackers reach a database, and the culprit was a security group defined years earlier with no documentation.
The practices in this article come from teams managing infrastructure across e-commerce, SaaS, and fintech workloads. They scale from a few resources to hundreds without becoming unwieldy.
One Security Group Per Role
The first rule: don't create a monolithic security group that handles everything. Instead, define one security group per logical role or tier in your architecture. This means separate groups for web servers, application servers, databases, and any other distinct component.
Why? Roles change independently. Your web tier might need to accept traffic from a new load balancer, but your app tier doesn't. When rules are mixed together, you end up with "just add one more rule," and suddenly your security group has 30 rules serving five different purposes. That's unmaintainable and dangerous.
Here's a concrete example: an e-commerce platform with web, API, and database tiers:
resource "aws_security_group" "web" {
name_prefix = "web-"
description = "Web tier – serves traffic from ALB"
vpc_id = aws_vpc.main.id
tags = {
Name = "web-sg"
Role = "web"
}
}
resource "aws_security_group" "api" {
name_prefix = "api-"
description = "API tier – serves requests from web tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "api-sg"
Role = "api"
}
}
resource "aws_security_group" "database" {
name_prefix = "db-"
description = "Database tier – serves queries from API tier only"
vpc_id = aws_vpc.main.id
tags = {
Name = "db-sg"
Role = "database"
}
}Each role is isolated. Each can evolve on its own schedule. The intent is immediately clear when someone reviews the code or the AWS console.
Reference Groups, Not IP Ranges, Inside the VPC
This is where many teams stumble. Inside a VPC, never reference IP addresses or CIDR blocks when you can reference another security group. Why? Because IP addresses change—instances get replaced, auto-scaling groups spin up new instances with different IPs, containers restart. But a security group ID is stable and declarative.
Here's the anti-pattern: hardcoding IP ranges.
resource "aws_security_group_rule" "allow_app_to_db" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.0.1.0/24"] # Wrong: brittle, unclear
security_group_id = aws_security_group.database.id
}If you move your API instances to a different subnet, that rule breaks and you won't know until traffic fails. More importantly, there's no documentation linking this rule to the API tier.
Here's the pattern we recommend:
resource "aws_security_group_rule" "allow_api_to_db" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.api.id
security_group_group_id = aws_security_group.database.id
description = "PostgreSQL from API tier"
}Now the rule is explicit about which tier can connect. When you review this code, you immediately understand the data flow. If the API tier needs database access, the security group relationship enforces it. No more guessing whether 10.0.1.0/24 still exists.
The one exception: external traffic. Load balancers, third-party webhooks, and customer-facing services need to accept traffic from outside the VPC. Then, and only then, use CIDR blocks—and document why.
resource "aws_security_group_rule" "allow_https_from_internet" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Internet traffic to ALB
security_group_id = aws_security_group.alb.id
description = "HTTPS from public internet"
}Even here, the description matters. It explains why the rule exists.
Separate Rule Resources for Clarity
You can define security group rules in two ways: inline within the aws_security_group resource, or as separate aws_security_group_rule resources. We strongly recommend the second approach for anything production-grade.
Inline rules look cleaner at first glance:
resource "aws_security_group" "api" {
name = "api-sg"
description = "API tier"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.database.id]
}
egress {
from_port = 0
to_port = 65535
protocol = "tcp"
security_groups = [aws_security_group.database.id]
}
}But this couples the security group definition to its rules. When you add a new rule, you modify the security group. When you remove a rule, you edit the same resource. Over time, the security group becomes a grab-bag of unrelated rules.
With separate rule resources, each rule is explicit and named:
resource "aws_security_group" "api" {
name = "api-sg"
description = "API tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "api-sg"
}
}
resource "aws_security_group_rule" "api_ingress_from_web" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
source_security_group_id = aws_security_group.web.id
security_group_id = aws_security_group.api.id
description = "HTTP from web tier"
}
resource "aws_security_group_rule" "api_ingress_from_db" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.database.id
security_group_id = aws_security_group.api.id
description = "PostgreSQL response from database"
}
resource "aws_security_group_rule" "api_egress_to_db" {
type = "egress"
from_port = 5432
to_port = 5432
protocol = "tcp"
destination_security_group_id = aws_security_group.database.id
security_group_id = aws_security_group.api.id
description = "PostgreSQL queries to database"
}Now each rule is a unit. You can add, remove, or modify rules without touching the security group itself. Your infrastructure becomes modular. Rules can even live in separate files or modules, organized by tier.
A second benefit: team workflows. In a code review, a diff showing five new rules is clear. A diff showing an entire security group redefined is harder to parse. Pull requests stay focused.
Least Privilege Ports
Least privilege means: allow only what's necessary, nothing more. For ports, this means being specific.
Anti-pattern: allowing entire port ranges.
resource "aws_security_group_rule" "allow_all_tcp" {
type = "ingress"
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
security_group_id = aws_security_group.api.id
}This opens every TCP port on instances in the API security group. If a vulnerability lets an attacker execute code on an instance, they can potentially reach any port-based service. That's unnecessary risk.
Least privilege approach: define exactly which ports each tier needs.
resource "aws_security_group_rule" "api_http" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
source_security_group_id = aws_security_group.alb.id
security_group_id = aws_security_group.api.id
description = "HTTP from ALB"
}
resource "aws_security_group_rule" "api_health_check" {
type = "ingress"
from_port = 8081
to_port = 8081
protocol = "tcp"
source_security_group_id = aws_security_group.alb.id
security_group_id = aws_security_group.api.id
description = "Health check endpoint from ALB"
}Your API needs port 8080 for requests and 8081 for health checks. Nothing else. You allow nothing else. If a security scanner finds an open port you didn't expect, the rules tell you immediately why it exists—or that it shouldn't.
The same applies to egress. Many teams allow outbound traffic to any destination on any port. That's often necessary (your application might call third-party APIs), but be explicit about what's needed.
resource "aws_security_group_rule" "api_egress_to_db" {
type = "egress"
from_port = 5432
to_port = 5432
protocol = "tcp"
destination_security_group_id = aws_security_group.database.id
security_group_id = aws_security_group.api.id
description = "PostgreSQL queries"
}
resource "aws_security_group_rule" "api_egress_https" {
type = "egress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.api.id
description = "HTTPS to external APIs (Stripe, SendGrid, etc.)"
}Now you have a record of what your application needs to reach. If an attacker compromises an instance, they're constrained by these rules. They can't exfiltrate data to arbitrary hosts on port 22 or 3389.
Organizing Rules in Modules
As your infrastructure grows, keeping all security group rules in a single file becomes unwieldy. A better pattern: one module per tier, containing the security group and all its rules.
module "web_tier" {
source = "./modules/web-tier"
vpc_id = aws_vpc.main.id
alb_cidr_blocks = ["10.0.0.0/24"]
tags = {
Environment = "production"
}
}
module "api_tier" {
source = "./modules/api-tier"
vpc_id = aws_vpc.main.id
web_tier_sg_id = module.web_tier.security_group_id
database_tier_sg_id = module.database_tier.security_group_id
tags = {
Environment = "production"
}
}Each module owns its security group and rules. Dependencies flow through outputs. When you need to audit the API tier's network permissions, you look in modules/api-tier and find everything in one place.
Common Mistakes
We've seen these patterns cause real incidents in production:
- Mixing security groups across tiers. A single security group serving web, API, and cache layers. Rules compound, ownership becomes unclear, and changes break things unexpectedly.
- Using hardcoded IP ranges inside the VPC. Then the infrastructure changes and traffic silently fails. Or someone troubleshooting adds a rule for a temporary IP, forgets it, and leaves a security hole.
- Inline rules with no description. Three months later, no one knows why a port is open. Is it still needed? Can we remove it?
- Allowing 0.0.0.0/0 for internal services. A database open to the internet "for now" becomes permanent. Internal APIs accepting requests from anywhere invite trouble.
- Not testing security group changes before merging. It's easy to introduce a rule that blocks legitimate traffic. Test in staging. Verify that services can still communicate.
- Forgetting egress rules. Teams define ingress carefully but leave egress wide open. An attacker with code execution can exfiltrate data freely.
Key Takeaways
- Define one security group per architectural role or tier. This keeps rules focused and maintainable.
- Inside your VPC, reference security groups by ID, not IP addresses. This makes rules stable and intent-clear.
- Use separate
aws_security_group_ruleresources for each rule. It improves readability, enables modular organization, and makes pull requests easier to review. - Apply least privilege: open only the ports and protocols your application needs. Restrict sources and destinations to specific security groups or CIDR blocks.
- Document every rule with a description. Future you will thank you.
- Organize large security group collections into modules, one per tier. This scales well and keeps dependencies explicit.
- Test security group changes in staging before production. Verify that services can still reach each other and that external traffic is blocked as intended.






Comments
Loading comments…