IaC AWS

Terraform Module: AWS Network ACL — Subnet-Level Stateless Firewalling as Code

Quick take — A reusable Terraform module for aws_network_acl on hashicorp/aws ~> 5.0: numbered stateless ingress/egress rules, subnet associations, and validated CIDR inputs for defense-in-depth at the subnet boundary. 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 "network_acl" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-network-acl?ref=v1.0.0"

  name   = "..."  # Base name for the NACL and Name tag (suffixed with `-na…
  vpc_id = "..."  # ID of the VPC the network ACL belongs to.
}

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

What this module is

A Network ACL (NACL) is the stateless packet filter that sits at the boundary of a VPC subnet. Unlike a security group — which is stateful, attaches to an ENI, and only supports allow rules — a NACL evaluates traffic as it crosses the subnet edge, processes rules in ascending numbered order, stops at the first match, and must explicitly allow both directions of a flow (because return traffic is not automatically permitted). That last point is the single biggest source of “why is my connection hanging” tickets: if you allow inbound 443 but forget the outbound ephemeral port range (typically 1024-65535), the SYN-ACK never leaves.

This module wraps aws_network_acl together with its aws_network_acl_rule and aws_network_acl_association companions into one var-driven unit. Hand-writing NACLs gets painful fast: rule numbers collide, the implicit * deny is easy to forget, and a default NACL silently allows everything. Wrapping it in a module gives you consistent rule numbering, enforced tagging, validated CIDRs, and a single place to express a subnet-tier security posture (public / app / data) that you can stamp out identically across every VPC and environment.

When to use it

Module structure

terraform-module-aws-network-acl/
├── 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 {
  name_prefix = "${var.name}-nacl"

  # Normalise ingress/egress lists into a single keyed map so each rule
  # becomes a discrete aws_network_acl_rule addressed by a stable key.
  rules = merge(
    {
      for r in var.ingress_rules :
      "ingress-${r.rule_number}" => merge(r, { egress = false })
    },
    {
      for r in var.egress_rules :
      "egress-${r.rule_number}" => merge(r, { egress = true })
    }
  )
}

resource "aws_network_acl" "this" {
  vpc_id = var.vpc_id

  tags = merge(
    var.tags,
    {
      Name = local.name_prefix
    }
  )
}

resource "aws_network_acl_rule" "this" {
  for_each = local.rules

  network_acl_id = aws_network_acl.this.id
  rule_number    = each.value.rule_number
  egress         = each.value.egress
  protocol       = each.value.protocol
  rule_action    = each.value.rule_action
  cidr_block     = each.value.cidr_block

  # from_port/to_port are ignored by AWS when protocol is icmp (1) or all (-1).
  from_port = each.value.from_port
  to_port   = each.value.to_port

  # Required only when protocol is icmp/icmpv6.
  icmp_type = each.value.icmp_type
  icmp_code = each.value.icmp_code
}

resource "aws_network_acl_association" "this" {
  for_each = toset(var.subnet_ids)

  network_acl_id = aws_network_acl.this.id
  subnet_id      = each.value
}
# variables.tf
variable "name" {
  description = "Base name used for the NACL and the Name tag (suffixed with -nacl)."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{1,48}$", var.name))
    error_message = "name must be 1-48 chars: lowercase letters, digits and hyphens only."
  }
}

variable "vpc_id" {
  description = "ID of the VPC the network ACL belongs to."
  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 "subnet_ids" {
  description = "Subnet IDs to associate with this NACL. Each subnet can have exactly one NACL; associating here detaches it from its previous NACL."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for s in var.subnet_ids : can(regex("^subnet-[0-9a-f]{8,17}$", s))])
    error_message = "Every entry in subnet_ids must be a valid subnet ID (subnet-...)."
  }
}

variable "ingress_rules" {
  description = "List of inbound rules. Lower rule_number wins; first match stops evaluation. Use protocol -1 for all, 6 tcp, 17 udp, 1 icmp."
  type = list(object({
    rule_number = number
    protocol    = string
    rule_action = string
    cidr_block  = string
    from_port   = optional(number)
    to_port     = optional(number)
    icmp_type   = optional(number)
    icmp_code   = optional(number)
  }))
  default = []

  validation {
    condition     = alltrue([for r in var.ingress_rules : r.rule_number >= 1 && r.rule_number <= 32766])
    error_message = "ingress rule_number must be between 1 and 32766 (32767 and 'ALL' are reserved)."
  }

  validation {
    condition     = alltrue([for r in var.ingress_rules : contains(["allow", "deny"], lower(r.rule_action))])
    error_message = "ingress rule_action must be either 'allow' or 'deny'."
  }

  validation {
    condition     = length(distinct([for r in var.ingress_rules : r.rule_number])) == length(var.ingress_rules)
    error_message = "ingress rule_number values must be unique."
  }
}

