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
- You need a security group whose rules change over time (adding a new app port, rotating an office CIDR) without tearing down and recreating every rule and dropping live connections.
- You want to reference other security groups (e.g. “allow the ALB SG to reach the app SG on 8080”) rather than hard-coding CIDR ranges — the safest pattern for tiered architectures.
- You are standardising firewall definitions across many stacks (EC2, RDS, ECS, MSK) and want one validated, tagged, named-consistently building block.
- You want guard rails: rejecting
0.0.0.0/0on management ports, or enforcing that every rule carries a human-readable description for audit.
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 config — live/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 config — live/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
- Prefer SG references over CIDRs for internal flows.
referenced_security_group_idfollows instances as they autoscale and avoids brittle IP allow-lists; reserve CIDR rules for true edges (office, on-prem, NAT egress). - Keep egress explicit and tight. This module ships no default egress, unlike raw
aws_security_group. Open only the destinations and ports a workload genuinely needs (e.g. 443 to update endpoints), not0.0.0.0/0all-protocols. - Always set a description per rule. Auditors and on-call engineers rely on it; the module makes
descriptionmandatory so “why is this port open?” is answerable from the console alone. - Use the dedicated-rule pattern to avoid churn. Never mix this module with inline
ingress/egressblocks on the same group — doing so makes Terraform fight AWS and recreate the rule set on every apply. - Lean on
create_before_destroy+name_prefix. This lets you change immutable attributes or swap a group out without the dreadedDependencyViolation; keep thenameshort since AWS appends a random suffix. - Name and tag consistently for cost and ownership. Propagate
Environment/Teamtags through thetagsinput so Cost Explorer and access reviews can attribute every rule; security groups themselves are free, but untracked open ports are the real cost.