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
- You need defense-in-depth below the security-group layer — e.g. a regulated workload where a subnet must hard-deny a known-bad CIDR or block a port range regardless of what any SG allows.
- You want to lock down a data-tier subnet so only the app tier’s CIDR can reach Postgres on 5432, and nothing can initiate outbound to the internet.
- You’re replacing the permissive default NACL (which allows all traffic) with explicit, auditable rules per subnet tier.
- You need a coarse, fast, stateless block — NACLs are evaluated before security groups and are cheap to deny large CIDR blocks at the subnet edge.
- Skip it when stateful, per-instance, allow-only rules are sufficient — that is the job of a security group, and managing return traffic by hand in a NACL is not worth it for fine-grained app rules.
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 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/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
- Always pair inbound allows with ephemeral egress allows (commonly
1024-65535TCP) — NACLs are stateless, so the return packet must be explicitly permitted or the connection silently hangs. This is the number-one NACL footgun. - Number rules in gaps of 100 (100, 200, 300) so you can insert a rule between two existing ones later without renumbering; reserve very low numbers (e.g. 10-50) for hard
denyblocks that must win. - Put broad
denyrules at low numbers and specificallowrules above them — evaluation stops at the first match, so a deny at rule 10 takes precedence over an allow at rule 100. - Never leave the default NACL in service for production subnets — it allows all traffic. Manage an explicit NACL per tier so the security posture is auditable and intentional.
- Use NACLs for coarse, stateless, subnet-wide blocks; use security groups for fine-grained, stateful, per-instance allows — don’t try to replicate every SG rule in a NACL, and remember the ~20 rules-per-direction soft limit (raisable to 40, with latency cost).
- Tag consistently (
Environment,Tier,ManagedBy) and keep one NACL per subnet tier so blast radius, cost attribution, and change review all map cleanly to a single Terraform module instance.