IaC AWS

Terraform Module: AWS Security Group — declarative, least-privilege firewall rules without churn

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_security_group using dedicated rule resources, CIDR and SG-referencing ingress/egress, validated ports, and lifecycle-safe naming for zero-downtime updates. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "security_group" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-group?ref=v1.0.0"

  name   = "..."  # Logical name; used as `name_prefix` and the `Name` tag.
  vpc_id = "..."  # VPC ID (`vpc-...`) the group belongs to.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS Security Group is a stateful virtual firewall attached to ENIs (EC2 instances, RDS, ALB nodes, Lambda-in-VPC, etc.). It governs traffic with ingress and egress rules; because it is stateful, return traffic for an allowed flow is implicitly permitted, so you only describe one direction. A group lives in a single VPC and is referenced by other resources via its ID.

Wrapping aws_security_group in a module solves three recurring pain points. First, inline ingress/egress blocks force Terraform to replace the entire rule set on any change, which causes momentary connection drops; this module uses the dedicated aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources so each rule is managed independently and updated in place. Second, AWS’s default behaviour of adding an implicit “allow all egress” rule is silently insecure — the module makes egress explicit and opt-in. Third, naming and tagging drift across teams; the module standardises name_prefix, description, and a merged tag set so every group is consistent and auditable.

When to use it

If you only need the loose VPC default group, or a fully AWS-managed rule set (e.g. a VPC endpoint’s auto-created group), you do not need this.

Module structure

terraform-module-aws-security-group/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # Merge caller tags with a Name tag derived from the prefix.
  tags = merge(
    var.tags,
    {
      Name      = var.name
      ManagedBy = "terraform"
    },
  )
}

resource "aws_security_group" "this" {
  # Using name_prefix lets create_before_destroy work cleanly: a new SG is
  # provisioned before the old one is removed, avoiding "in use" errors.
  name_prefix = "${var.name}-"
  description = var.description
  vpc_id      = var.vpc_id

  # Explicitly DO NOT manage inline rules here; rules are dedicated resources
  # below so each one can be changed in place without recreating the group.
  revoke_rules_on_delete = var.revoke_rules_on_delete

  tags = local.tags

  lifecycle {
    create_before_destroy = true
  }
}

# ---------------------------------------------------------------------------
# Ingress rules — CIDR based
# ---------------------------------------------------------------------------
resource "aws_vpc_security_group_ingress_rule" "cidr" {
  for_each = { for r in var.ingress_cidr_rules : r.key => r }

  security_group_id = aws_security_group.this.id
  description       = each.value.description
  ip_protocol       = each.value.ip_protocol
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  cidr_ipv4         = each.value.cidr_ipv4
  cidr_ipv6         = each.value.cidr_ipv6

  tags = merge(local.tags, { Name = "${var.name}-ingress-${each.key}" })
}

# ---------------------------------------------------------------------------
# Ingress rules — referencing another security group (tiered access)
# ---------------------------------------------------------------------------
resource "aws_vpc_security_group_ingress_rule" "sg" {
  for_each = { for r in var.ingress_sg_rules : r.key => r }

  security_group_id            = aws_security_group.this.id
  description                  = each.value.description
  ip_protocol                  = each.value.ip_protocol
  from_port                    = each.value.from_port
  to_port                      = each.value.to_port
  referenced_security_group_id = each.value.referenced_security_group_id

  tags = merge(local.tags, { Name = "${var.name}-ingress-sg-${each.key}" })
}

# ---------------------------------------------------------------------------
# Egress rules — explicit; nothing is allowed out unless declared
# ---------------------------------------------------------------------------
resource "aws_vpc_security_group_egress_rule" "cidr" {
  for_each = { for r in var.egress_cidr_rules : r.key => r }

  security_group_id = aws_security_group.this.id
  description       = each.value.description
  ip_protocol       = each.value.ip_protocol
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  cidr_ipv4         = each.value.cidr_ipv4
  cidr_ipv6         = each.value.cidr_ipv6

  tags = merge(local.tags, { Name = "${var.name}-egress-${each.key}" })
}
# variables.tf

