Managing network access in AWS with Terraform requires a clear mental model for how security group rules are represented in HCL and how they map to AWS resources. Security groups are stateful for ingress and effectively stateful for egress, but the Terraform provider exposes several ways to define rules: inline with the security group resource, standalone with the rule resources, and through community modules that abstract the rule set. Understanding when each pattern applies, how to reference existing groups, and how to scale rule management across multiple groups is essential for maintainable infrastructure.
The Terraform AWS provider historically offered two primary mechanisms for adding rules to a security group. 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. Note that these are the two ways to add rules to a security group through Terraform, which ultimately correspond to the same thing on AWS. Terraform advises that inline rules should not be used in conjunction with standalone rules.
In practice this means a security group defined with aws_security_group can carry an ingress and egress block that creates rules atomically with the group, while aws_security_group_rule creates the rule resource independently after the group exists. The standalone approach enables fine-grained lifecycle control, for_each patterns, and rule-only changes without touching the group definition.
Resource Models and Plan Behavior
The aws_security_group_rule resource exposes a flat attribute set for a single rule. A typical ingress rule created by Terraform shows the following plan output:
+ resource "aws_security_group_rule" "allow_ssh_from_vpc" {
+ cidr_blocks = [
+ "172.31.0.0/16",
]
+ description = "Allow SSH from VPC"
+ from_port = 22
+ id = (known after apply)
+ protocol = "tcp"
+ security_group_id = "sg-0d7749dea35961abc"
+ security_group_rule_id = (known after apply)
+ self = false
+ source_security_group_id = (known after apply)
+ to_port = 22
+ type = "ingress"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Run the terraform apply command to deploy the changes.
When a rule is changed via for_each, Terraform shows destruction of the prior single instance and creation of indexed instances. The plan reflects the deletion of the previous rule and the 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"
- securitygrouprule_id = (known after apply)
- self = false
- sourcesecuritygroup_id = (known after apply)
- to_port = 22
type = "ingress"
}awssecuritygrouprule.allowsshfromvpc["sg-0d7749dea35961abc"] 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 =
```
This behavior illustrates the importance of stable identifiers when scaling rules across multiple security groups.
A comparison of the two native Terraform approaches is summarized below.
| Attribute | Inline with awssecuritygroup | Standalone with awssecuritygroup_rule |
|---|---|---|
| Definition | ingress / egress blocks inside resource |
Separate resource aws_security_group_rule |
| Lifecycle coupling | Tied to security group create/update/delete | Independent lifecycle |
| For_each support | Limited, group scoped | Full support per rule |
| Recommended by Terraform | Avoid mixing with standalone | Preferred for standalone management |
| Typical use | Simple groups with few rules | Large rule sets, cross-group references |
Working With Existing Security Groups
A common operational scenario is importing security groups created outside Terraform and adding rules via Terraform. The workflow starts by creating a security group with the name security-group-managed-outside-terraform. The tag applied in the console is:
{
"managed-by" = "aws-console"
}
To refer to the security-group-managed-outside-terraform security group, use the aws_security_group data resource and provide the security group ID in the main.tf file. Relevant changes are shown below:
variable "security_group_id" {
type = string
default = "sg-0d7749dea35961abc"
}
data "aws_security_group" "selected" {
id = var.security_group_id
}
Add an inbound rule to this security group that allows SSH from the default VPC. Note that we are using an ID from the data resource, which is kind of redundant as we already knew the ID and used it to fetch the security group.
The same pattern scales to multiple groups. Let us create another security group called security-group-managed-outside-terraform-2 using the AWS console with the tag shown below.
{
"managed-by" = "aws-console"
}
Instead of referring to each of these security groups individually, we can use the aws_security_groups data resource and provide the tag value to refer to all of them at once. Note: Previously, we used aws_security_group data source to refer to a single security group and now we are using aws_security_group(s) data source to refer to multiple security groups.
data "aws_security_groups" "security_groups_managed_by_aws_console" {
tags = {
"managed-by" = "aws-console"
}
}
Once the data source returns the matching group IDs, a rule resource can be instantiated with for_each over the set, enabling consistent SSH ingress across all console-managed groups without hardcoding IDs.
Verification follows the standard workflow. Check the AWS console to verify if the security group was attached successfully. Our security group is attached to our EC2 instance as expected.
Organizing Rules Across Applications
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. But when you are creating resources for each individual rule, it can sometimes be difficult to keep them organized.
For example, 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. And then suppose you need a rule which allows egress traffic from the app to the database, and you need a rule which allows ingress traffic to the database from application. It can be easy to place each rule in the “wrong” file and then six months later when you need to make a change you forgot which rule is in which file. Or if you have dozens of related rules in the same configuration, it can be annoying to give each rule a unique name that you’ll be able to remember later.
In this post I’ll show how I like to organize my SG rules to avoid those kind of problems. When you follow this pattern, there’s really only one logical place in your configuration that a given rule could possibly be placed, so it always gets placed correctly.
A practical convention is to co-locate rules with the resource they protect, or centralize all cross-service rules in a dedicated networking file. Naming conventions that encode direction, source, destination, and port reduce ambiguity. Using descriptive description fields on each rule, such as "Allow SSH from VPC", ensures the AWS console remains readable.
Module Based Approaches
Community modules abstract rule authoring into higher-level constructs.
The terraform-aws-sg module deploys an EC2 Security Group into specified VPC with ingress/egress rules generated from a 'policy document' in plain text format. This Terraform module deploys an EC2 Security Group into specified VPC with ingress/egress rules generated from a 'policy document' in plain text format.
From this:
IN TCP 80 Any_IPv4,Any_IPv6 - HTTP Inbound
IN TCP 443 0.0.0.0/0,::/0 - HTTPS Inbound
IN TCP 8005 {bastion_ip}/32 - Tomcat admin from Bastion
IN PING 0.0.0.0/0,::/0 - PING from Internet
OUT TCP 3306 {sg_db} - Outbound to MySql DB
OUT TCP 443 pl-02cd2c6b - DynamoDB Prefix List
to this:
Motivation for this module was to allow people that are not familiar with terraform (like Network and InfoSec guys) to be able to create/review Security Groups configurations without HCL in a way.
The module is useful when policy review needs to be done by teams that prefer firewall-like syntax over HCL.
The terraform-aws-modules/terraform-aws-security-group module provides a more Terraform-native interface.
module "security_group" {
source = "terraform-aws-modules/security-group/aws"
name = "example"
description = "Example security group"
vpc_id = "vpc-12345678"
ingress_rules = {
https = {
from_port = 443
ip_protocol = "tcp"
cidr_ipv4 = "10.0.0.0/16"
description = "HTTPS from internal"
}
self-all = {
ip_protocol = "-1"
referenced_security_group_id = "self"
description = "All traffic from members of this SG"
}
}
egress_rules = {
all = {
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
}
}
tags = {
Environment = "dev"
}
}
Each preset submodule under modules/ ships a curated set of ingress rules for a specific service (PostgreSQL, Consul, Cassandra, etc.). Use one when a security group serves a single service.
module "postgresql_security_group" {
source = "terraform-aws-modules/security-group/aws//modules/postgresql"
name = "postgresql"
description = "PostgreSQL access"
vpc_id = "vpc-12345678"
ingress_cidr_ipv4 = {
vpc = "10.0.0.0/16"
peer = "172.16.0.0/12"
}
}
Complete - Comprehensive example demonstrating the full module surface. To allow traffic between members of the security group created by this module, set referenced_security_group_id = "self" on the rule.
The module supports both CIDR and security group references, and abstracts the need to manage individual aws_security_group_rule resources.
Practical Implementation Patterns
A rule authoring workflow typically follows these steps:
- Identify the security group scope: single group, multiple groups by tag, or module managed group.
- Choose the resource model: inline for small static groups, standalone
aws_security_group_rulefor independent lifecycle, oraws_vpc_security_group_ingress_rule/aws_vpc_security_group_egress_rulefor newer provider patterns. - Use data sources to discover externally managed groups via tags.
- Apply for_each over discovered groups to create consistent rules.
- Centralize rule definitions per service boundary to avoid placement ambiguity.
The following table summarizes common rule parameters used in reference examples.
| Parameter | Example Value | Notes |
|---|---|---|
| securitygroupid | sg-0d7749dea35961abc | Target SG |
| type | ingress | Direction |
| protocol | tcp | Transport |
| from_port | 22 | Start port |
| to_port | 22 | End port |
| cidr_blocks | 172.31.0.0/16 | Source CIDR |
| description | Allow SSH from VPC | Human readable |
When managing multiple security groups with for_each, the resource address becomes aws_security_group_rule.allow_ssh_from_vpc["sg-0438324f09abb192e"] and aws_security_group_rule.allow_ssh_from_vpc["sg-0d7749dea35961abc"], which makes plan output explicit about which group receives which rule.
Conclusion
Terraform AWS security group rule management benefits from clear separation between group definition and rule definition. Inline rules are convenient for simple cases but should not be mixed with standalone rules. Standalone aws_security_group_rule resources enable for_each scaling across groups discovered via aws_security_groups data sources, and the newer aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources represent current community best practice.
Organizational discipline matters as much as syntax. Placing rules in a predictable location per service, using descriptive names and descriptions, and leveraging modules for common service patterns reduces drift and eases future changes. For teams that need policy review without HCL, plain-text policy modules provide an alternative authoring surface.
Existing security groups can be safely referenced with data sources, and tags such as managed-by = "aws-console" provide a reliable way to bind Terraform-managed rules to console-created groups. With these patterns, security group rules remain auditable, repeatable, and scalable across environments.