Security groups in AWS are stateful and define the authorized ingress and egress for instances in a VPC. In Terraform, the way those rules are expressed determines how predictable plans are, how clean diffs remain, and how safely teams can evolve networking as infrastructure grows. The fundamental tension is between defining rules inline inside an aws_security_group resource and defining rules as separate resources that are managed independently.
Inline rules are rules defined with the aws_security_group terraform resource. Standalone rules are rules defined separately using the aws_security_group_rule terraform resource. These two approaches ultimately correspond to the same thing on AWS, but Terraform treats them differently during planning and apply. Terraform advises that inline rules should not be used in conjunction with standalone rules for the same security group.
Choosing one model per security group and sticking with it avoids the common failure mode where Terraform attempts to reconcile two different ownership models for the same set of rules and produces unexpected replacements.
Inline Rules vs Standalone Rules
The basic approach for a security group with multiple rules is to put everything inline.
```
resource "awssecuritygroup" "webapp" {
nameprefix = "web-app-"
description = "Security group for web application servers"
vpcid = awsvpc.main.id
ingress {
description = "HTTP from internet"
fromport = 80
toport = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS from internet"
fromport = 443
toport = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH from office"
fromport = 22
toport = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
ingress {
description = "App port from ALB"
fromport = 8080
toport = 8080
protocol = "tcp"
securitygroups = [awssecurity_group.alb.id]
}
egress {
description = "Allow all outbound"
fromport = 0
toport = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "web-app-sg"
}
}
```
Inline definition keeps the entire security group visible in one place. For simple security groups with a handful of rules, inline rules work fine. The trade-off is loss of granularity in diffs and the inability to reuse a single rule across multiple groups without duplication.
Standalone rule resources provide that granularity. A typical plan for a standalone ingress rule looks like:
resource "aws_security_group_rule" "allow_ssh_from_vpc" {
cidr_blocks = [
"172.31.0.0/16",
]
description = "Allow SSH from VPC"
from_port = 22
protocol = "tcp"
security_group_id = "sg-0d7749dea35961abc"
self = false
source_security_group_id = (known after apply)
to_port = 22
type = "ingress"
}
Plan output:
```
awssecuritygrouprule.allowsshfromvpc will be created
- resource "awssecuritygrouprule" "allowsshfromvpc" {
- cidr_blocks = [ "172.31.0.0/16", ]
- description = "Allow SSH from VPC"
- from_port = 22
- id = (known after apply)
- protocol = "tcp"
- securitygroupid = "sg-0d7749dea35961abc"
- securitygrouprule_id = (known after apply)
- self = false
- sourcesecuritygroup_id = (known after apply)
- to_port = 22
- type = "ingress"
}
Plan: 1 to add, 0 to change, 0 to destroy.
```
After terraform apply, the rule is attached and the security group is attached to EC2 instances as expected. Verification in the AWS console confirms attachment.
Standalone rule resources also allow dynamic targeting of multiple groups. When a security group is created outside Terraform and later referenced, a data source provides the identifier.
Referring to a single security group uses the aws_security_group data resource:
```
variable "securitygroupid" {
type = string
default = "sg-0d7749dea35961abc"
}
data "awssecuritygroup" "selected" {
id = var.securitygroupid
}
```
The security group in this example is created outside Terraform with the name security-group-managed-outside-terraform and a tag:
{
"managed-by" = "aws-console"
}
An inbound rule can then be added using the ID from the data resource. The data lookup is redundant when the ID is already known, but it documents the dependency.
For multiple groups managed outside Terraform, the aws_security_groups data source is used with tags:
data "aws_security_groups" "security_groups_managed_by_aws_console" {
tags = {
"managed-by" = "aws-console"
}
}
Previously, aws_security_group data source refers to a single security group and now aws_security_groups data source refers to multiple security groups.
A for_each pattern over the resulting IDs enables a single rule definition to be applied to all matched groups. The plan reflects deletion of the previous rule and creation of two new rules along with the output security_group_ids.
```
awssecuritygrouprule.allowsshfromvpc will be destroyed
(because resource uses count or for_each)
- resource "awssecuritygrouprule" "allowsshfromvpc" {
- cidr_blocks = [ "172.31.0.0/16", ] -> null
- description = "Allow SSH from VPC" -> null
- from_port = 22 -> null
- id = "sgrule-2807208966" -> null
- protocol = "tcp" -> null
- securitygroupid = "sg-0d7749dea35961abc" -> null
- securitygrouprule_id = "sgr-05e047df70ca6e3e2" -> null
- self = false -> null
- to_port = 22 -> null
- type = "ingress" -> null
}
awssecuritygrouprule.allowsshfromvpc["sg-0438324f09abb192e"] will be created
- resource "awssecuritygrouprule" "allowsshfromvpc" {
- cidr_blocks = [ "172.31.0.0/16", ]
- description = "Allow SSH from VPC"
- from_port = 22
- id = (known after apply)
- protocol = "tcp"
- securitygroupid = "sg-0438324f09abb192e"
- ...
- type = "ingress"
}
```
Managing multiple security groups with for_each lets one rule definition fan out to a set of groups selected by tag, without referencing each group individually.
Modern Rule Resources and Community Recommendation
There are a bunch of ways that you can handle AWS Security Group rules in Terraform, including in-line rules with the aws_security_group resource or the old aws_security_group_rule resource, but the Terraform community recommends using aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule as a best practice.
The older aws_security_group_rule resource is still valid, but the VPC-scoped rule resources align with the AWS API model and avoid some implicit dependencies that caused drift in earlier Terraform versions.
The organization problem becomes acute when applications are split across files. Suppose you have an application with all of its resources and its security group defined in application.tf and you have a database with all of its resources and its security group defined in database.tf. A rule which allows egress traffic from the app to the database, and a rule which allows ingress traffic to the database from application, can be placed in the wrong file. Six months later when a change is needed, the rule is harder to find. With dozens of related rules, giving each rule a unique name that will be remembered later becomes annoying.
A pattern that reduces this ambiguity is to place rules in a single logical location per relationship. When you follow this pattern, there is really only one logical place in your configuration that a given rule could possibly be placed, so it always gets placed correctly.
Trade-offs and Common Pitfalls
Real-world applications need security groups with dozens of rules - different ports for different services, varying CIDR ranges, references to other security groups, and so on.
The choice between inline and standalone has operational consequences.
| Aspect | Inline Rules | Standalone Rules |
|---|---|---|
| Definition location | Inside aws_security_group block |
Separate aws_security_group_rule, aws_vpc_security_group_ingress_rule, aws_vpc_security_group_egress_rule resources |
| Diff granularity | Whole security group changes on any rule edit | Individual rule changes are isolated |
| Reuse across groups | Difficult, requires duplication | Easy with for_each over group IDs |
| Terraform recommendation | Do not mix with standalone rules | Recommended for complex sets |
For anything more complex, separate rule resources with for_each give you granular control and cleaner diffs. Wrapping everything in a module keeps your codebase consistent as it grows. The key is picking one approach per security group and sticking with it.
Several pitfalls recur in production:
- Mixing rule types. That includes
aws_security_group_rule,aws_vpc_security_group_ingress_rule, andaws_vpc_security_group_egress_rule. Terraform will fight itself trying to manage them. - Forgetting
create_before_destroy. Without this lifecycle rule, Terraform may try to delete the security group before creating a replacement, which fails if other resources still reference it. - Overly permissive egress. Many configurations use
0.0.0.0/0for all outbound. Consider restricting this to only the ports and destinations your application actually needs. - Not using
name_prefix. Usingnameinstead ofname_prefixpreventscreate_before_destroyfrom working because security group names must be unique.
Working With Existing Security Groups
How to use existing security groups with Terraform is a common onboarding scenario.
In this workflow, a security group is created outside of Terraform and then an inbound SSH rule is added to it using Terraform.
Referring to a single security group:
- Create a security group with the name
security-group-managed-outside-terraform. - Tag it with
"managed-by" = "aws-console". - To refer to it, use the
aws_security_groupdata resource and provide the security group ID inmain.tf.
The data resource provides the ID for subsequent rule resources without recreating the group. After applying, check the AWS console to verify if the security group was attached successfully.
When multiple groups are managed outside Terraform, the aws_security_groups data source with the same tag allows bulk selection and rule application via for_each.
Conclusion
Security group rule management in Terraform is less about syntax and more about ownership and predictability. Inline rules provide readability for small, static groups and keep the definition co-located with the group. Standalone rule resources, and the newer aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources, provide isolation, reuse, and granular plans.
The operational sweet spot is to define simple groups inline and migrate to standalone rule resources as rule count and cross-team dependencies grow. Using for_each over data-sourced security groups enables consistent application of shared rules without manual enumeration. Applying name_prefix and a create_before_destroy lifecycle guard prevents replacement failures. Restricting egress from 0.0.0.0/0 to required destinations reduces blast radius.
Choosing one model per security group and enforcing it through modules avoids the most painful class of Terraform drift: rules that appear managed in two places at once. When each relationship has a single canonical location in the codebase, future changes are found quickly and applied safely.