There 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. Here’s how I like to do it:

 1resource "aws_security_group" "app" {
 2  name   = "example-app"
 3  vpc_id = aws_vpc.default.id
 4}
 5
 6resource "aws_vpc_security_group_ingress_rule" "app" {
 7  for_each = { for rule in [
 8    {
 9      index_name  = "http"
10      cidr_ipv4   = "0.0.0.0/0"
11      from_port   = 80
12      to_port     = 80
13      description = "Allow HTTP traffic from anywhere"
14    },
15    {
16      index_name  = "https"
17      cidr_ipv4   = "0.0.0.0/0"
18      from_port   = 443
19      to_port     = 443
20      description = "Allow HTTPS traffic from anywhere"
21    },
22  ] : rule.index_name => rule }
23
24  cidr_ipv4                    = lookup(each.value, "cidr_ipv4", null)
25  from_port                    = lookup(each.value, "from_port", null)
26  ip_protocol                  = lookup(each.value, "ip_protocol", "tcp")
27  to_port                      = lookup(each.value, "to_port", null)
28  prefix_list_id               = lookup(each.value, "prefix_list_id", null)
29  referenced_security_group_id = lookup(each.value, "referenced_security_group_id", null)
30  description                  = lookup(each.value, "description", null)
31  security_group_id            = aws_security_group.app.id
32}
33
34resource "aws_vpc_security_group_egress_rule" "app" {
35  for_each = { for rule in [
36    {
37      index_name                   = "app_to_postgres"
38      referenced_security_group_id = aws_security_group.postgres.id
39      from_port                    = 5432
40      to_port                      = 5432
41      description                  = "Allow Postgres traffic to the database"
42    },
43  ] : rule.index_name => rule }
44
45  cidr_ipv4                    = lookup(each.value, "cidr_ipv4", null)
46  from_port                    = lookup(each.value, "from_port", null)
47  ip_protocol                  = lookup(each.value, "ip_protocol", "tcp")
48  to_port                      = lookup(each.value, "to_port", null)
49  prefix_list_id               = lookup(each.value, "prefix_list_id", null)
50  referenced_security_group_id = lookup(each.value, "referenced_security_group_id", null)
51  description                  = lookup(each.value, "description", null)
52  security_group_id            = aws_security_group.app.id
53}

By defining all the ingress rules within one single for_each block and all the egress rules within another single for_each block, it’s immediately visually clear to the user where any given rule should go. There’s no temptation to scatter the rules around within a file or put them over in some other file. There is only one resource for all the ingress rules, one resource for all the egress rules, and they both have the same name as the aws_security_group resource. And I can assign defaults (line 26) so that I don’t have to specify tcp for each rule. You could probably even use functions to enforce that a description for each rule must be present, although I’m not going to get into that here.