variable "egress_rules" {
  description = "List of outbound rules. Remember NACLs are stateless: allow the ephemeral return range (e.g. 1024-65535) for replies to inbound traffic."
  type = list(object({
    rule_number = number
    protocol    = string
    rule_action = string
    cidr_block  = string
    from_port   = optional(number)
    to_port     = optional(number)
    icmp_type   = optional(number)
    icmp_code   = optional(number)
  }))
  default = []

  validation {
    condition     = alltrue([for r in var.egress_rules : r.rule_number >= 1 && r.rule_number <= 32766])
    error_message = "egress rule_number must be between 1 and 32766 (32767 and 'ALL' are reserved)."
  }

  validation {
    condition     = alltrue([for r in var.egress_rules : contains(["allow", "deny"], lower(r.rule_action))])
    error_message = "egress rule_action must be either 'allow' or 'deny'."
  }

  validation {
    condition     = length(distinct([for r in var.egress_rules : r.rule_number])) == length(var.egress_rules)
    error_message = "egress rule_number values must be unique."
  }
}

variable "tags" {
  description = "Tags applied to the network ACL."
  type        = map(string)
  default     = {}
}
# outputs.tf
output "id" {
  description = "ID of the network ACL."
  value       = aws_network_acl.this.id
}

output "arn" {
  description = "ARN of the network ACL."
  value       = aws_network_acl.this.arn
}

output "name" {
  description = "Computed Name tag of the network ACL."
  value       = local.name_prefix
}

output "vpc_id" {
  description = "VPC ID the network ACL is attached to."
  value       = aws_network_acl.this.vpc_id
}

output "owner_id" {
  description = "AWS account ID that owns the network ACL."
  value       = aws_network_acl.this.owner_id
}

output "associated_subnet_ids" {
  description = "Subnet IDs associated with this network ACL."
  value       = [for a in aws_network_acl_association.this : a.subnet_id]
}

output "rule_numbers" {
  description = "Map of rule key to rule_number for the managed rules."
  value       = { for k, r in aws_network_acl_rule.this : k => r.rule_number }
}

How to use it

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

  name       = "prod-data"
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.data_a.id, aws_subnet.data_b.id]

  ingress_rules = [
    # Allow Postgres only from the app-tier CIDR.
    {
      rule_number = 100
      protocol    = "6" # tcp
      rule_action = "allow"
      cidr_block  = "10.20.16.0/20" # app tier
      from_port   = 5432
      to_port     = 5432
    },
    # Allow ephemeral return traffic for outbound flows the data tier initiates.
    {
      rule_number = 200
      protocol    = "6"
      rule_action = "allow"
      cidr_block  = "10.20.16.0/20"
      from_port   = 1024
      to_port     = 65535
    },
  ]

  egress_rules = [
    # Replies to the app tier on ephemeral ports.
    {
      rule_number = 100
      protocol    = "6"
      rule_action = "allow"
      cidr_block  = "10.20.16.0/20"
      from_port   = 1024
      to_port     = 65535
    },
  ]

  tags = {
    Environment = "production"
    Tier        = "data"
    ManagedBy   = "Terraform"
  }
}

# Downstream reference: feed the NACL ID into a config/audit record,
# or use it to assert posture in a compliance check.
resource "aws_ssm_parameter" "data_nacl_id" {
  name  = "/network/prod/data-tier/nacl-id"
  type  = "String"
  value = module.data_tier_nacl.id
}

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/network_acl/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

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

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

cd live/prod/network_acl && 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 n/a yes Base name for the NACL and Name tag (suffixed with -nacl); 1-48 lowercase/digit/hyphen chars.
vpc_id string n/a yes ID of the VPC the network ACL belongs to.
subnet_ids list(string) [] no Subnet IDs to associate; each subnet has exactly one NACL, so associating detaches it from its prior NACL.
ingress_rules list(object) [] no Inbound rules: rule_number, protocol, rule_action, cidr_block, optional from_port/to_port/icmp_type/icmp_code.
egress_rules list(object) [] no Outbound rules (same shape as ingress). Remember to allow ephemeral return ports — NACLs are stateless.
tags map(string) {} no Tags applied to the network ACL.

Outputs

Name Description
id ID of the network ACL.
arn ARN of the network ACL.
name Computed Name tag of the network ACL.
vpc_id VPC ID the network ACL is attached to.
owner_id AWS account ID that owns the network ACL.
associated_subnet_ids Subnet IDs associated with this network ACL.
rule_numbers Map of rule key to rule_number for the managed rules.

Enterprise scenario

A fintech running a three-tier VPC per environment uses this module to enforce a PCI-DSS network-segmentation control: the data-tier subnets get a NACL that allows inbound 5432 only from the app-tier CIDR and denies all internet egress, while a low-numbered deny rule (rule 10) hard-blocks a threat-intel CIDR feed regardless of any security-group allow. Because the module is stamped identically across dev, staging, and prod from the same Git tag, the QSA auditor can verify that the subnet-boundary posture is provably consistent, and a drift-detection pipeline flags any out-of-band rule change as a Terraform plan diff.

Best practices

TerraformAWSNetwork ACLModuleIaC
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