Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_networkfirewall_firewall that wires a firewall policy, stateless and Suricata stateful rule groups, per-subnet endpoints, and flow/alert logging in one block. 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_firewall" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-network-firewall?ref=v1.0.0"
name = "..." # Firewall name; base for policy, rule-group, and tag nam…
vpc_id = "..." # VPC the firewall inspects (validated as `vpc-…`).
subnet_ids = ["...", "..."] # Dedicated firewall subnets (one per AZ); each gets a fi…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Network Firewall is a managed, stateful, layer 3–7 network firewall for your VPC. Unlike a security group or NACL — which are stateless-ish ENI/subnet attributes — Network Firewall is a real inspection appliance: AWS deploys a fleet of firewall endpoints (backed by the Gateway Load Balancer engine) into subnets you dedicate to it, and you route traffic through those endpoints. It speaks Suricata, so you get domain-name filtering, TLS SNI inspection, IPS/IDS signature rules, and protocol-aware stateful matching — the things you would otherwise stand up a third-party NVA fleet to do.
The object model is layered, and that is exactly why a raw deployment is painful. The top resource, aws_networkfirewall_firewall, is mostly a placement decision — which VPC, which subnets get an endpoint — and it references a aws_networkfirewall_firewall_policy. The policy is the brain: it lists ordered stateless rule groups (5-tuple, fast-path allow/drop/forward) and stateful rule groups (Suricata signatures or domain lists, with strict or default action order), plus the default actions for packets that match nothing. The rule groups themselves — aws_networkfirewall_rule_group — are separate resources again, capacity-budgeted, and come in stateless and stateful flavours with completely different bodies. Get the three-layer reference chain or the stateful_engine_options rule order wrong and you either pass everything or blackhole the VPC.
Wrapping this in a module collapses the chain into typed inputs. You pass a map of subnet IDs (one per AZ), an optional Suricata rule string or domain allow/deny lists, and a logging destination; the module builds the rule groups, assembles the policy with correct rule-group priorities and a fail-open-or-closed default action you choose, attaches aws_networkfirewall_logging_configuration for FLOW and ALERT logs, and emits the per-AZ endpoint IDs your route tables need — the part you cannot get from a console click-through.
When to use it
- You operate a centralised inspection VPC (the classic hub-and-spoke / Transit Gateway “north-south” design) and need every spoke’s egress to pass stateful inspection and egress domain filtering (allow only
*.amazonaws.com,*.pkg.dev, your registries) before it reaches a NAT gateway. - You must enforce IPS/IDS with managed or custom Suricata signatures against east-west or north-south traffic and want the rule string version-controlled, not pasted into the console.
- A compliance control (PCI, IRAP, HIPAA, FedRAMP) requires deep packet inspection and full flow logging at the network boundary, beyond what security groups and NACLs provide.
- You are standardising firewalling across many accounts/VPCs and want one audited module — consistent default actions, logging, and
delete_protection— instead of hand-wired three-resource stacks that drift.
Reach for something cheaper when you only need L3/L4 allow/deny on an ENI or subnet — a security group or NACL is free and sufficient. Network Firewall bills per endpoint-hour and per GB processed, so it is for inspection you genuinely need, not basic port filtering. For pure HTTP(S) layer-7 rules in front of an ALB/CloudFront, use WAFv2 instead.
Module structure
terraform-module-aws-network-firewall/
├── versions.tf # provider + Terraform version pins
├── main.tf # firewall + policy + stateful/stateless rule groups + logging
├── variables.tf # vpc, subnets, suricata rules, domains, default actions (validated)
└── outputs.tf # id, arn, policy arn, per-AZ endpoint IDs, status
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Build the firewall's subnet_mapping set from the supplied subnet IDs.
# Each entry places one firewall endpoint in that subnet (ideally 1 per AZ).
subnet_mappings = toset(var.subnet_ids)
# A stateful rule group is only created when the caller supplies either a
# raw Suricata rules string or at least one domain in an allow/deny list.
create_domain_rules = length(var.domain_allowlist) > 0 || length(var.domain_denylist) > 0
create_suricata_rules = var.suricata_rules_string != null && trimspace(coalesce(var.suricata_rules_string, "")) != ""
# Stateless rule group is optional too — only when 5-tuple rules are given.
create_stateless_rules = length(var.stateless_rules) > 0
# Assemble the policy's stateful rule-group references in a deterministic
# order. STRICT_ORDER honours these priorities; DEFAULT_ACTION_ORDER ignores
# priority and evaluates pass > drop > alert by Suricata action precedence.
stateful_group_arns = compact([
local.create_domain_rules ? aws_networkfirewall_rule_group.domains[0].arn : "",
local.create_suricata_rules ? aws_networkfirewall_rule_group.suricata[0].arn : "",
])
base_tags = merge(
{
Name = var.name
ManagedBy = "terraform"
Module = "terraform-module-aws-network-firewall"
},
var.tags,
)
}
# ---------------------------------------------------------------------------
# Stateless rule group (optional): fast 5-tuple pass/drop/forward path. Packets
# matching here never reach the stateful engine, so it is cheap pre-filtering.
# ---------------------------------------------------------------------------
resource "aws_networkfirewall_rule_group" "stateless" {
count = local.create_stateless_rules ? 1 : 0
name = "${var.name}-stateless"
type = "STATELESS"
capacity = var.stateless_capacity
description = "Stateless 5-tuple pre-filter for ${var.name}"
rule_group {
rules_source {
stateless_rules_and_custom_actions {
dynamic "stateless_rule" {
for_each = { for idx, r in var.stateless_rules : idx => r }
content {
priority = stateless_rule.value.priority
rule_definition {
actions = stateless_rule.value.actions
match_attributes {
source {
address_definition = stateless_rule.value.source_cidr
}
destination {
address_definition = stateless_rule.value.destination_cidr
}
dynamic "destination_port" {
for_each = stateless_rule.value.destination_port == null ? [] : [stateless_rule.value.destination_port]
content {
from_port = destination_port.value
to_port = destination_port.value
}
}
protocols = stateless_rule.value.protocols
}
}
}
}
}
}
}
tags = local.base_tags
}
# ---------------------------------------------------------------------------
# Stateful domain-filtering rule group (optional): allow- or deny-list of HTTP
# Host / TLS SNI domains. The most common egress-control pattern.
# ---------------------------------------------------------------------------
resource "aws_networkfirewall_rule_group" "domains" {
count = local.create_domain_rules ? 1 : 0
name = "${var.name}-domains"
type = "STATEFUL"
capacity = var.domain_rules_capacity
description = "Domain allow/deny egress filtering for ${var.name}"
rule_group {
rule_variables {
ip_sets {
key = "HOME_NET"
ip_set {
definition = var.home_net
}
}
}
rules_source {
rules_source_list {
generated_rules_type = length(var.domain_allowlist) > 0 ? "ALLOWLIST" : "DENYLIST"
target_types = var.domain_target_types
targets = length(var.domain_allowlist) > 0 ? var.domain_allowlist : var.domain_denylist
}
}
}
tags = local.base_tags
}
# ---------------------------------------------------------------------------
# Stateful Suricata rule group (optional): raw IPS/IDS signatures. Strict order
# means these are evaluated by the priorities baked into the rule string.
# ---------------------------------------------------------------------------
resource "aws_networkfirewall_rule_group" "suricata" {
count = local.create_suricata_rules ? 1 : 0
name = "${var.name}-suricata"
type = "STATEFUL"
capacity = var.suricata_rules_capacity
description = "Custom Suricata signatures for ${var.name}"
rule_group {
rule_variables {
ip_sets {
key = "HOME_NET"
ip_set {
definition = var.home_net
}
}
}
stateful_rule_options {
rule_order = var.stateful_rule_order
}
rules_source {
rules_string = var.suricata_rules_string
}
}
tags = local.base_tags
}
# ---------------------------------------------------------------------------
# Firewall policy: the brain. Orders the rule groups and sets default actions
# for traffic matching nothing.
# ---------------------------------------------------------------------------
resource "aws_networkfirewall_firewall_policy" "this" {
name = "${var.name}-policy"
description = "Firewall policy for ${var.name}"
firewall_policy {
# Default actions for packets the STATELESS engine does not match.
stateless_default_action = var.stateless_default_action
stateless_fragment_default_action = var.stateless_fragment_default_action
# Hand stateful inspection the engine's rule-order strategy.
stateful_engine_options {
rule_order = var.stateful_rule_order
}
# STRICT_ORDER lets you set the catch-all stateful default action.
dynamic "stateful_default_actions" {
for_each = var.stateful_rule_order == "STRICT_ORDER" ? [1] : []
content {}
}
# Reference the stateless rule group (if any) at the given priority.
dynamic "stateless_rule_group_reference" {
for_each = local.create_stateless_rules ? [aws_networkfirewall_rule_group.stateless[0].arn] : []
content {
priority = 100
resource_arn = stateless_rule_group_reference.value
}
}
# Reference each stateful rule group. With STRICT_ORDER, priority decides
# evaluation order; with DEFAULT_ACTION_ORDER, priority is omitted.
dynamic "stateful_rule_group_reference" {
for_each = { for idx, arn in local.stateful_group_arns : idx => arn }
content {
priority = var.stateful_rule_order == "STRICT_ORDER" ? (stateful_rule_group_reference.key + 1) : null
resource_arn = stateful_rule_group_reference.value
}
}
}
tags = local.base_tags
}
# ---------------------------------------------------------------------------
# The firewall itself: placement + the policy reference. Endpoints land in the
# mapped subnets; route traffic through them via the per-AZ endpoint IDs output.
# ---------------------------------------------------------------------------
resource "aws_networkfirewall_firewall" "this" {
name = var.name
description = var.description
vpc_id = var.vpc_id
firewall_policy_arn = aws_networkfirewall_firewall_policy.this.arn
# Guardrails: block accidental destruction of an inline inspection point.
delete_protection = var.delete_protection
firewall_policy_change_protection = var.firewall_policy_change_protection
subnet_change_protection = var.subnet_change_protection
dynamic "subnet_mapping" {
for_each = local.subnet_mappings
content {
subnet_id = subnet_mapping.value
ip_address_type = var.ip_address_type
}
}
tags = local.base_tags
}
# ---------------------------------------------------------------------------
# Logging: FLOW (connection records) and/or ALERT (rule matches) to CloudWatch
# Logs, S3, or Kinesis Firehose. Attached to the firewall, not the policy.
# ---------------------------------------------------------------------------
resource "aws_networkfirewall_logging_configuration" "this" {
count = length(var.logging_configuration) > 0 ? 1 : 0
firewall_arn = aws_networkfirewall_firewall.this.arn
logging_configuration {
dynamic "log_destination_config" {
for_each = var.logging_configuration
content {
log_type = log_destination_config.value.log_type
log_destination_type = log_destination_config.value.log_destination_type
log_destination = log_destination_config.value.log_destination
}
}
}
}
variables.tf
variable "name" {
description = "Name of the firewall; base for the policy, rule-group, and tag names."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9-]{1,128}$", var.name))
error_message = "name must be 1-128 chars of letters, digits, or hyphens."
}
}
variable "description" {
description = "Human-readable description of the firewall's purpose."
type = string
default = "Managed by Terraform"
}
variable "vpc_id" {
description = "ID of the VPC the firewall inspects traffic for."
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 (vpc-xxxxxxxx)."
}
}
variable "subnet_ids" {
description = "Dedicated firewall subnets — one per AZ — that each receive a firewall endpoint. Must be empty /28+ subnets used only for the firewall."
type = list(string)
validation {
condition = length(var.subnet_ids) >= 1
error_message = "Provide at least one firewall subnet (one per AZ is strongly recommended)."
}
}
variable "ip_address_type" {
description = "IP address type for firewall endpoints: \"IPV4\", \"IPV6\", or \"DUALSTACK\"."
type = string
default = "IPV4"
validation {
condition = contains(["IPV4", "IPV6", "DUALSTACK"], var.ip_address_type)
error_message = "ip_address_type must be IPV4, IPV6, or DUALSTACK."
}
}
variable "home_net" {
description = "CIDR blocks treated as HOME_NET in stateful rules (typically the VPC and on-prem ranges). Used by domain and Suricata rule groups."
type = list(string)
default = []
}
variable "stateful_rule_order" {
description = "Stateful engine evaluation order: \"DEFAULT_ACTION_ORDER\" (Suricata action precedence: pass > drop > reject > alert) or \"STRICT_ORDER\" (by rule-group priority, lets you set a stateful default action)."
type = string
default = "STRICT_ORDER"
validation {
condition = contains(["DEFAULT_ACTION_ORDER", "STRICT_ORDER"], var.stateful_rule_order)
error_message = "stateful_rule_order must be DEFAULT_ACTION_ORDER or STRICT_ORDER."
}
}
variable "stateless_default_action" {
description = "Action for full packets the stateless engine matches no rule for: \"aws:forward_to_sfe\" (hand to stateful engine), \"aws:pass\", \"aws:drop\". Almost always forward_to_sfe."
type = string
default = "aws:forward_to_sfe"
validation {
condition = contains(["aws:forward_to_sfe", "aws:pass", "aws:drop"], var.stateless_default_action)
error_message = "stateless_default_action must be aws:forward_to_sfe, aws:pass, or aws:drop."
}
}
variable "stateless_fragment_default_action" {
description = "Action for fragmented packets the stateless engine matches no rule for. Usually aws:forward_to_sfe."
type = string
default = "aws:forward_to_sfe"
validation {
condition = contains(["aws:forward_to_sfe", "aws:pass", "aws:drop"], var.stateless_fragment_default_action)
error_message = "stateless_fragment_default_action must be aws:forward_to_sfe, aws:pass, or aws:drop."
}
}
variable "domain_allowlist" {
description = "Domains to ALLOW for egress (e.g. [\".amazonaws.com\", \".pkg.dev\"]). When non-empty, a stateful ALLOWLIST rule group is created and domain_denylist is ignored. A leading dot matches subdomains."
type = list(string)
default = []
}
variable "domain_denylist" {
description = "Domains to DENY for egress. Used only when domain_allowlist is empty; creates a stateful DENYLIST rule group."
type = list(string)
default = []
}
variable "domain_target_types" {
description = "Protocols the domain rule group inspects for hostnames: TLS_SNI and/or HTTP_HOST."
type = list(string)
default = ["TLS_SNI", "HTTP_HOST"]
validation {
condition = alltrue([for t in var.domain_target_types : contains(["TLS_SNI", "HTTP_HOST"], t)])
error_message = "domain_target_types entries must be TLS_SNI or HTTP_HOST."
}
}
variable "domain_rules_capacity" {
description = "Reserved capacity for the domain rule group. Roughly the count of domains; AWS reserves this at creation and it cannot be changed later."
type = number
default = 100
validation {
condition = var.domain_rules_capacity >= 1 && var.domain_rules_capacity <= 30000
error_message = "domain_rules_capacity must be between 1 and 30000."
}
}
variable "suricata_rules_string" {
description = "Raw Suricata rules (newline-separated) for a custom IPS/IDS stateful rule group. Null/empty skips the group. Reference HOME_NET in the rules; it resolves to home_net."
type = string
default = null
}
variable "suricata_rules_capacity" {
description = "Reserved capacity for the Suricata rule group — set to at least the number of rules. Fixed at creation."
type = number
default = 1000
validation {
condition = var.suricata_rules_capacity >= 1 && var.suricata_rules_capacity <= 30000
error_message = "suricata_rules_capacity must be between 1 and 30000."
}
}
variable "stateless_rules" {
description = <<-EOT
Optional fast-path stateless 5-tuple rules evaluated before the stateful
engine. `actions` is a list like ["aws:pass"], ["aws:drop"], or
["aws:forward_to_sfe"]. Use "ANY" for an open CIDR/port and protocol number
6 = TCP, 17 = UDP, 1 = ICMP.
Example:
[{
priority = 1
actions = ["aws:drop"]
source_cidr = "0.0.0.0/0"
destination_cidr = "10.0.0.0/8"
destination_port = 23
protocols = [6]
}]
EOT
type = list(object({
priority = number
actions = list(string)
source_cidr = string
destination_cidr = string
destination_port = optional(number)
protocols = list(number)
}))
default = []
}
variable "stateless_capacity" {
description = "Reserved capacity for the stateless rule group. Fixed at creation."
type = number
default = 100
validation {
condition = var.stateless_capacity >= 1 && var.stateless_capacity <= 30000
error_message = "stateless_capacity must be between 1 and 30000."
}
}
variable "logging_configuration" {
description = <<-EOT
Zero or more log destinations. log_type is FLOW (connection records) or
ALERT (rule matches). log_destination_type is CloudWatchLogs, S3, or
KinesisDataFirehose. log_destination is the type-specific map, e.g.
{ logGroup = "/aws/nfw/prod" } or { bucketName = "my-nfw-logs", prefix = "alert" }.
EOT
type = list(object({
log_type = string
log_destination_type = string
log_destination = map(string)
}))
default = []
validation {
condition = alltrue([for c in var.logging_configuration : contains(["FLOW", "ALERT"], c.log_type)])
error_message = "Each logging_configuration log_type must be FLOW or ALERT."
}
validation {
condition = alltrue([for c in var.logging_configuration : contains(["CloudWatchLogs", "S3", "KinesisDataFirehose"], c.log_destination_type)])
error_message = "Each log_destination_type must be CloudWatchLogs, S3, or KinesisDataFirehose."
}
}
variable "delete_protection" {
description = "Prevent the firewall from being deleted via API/Terraform until disabled. Recommended for inline production firewalls."
type = bool
default = true
}
variable "firewall_policy_change_protection" {
description = "Prevent the associated policy from being swapped out while protection is on."
type = bool
default = false
}
variable "subnet_change_protection" {
description = "Prevent firewall subnet mappings from being changed while protection is on."
type = bool
default = false
}
variable "tags" {
description = "Tags applied to the firewall, policy, and rule groups."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The ID of the Network Firewall (its ARN; aws_networkfirewall_firewall has no separate id)."
value = aws_networkfirewall_firewall.this.id
}
output "arn" {
description = "The ARN of the Network Firewall."
value = aws_networkfirewall_firewall.this.arn
}
output "name" {
description = "The name of the firewall."
value = aws_networkfirewall_firewall.this.name
}
output "firewall_policy_arn" {
description = "The ARN of the firewall policy the firewall uses."
value = aws_networkfirewall_firewall_policy.this.arn
}
output "update_token" {
description = "Token reflecting the firewall's last update; useful for change detection."
value = aws_networkfirewall_firewall.this.update_token
}
output "endpoint_ids" {
description = "Map of availability-zone => firewall VPC endpoint ID. Use these as the route-table target (vpc_endpoint_id) to send traffic through the firewall in each AZ."
value = {
for sync in aws_networkfirewall_firewall.this.firewall_status[0].sync_states :
sync.availability_zone => sync.attachment[0].endpoint_id
}
}
output "stateful_rule_group_arns" {
description = "ARNs of the stateful rule groups created (domain and/or Suricata), empty if none."
value = compact([
length(aws_networkfirewall_rule_group.domains) > 0 ? aws_networkfirewall_rule_group.domains[0].arn : "",
length(aws_networkfirewall_rule_group.suricata) > 0 ? aws_networkfirewall_rule_group.suricata[0].arn : "",
])
}
How to use it
This example deploys a firewall in a centralised inspection VPC across two AZs: it allows egress only to AWS and package-registry domains, adds a custom Suricata rule that drops a known-bad CIDR, and ships FLOW + ALERT logs to CloudWatch Logs. The downstream block consumes the endpoint_ids output to route a spoke’s traffic through the firewall endpoint in the matching AZ.
resource "aws_cloudwatch_log_group" "nfw_flow" {
name = "/aws/nfw/inspection/flow"
retention_in_days = 90
}
resource "aws_cloudwatch_log_group" "nfw_alert" {
name = "/aws/nfw/inspection/alert"
retention_in_days = 365
}
module "network_firewall" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-network-firewall?ref=v1.0.0"
name = "inspection-egress"
description = "Centralised north-south egress inspection"
vpc_id = aws_vpc.inspection.id
subnet_ids = aws_subnet.firewall[*].id # one /28 per AZ, firewall-only
home_net = ["10.0.0.0/8"]
# Allow-list egress: anything not on this list is dropped by the SNI/Host check.
domain_allowlist = [
".amazonaws.com",
".pkg.dev",
".ghcr.io",
"registry.terraform.io",
]
# Custom IPS signature: drop traffic to a quarantined CIDR and log it.
suricata_rules_string = <<-RULES
drop ip $HOME_NET any -> 198.51.100.0/24 any (msg:"Blocked quarantine range"; sid:1000001; rev:1;)
RULES
stateful_rule_order = "STRICT_ORDER"
delete_protection = true
logging_configuration = [
{
log_type = "FLOW"
log_destination_type = "CloudWatchLogs"
log_destination = { logGroup = aws_cloudwatch_log_group.nfw_flow.name }
},
{
log_type = "ALERT"
log_destination_type = "CloudWatchLogs"
log_destination = { logGroup = aws_cloudwatch_log_group.nfw_alert.name }
},
]
tags = {
Environment = "prod"
Team = "platform-network"
}
}
# Downstream: route the spoke subnet's default traffic through the firewall
# endpoint in the SAME AZ (ap-south-1a), using the module's endpoint_ids map.
resource "aws_route" "spoke_to_firewall" {
route_table_id = aws_route_table.spoke_az_a.id
destination_cidr_block = "0.0.0.0/0"
vpc_endpoint_id = module.network_firewall.endpoint_ids["ap-south-1a"]
}
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_firewall/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-firewall?ref=v1.0.0"
}
inputs = {
name = "..."
vpc_id = "..."
subnet_ids = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/network_firewall && 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 | Firewall name; base for policy, rule-group, and tag names (1–128 chars). |
description |
string |
"Managed by Terraform" |
No | Purpose of the firewall. |
vpc_id |
string |
— | Yes | VPC the firewall inspects (validated as vpc-…). |
subnet_ids |
list(string) |
— | Yes | Dedicated firewall subnets (one per AZ); each gets a firewall endpoint. |
ip_address_type |
string |
"IPV4" |
No | Endpoint IP type: IPV4, IPV6, or DUALSTACK. |
home_net |
list(string) |
[] |
No | CIDRs treated as HOME_NET in stateful rules (VPC + on-prem). |
stateful_rule_order |
string |
"STRICT_ORDER" |
No | STRICT_ORDER (by priority) or DEFAULT_ACTION_ORDER (Suricata precedence). |
stateless_default_action |
string |
"aws:forward_to_sfe" |
No | Stateless no-match action: aws:forward_to_sfe, aws:pass, aws:drop. |
stateless_fragment_default_action |
string |
"aws:forward_to_sfe" |
No | Stateless no-match action for fragments. |
domain_allowlist |
list(string) |
[] |
No | Domains to allow; creates an ALLOWLIST stateful group and overrides denylist. |
domain_denylist |
list(string) |
[] |
No | Domains to deny; used only when domain_allowlist is empty. |
domain_target_types |
list(string) |
["TLS_SNI","HTTP_HOST"] |
No | Protocols inspected for hostnames: TLS_SNI, HTTP_HOST. |
domain_rules_capacity |
number |
100 |
No | Reserved capacity for the domain rule group (1–30000, fixed at creation). |
suricata_rules_string |
string |
null |
No | Raw Suricata signatures for a custom IPS/IDS group; null skips it. |
suricata_rules_capacity |
number |
1000 |
No | Reserved capacity for the Suricata group (1–30000, fixed at creation). |
stateless_rules |
list(object) |
[] |
No | Optional fast-path 5-tuple rules (priority/actions/CIDRs/port/protocols). |
stateless_capacity |
number |
100 |
No | Reserved capacity for the stateless group (1–30000, fixed at creation). |
logging_configuration |
list(object) |
[] |
No | FLOW/ALERT log destinations (CloudWatchLogs/S3/KinesisDataFirehose). |
delete_protection |
bool |
true |
No | Block deletion of the firewall until disabled. |
firewall_policy_change_protection |
bool |
false |
No | Block swapping the policy while on. |
subnet_change_protection |
bool |
false |
No | Block changing subnet mappings while on. |
tags |
map(string) |
{} |
No | Tags applied to firewall, policy, and rule groups. |
Outputs
| Name | Description |
|---|---|
id |
The firewall ID (its ARN). |
arn |
The ARN of the Network Firewall. |
name |
The firewall name. |
firewall_policy_arn |
ARN of the firewall policy in use. |
update_token |
Token reflecting the firewall’s last update. |
endpoint_ids |
Map of AZ → firewall VPC endpoint ID; use as the route-table vpc_endpoint_id target. |
stateful_rule_group_arns |
ARNs of the created stateful rule groups (domain and/or Suricata). |
Enterprise scenario
A media company runs a Transit Gateway hub-and-spoke with 60 workload accounts and a single shared inspection VPC in ap-south-1. The network team publishes this module at v1.0.0 and deploys one firewall across three AZs with delete_protection = true, an egress allow-list of approved SaaS, AWS, and registry domains, and a managed-plus-custom Suricata rule group for IPS. Every spoke’s 0.0.0.0/0 route points at the firewall endpoint_ids entry for its own AZ (kept in-AZ to avoid cross-AZ data charges), so all north-south egress is inspected and domain-filtered before it hits the NAT gateways, and ALERT logs stream to a central CloudWatch group wired to a Security Hub finding pipeline — turning ad-hoc per-account egress into one audited, version-controlled control point.
Best practices
- Dedicate firewall subnets and keep routing symmetric. Each firewall endpoint needs its own empty subnet (one per AZ) used for nothing else. Send each spoke’s traffic to the firewall endpoint in the same AZ — asymmetric AZ routing drops stateful flows and quietly silently breaks return traffic, so always consume
endpoint_ids[az]per route table. - Right-size capacity up front — it is immutable. A rule group’s
capacityis reserved at creation and cannot be raised; if a managed signature set or domain list outgrows it you must create a new group and re-point the policy. Budget headroom (e.g.suricata_rules_capacitywell above today’s rule count) to avoid a disruptive replacement. - Prefer
STRICT_ORDERfor predictable enforcement.DEFAULT_ACTION_ORDERevaluates by Suricata action precedence (pass beats drop) and gives you no stateful default action.STRICT_ORDERlets you order rule groups by priority and set an explicit drop-everything-else default — essential for a real allow-list egress posture. - Turn on FLOW and ALERT logging, and set retention. You cannot triage a blocked deploy or investigate an incident without logs. Stream FLOW for connection accounting and ALERT for rule matches to CloudWatch/S3/Firehose, and apply retention to control cost — alert logs deserve longer retention than high-volume flow logs.
- Mind the cost model: endpoint-hours plus per-GB. Network Firewall bills per endpoint per hour and per GB processed, so an over-provisioned multi-AZ firewall in a low-traffic VPC is pure waste. Consolidate inspection into a shared VPC, keep traffic in-AZ, and tag
Environment/Teamso Cost Explorer attributes both dimensions. - Enable
delete_protection(and change protection) on inline firewalls, and name consistently. An accidentalterraform destroyof an inline inspection point blackholes every spoke behind it. Keepdelete_protection = truein production, turn onsubnet_change_protection/firewall_policy_change_protectionfor regulated estates, and use a stable<env>-<purpose>name so the firewall, its policy, and rule groups stay discoverable.