variable "name" {
  description = "Logical name for the security group; used as a name_prefix and Name tag."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9._-]{1,200}$", var.name))
    error_message = "name must be 1-200 chars of letters, digits, dots, hyphens or underscores."
  }
}

variable "description" {
  description = "Human-readable description of the security group's purpose."
  type        = string
  default     = "Managed by Terraform"

  validation {
    condition     = length(var.description) > 0 && length(var.description) <= 255
    error_message = "description must be between 1 and 255 characters (AWS limit)."
  }
}

variable "vpc_id" {
  description = "ID of the VPC in which to create the security group."
  type        = string

  validation {
    condition     = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
    error_message = "vpc_id must be a valid VPC ID, e.g. vpc-0a1b2c3d4e5f6a7b8."
  }
}

variable "revoke_rules_on_delete" {
  description = "Revoke all rules before deleting the group; useful to break cyclic SG references on destroy."
  type        = bool
  default     = false
}

variable "ingress_cidr_rules" {
  description = "Ingress rules sourced from IPv4/IPv6 CIDR blocks. Provide exactly one of cidr_ipv4 or cidr_ipv6 per rule. Use ip_protocol = \"-1\" with null ports for all traffic."
  type = list(object({
    key         = string
    description = string
    ip_protocol = string
    from_port   = optional(number)
    to_port     = optional(number)
    cidr_ipv4   = optional(string)
    cidr_ipv6   = optional(string)
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.ingress_cidr_rules :
      (r.cidr_ipv4 != null) != (r.cidr_ipv6 != null)
    ])
    error_message = "Each ingress_cidr_rules entry must set exactly one of cidr_ipv4 or cidr_ipv6."
  }

  validation {
    # Block the classic foot-gun: SSH/RDP open to the entire internet.
    condition = alltrue([
      for r in var.ingress_cidr_rules :
      !(try(r.cidr_ipv4, "") == "0.0.0.0/0" && try(r.from_port, -1) != null &&
        contains([22, 3389], try(r.from_port, -1)))
    ])
    error_message = "Refusing to open port 22 or 3389 to 0.0.0.0/0; use an SG reference or a restricted CIDR."
  }
}

variable "ingress_sg_rules" {
  description = "Ingress rules that allow traffic from another security group (preferred for tiered apps)."
  type = list(object({
    key                          = string
    description                  = string
    ip_protocol                  = string
    from_port                    = optional(number)
    to_port                      = optional(number)
    referenced_security_group_id = string
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.ingress_sg_rules :
      can(regex("^sg-[0-9a-f]{8,17}$", r.referenced_security_group_id))
    ])
    error_message = "Each ingress_sg_rules entry needs a valid referenced_security_group_id (sg-...)."
  }
}

variable "egress_cidr_rules" {
  description = "Egress rules to IPv4/IPv6 CIDR blocks. Empty list means the group allows NO outbound traffic."
  type = list(object({
    key         = string
    description = string
    ip_protocol = string
    from_port   = optional(number)
    to_port     = optional(number)
    cidr_ipv4   = optional(string)
    cidr_ipv6   = optional(string)
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.egress_cidr_rules :
      (r.cidr_ipv4 != null) != (r.cidr_ipv6 != null)
    ])
    error_message = "Each egress_cidr_rules entry must set exactly one of cidr_ipv4 or cidr_ipv6."
  }
}

