IaC AWS

Terraform Module: AWS Network Firewall — managed stateful inspection at your VPC edge

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

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 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_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

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