variable "tags" {
  description = "Tags applied to the security group and all of its rules."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "id" {
  description = "The ID of the security group (reference this from ENIs/instances/RDS/ALB)."
  value       = aws_security_group.this.id
}

output "arn" {
  description = "The ARN of the security group."
  value       = aws_security_group.this.arn
}

output "name" {
  description = "The generated name of the security group (from name_prefix)."
  value       = aws_security_group.this.name
}

output "vpc_id" {
  description = "The VPC ID in which the security group was created."
  value       = aws_security_group.this.vpc_id
}

output "ingress_rule_ids" {
  description = "Map of rule key to security_group_rule_id for all ingress rules (CIDR and SG-referenced)."
  value = merge(
    { for k, r in aws_vpc_security_group_ingress_rule.cidr : k => r.security_group_rule_id },
    { for k, r in aws_vpc_security_group_ingress_rule.sg : k => r.security_group_rule_id },
  )
}

output "egress_rule_ids" {
  description = "Map of rule key to security_group_rule_id for all egress rules."
  value       = { for k, r in aws_vpc_security_group_egress_rule.cidr : k => r.security_group_rule_id }
}

How to use it

This example creates an application-tier security group that accepts traffic only from an upstream ALB security group and from the corporate office CIDR, and is allowed to reach an RDS database group plus HTTPS for package downloads.

module "app_sg" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-group?ref=v1.0.0"

  name        = "orders-app"
  description = "Orders service application tier"
  vpc_id      = var.vpc_id

  ingress_sg_rules = [
    {
      key                          = "from-alb"
      description                  = "App traffic from the public ALB"
      ip_protocol                  = "tcp"
      from_port                    = 8080
      to_port                      = 8080
      referenced_security_group_id = module.alb_sg.id
    },
  ]

  ingress_cidr_rules = [
    {
      key         = "office-ssh"
      description = "Break-glass SSH from the office"
      ip_protocol = "tcp"
      from_port   = 22
      to_port     = 22
      cidr_ipv4   = "203.0.113.0/24"
    },
  ]

  egress_cidr_rules = [
    {
      key         = "https-out"
      description = "Outbound HTTPS for package + API calls"
      ip_protocol = "tcp"
      from_port   = 443
      to_port     = 443
      cidr_ipv4   = "0.0.0.0/0"
    },
  ]

  tags = {
    Environment = "prod"
    Team        = "payments"
  }
}

# Downstream: attach the SG's id output to an EC2 instance.
resource "aws_instance" "app" {
  ami                    = var.app_ami
  instance_type          = "t3.medium"
  subnet_id              = var.private_subnet_id
  vpc_security_group_ids = [module.app_sg.id]

  tags = { Name = "orders-app" }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/security_group/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-security-group?ref=v1.0.0"
}

inputs = {
  name = "..."
  vpc_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/security_group && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Logical name; used as name_prefix and the Name tag.
description string "Managed by Terraform" No Purpose of the group (1–255 chars).
vpc_id string Yes VPC ID (vpc-...) the group belongs to.
revoke_rules_on_delete bool false No Revoke all rules before delete to break cyclic SG references.
ingress_cidr_rules list(object) [] No Ingress from CIDR blocks; exactly one of cidr_ipv4/cidr_ipv6 per rule.
ingress_sg_rules list(object) [] No Ingress that references another SG (tiered access).
egress_cidr_rules list(object) [] No Egress to CIDR blocks; empty means NO outbound allowed.
tags map(string) {} No Tags applied to the group and every rule.

Outputs

Name Description
id The security group ID — reference this from instances, RDS, ALB, ENIs.
arn The security group ARN.
name The generated group name (from name_prefix).
vpc_id The VPC ID the group was created in.
ingress_rule_ids Map of rule key → security_group_rule_id for all ingress rules.
egress_rule_ids Map of rule key → security_group_rule_id for all egress rules.

Enterprise scenario

A fintech runs a three-tier payments platform (public ALB → ECS app tier → Aurora) across dev, staging, and prod accounts. Each tier instantiates this module once per environment, wiring ingress purely by SG reference (module.alb_sg.id → app, module.app_sg.id → database) so no application CIDR is ever hard-coded and the topology survives subnet re-IPing. The built-in validation that rejects 0.0.0.0/0 on ports 22 and 3389 is enforced in CI via terraform plan, which has already caught two pull requests that would have exposed RDP to the internet. Because rules are dedicated resources, the platform team adds a new partner API egress rule during business hours with zero connection resets on the live fleet.

Best practices

TerraformAWSSecurity GroupModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading