A logged, deletion-protected, secure-by-default AWS Network Firewall — the firewall itself, its one-and-only firewall policy, any number of stateful/stateless rule groups, and a FLOW/ALERT logging configuration — from a single module call, wired into a VPC's dedicated firewall subnets (or a transit gateway) with change-protection on and CMK encryption available. Route-table wiring is deliberately the caller's job. Built for the AWS provider v6.x.
- 🧱 Provisions an
aws_networkfirewall_firewallkeystone attached to a VPC (subnet_mapping, one endpoint per firewall subnet) or a transit gateway (availability_zone_mapping). - 📜 Creates the firewall's single firewall policy (
aws_networkfirewall_firewall_policy— AWS enforces exactly one per firewall) with a secure default that forwards stateless traffic to the stateful engine for full inspection. - 🛡️ Creates any number of stateful and stateless rule groups as a
for_eachchild collection — 5-tuple rules, Suricata strings, domain allow/deny lists, or raw Suricata files — each referenceable from the policy by a stable key (no ARN hand-wiring). - 🔒 Defaults
delete_protection,subnet_change_protection, andfirewall_policy_change_protectiontotrue, so an in-use firewall cannot be silently deleted or re-pointed. - 📈 Optionally streams FLOW / ALERT / TLS logs to CloudWatch Logs, S3, or Kinesis Data Firehose — one destination per log type — created automatically whenever a destination is supplied.
- 🗝️ Encrypts every in-scope resource with a customer-managed KMS key the moment
kms_key_arnis supplied (AWS-owned key otherwise). - 🧭 Emits
firewall_endpoint_ids(subnet key →vpce-...) — the one output this module exists to produce — for the caller's ownaws_routeresources.
💡 Why it matters: a firewall that is deployed but not routed inspects zero traffic — silently (apply succeeds). This module ships the hardened, observable inspection layer and makes the routing responsibility explicit, so the boundary between "firewall exists" and "traffic is actually inspected" is never accidentally blurred in an PII/privacy-regulation environment.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
subgraph up["Upstream (consumed by reference)"]
VPC["terraform-aws-vpc<br/>(vpc_id, firewall subnet ids,<br/>route table ids)"]
TGW["terraform-aws-transit-gateway<br/>(transit_gateway_id)"]
KMS["terraform-aws-kms<br/>(kms_key_arn — CMK)"]
LOGS["terraform-aws-cloudwatch-log-group<br/>/ terraform-aws-s3-bucket<br/>/ terraform-aws-kinesis-firehose<br/>(log destinations)"]
end
NFW["terraform-aws-network-firewall<br/>(firewall + policy + rule groups<br/>+ logging configuration)"]
ROUTE["CALLER's own aws_route<br/>(NOT this module)"]
VPC -->|"vpc_id + subnet_mapping"| NFW
TGW -->|"transit_gateway_id"| NFW
KMS -->|"encryption_configuration"| NFW
LOGS -->|"log_destination"| NFW
NFW -->|"firewall_endpoint_ids (vpce-...)"| ROUTE
VPC -->|"route_table_ids"| ROUTE
classDef me fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px;
classDef caller fill:#FFF3E0,color:#5a3d00,stroke:#FF9900,stroke-dasharray:4 3;
class NFW me;
class ROUTE caller;
- Upstream:
terraform-aws-vpcsuppliesvpc_idand the dedicated firewall subnet ids;terraform-aws-transit-gatewaysuppliestransit_gateway_id;terraform-aws-kmssupplies the CMK;terraform-aws-cloudwatch-log-group/terraform-aws-s3-bucket/terraform-aws-kinesis-firehosesupply the log destination. - Downstream: the caller's own
aws_routeresources consume this module'sfirewall_endpoint_idstogether withroute_table_idsfromterraform-aws-vpc. This module never creates or modifies a route table (see Architecture Notes).
flowchart TD
RG["aws_networkfirewall_rule_group.this<br/>for_each var.rule_groups<br/>(STATEFUL / STATELESS)"]
POL["aws_networkfirewall_firewall_policy.this<br/>(singleton — 1 per firewall)"]
FW["aws_networkfirewall_firewall.this<br/>(keystone — subnet/AZ mapping,<br/>delete + change protection)"]
LOG["aws_networkfirewall_logging_configuration.this<br/>(optional — FLOW / ALERT / TLS)"]
EP["firewall_endpoint_ids output<br/>(vpce-... per firewall subnet)"]
RG -.->|"rule_group_key resolves to arn"| POL
POL -->|"firewall_policy_arn"| FW
FW -->|"firewall_arn"| LOG
FW -->|"firewall_status.sync_states"| EP
classDef me fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px;
classDef child fill:#FFF3E0,color:#5a3d00,stroke:#FF9900;
classDef out fill:#E8F5E9,color:#1b5e20,stroke:#2E7D32;
class FW me;
class RG,POL,LOG child;
class EP out;
| Resource | Cardinality | Driven by |
|---|---|---|
aws_networkfirewall_firewall.this |
exactly 1 (keystone) | always |
aws_networkfirewall_firewall_policy.this |
exactly 1 (singleton) | always (secure defaults) |
aws_networkfirewall_rule_group.this |
0..N (for_each) |
var.rule_groups |
aws_networkfirewall_logging_configuration.this |
0..1 | var.logging |
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/aws |
>= 6.0, < 7.0 (GA v6.5x) |
This module declares only required_providers — no provider {} block, no configuration_aliases. Network Firewall is a regional service, so it inherits the single caller-supplied aws provider; the caller chooses the Region.
The Terraform identity (profile / SSO / OIDC-assumed role) needs the following, scoped least-privilege to the firewall and its children.
| Action | Required for | Notes |
|---|---|---|
network-firewall:CreateFirewall, network-firewall:DeleteFirewall, network-firewall:DescribeFirewall, network-firewall:UpdateFirewallDescription |
Firewall lifecycle (keystone) | — |
network-firewall:AssociateSubnets, network-firewall:DisassociateSubnets |
Firewall-endpoint (subnet) association | Driven by subnet_mapping changes |
network-firewall:UpdateSubnetChangeProtection, network-firewall:UpdateFirewallPolicyChangeProtection, network-firewall:UpdateFirewallDeleteProtection, network-firewall:UpdateAvailabilityZoneChangeProtection |
The four change-protection flags | This module defaults all to true |
network-firewall:AssociateFirewallPolicy |
Attaching the policy to the firewall | — |
network-firewall:CreateFirewallPolicy, network-firewall:DeleteFirewallPolicy, network-firewall:DescribeFirewallPolicy, network-firewall:UpdateFirewallPolicy |
Firewall policy lifecycle | Singleton child |
network-firewall:CreateRuleGroup, network-firewall:DeleteRuleGroup, network-firewall:DescribeRuleGroup, network-firewall:UpdateRuleGroup, network-firewall:ListRuleGroups |
Rule group lifecycle | Only when rule_groups is non-empty |
network-firewall:UpdateLoggingConfiguration, network-firewall:DescribeLoggingConfiguration |
Logging configuration | Only when logging is set |
network-firewall:TagResource, network-firewall:UntagResource, network-firewall:ListTagsForResource |
var.tags / tags_all on firewall, policy, rule groups |
Logging config is not taggable |
kms:CreateGrant, kms:DescribeKey |
CMK-encrypted resources | Only when kms_key_arn is supplied |
ec2:DescribeSubnets, ec2:DescribeVpcs |
Validating vpc_id / firewall subnets at plan time |
— |
logs:CreateLogDelivery or s3:PutBucketPolicy or firehose:DescribeDeliveryStream |
Wiring the chosen log destination to accept firewall delivery | On the destination side — usually owned by the log-group / bucket / Firehose module, listed here for completeness |
ℹ️ No
iam:PassRoleis required — this module passes no role to any resource.network-firewall:PutResourcePolicy/DescribeResourcePolicyare only needed if you share a policy / rule group cross-account via a resource policy (not exercised by the default variables).
- No service-linked role is required for Network Firewall itself.
- Firewall subnets are dedicated and pre-existing. AWS recommends a
/28subnet per AZ, separate from the protected ("customer") subnets, existing solely to host the firewall endpoint (vpce-...) in that AZ. Create them withterraform-aws-vpcbefore calling this module. Network Firewall creates one endpoint persubnet_mappingentry, and each subnet must be in a distinct AZ. - Route tables are a hard prerequisite for a functioning firewall — and are OUT OF SCOPE here. A firewall with no routing pointed at its endpoints passes zero traffic, silently. The three route table types and who owns them:
flowchart LR
subgraph vpcmod["terraform-aws-vpc creates"]
IGWRT["IGW route table"]
FWSUB["Firewall subnet<br/>(/28 per AZ)"]
FWRT["Firewall subnet<br/>route table"]
CUSTSUB["Protected subnet"]
CUSTRT["Protected subnet<br/>route table"]
end
subgraph nfwmod["terraform-aws-network-firewall creates"]
EP["Firewall endpoint<br/>vpce-... (per AZ)"]
end
subgraph callermod["CALLER creates (aws_route)"]
R1["route: protected -> endpoint"]
R2["route: IGW -> endpoint"]
R3["route: endpoint -> IGW / NAT"]
end
FWSUB --- EP
EP -.->|"firewall_endpoint_ids"| R1
EP -.->|"firewall_endpoint_ids"| R2
CUSTRT --- R1
IGWRT --- R2
FWRT --- R3
classDef me fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px;
class EP me;
This module creates the ENDPOINTS, terraform-aws-vpc creates the SUBNETS, and the CALLER creates the aws_route resources connecting the two.
- Capacity is immutable per rule group.
capacityonaws_networkfirewall_rule_groupis FORCE-NEW — undersizing requires rule-group replacement, not an in-place update. Size generously. - Quotas (soft, per account/Region unless noted): 100 firewalls; 300 rule groups; 15 firewall policies; 1 firewall policy per firewall (hard); one firewall endpoint per firewall per AZ (hard); stateful capacity up to ~30,000. Verify current values in the Service Quotas console before large deployments.
- KMS key policy must grant the Network Firewall service principal (
network-firewall.amazonaws.com)kms:GenerateDataKey/kms:Decryptwhen a customer-managed key is supplied. - us-east-1: no global-resource constraint — Network Firewall is regional (unlike CloudFront / ACM-for-CloudFront / WAFv2-CLOUDFRONT).
terraform-aws-network-firewall/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); regional, no aliases
├── variables.tf # deeply-typed object schemas; secure defaults; validation blocks
├── main.tf # rule_group.this (for_each) -> firewall_policy.this -> firewall.this + logging
├── outputs.tf # id + arn + name + firewall_endpoint_ids + rule_group ARNs + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM, prerequisites, gotchas, secure defaults
Smallest useful VPC-attached firewall, wiring firewall subnets from terraform-aws-vpc, with FLOW + ALERT logging:
module "nfw" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-prod-nfw"
vpc_id = module.vpc.id
# Dedicated firewall subnets (one /28 per AZ) from terraform-aws-vpc.
subnet_mapping = {
az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] }
az_b = { subnet_id = module.vpc.firewall_subnet_ids["az-b"] }
}
# A minimal deny-list rule group, referenced by key from the policy.
rule_groups = {
block_bad_domains = {
type = "STATEFUL"
capacity = 100
rule_group = {
rules_source = {
rules_source_list = {
generated_rules_type = "DENYLIST"
target_types = ["HTTP_HOST", "TLS_SNI"]
targets = [".malware.test", "evil.example.com"]
}
}
}
}
}
firewall_policy = {
stateful_rule_group_references = {
bad_domains = { rule_group_key = "block_bad_domains" }
}
}
logging = {
log_destinations = {
FLOW = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.flow_logs.name } }
ALERT = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.alert_logs.name } }
}
}
tags = { application = "member-portal", data_class = "npi" }
}
# The firewall inspects nothing until YOU route traffic through its endpoints:
resource "aws_route" "protected_to_fw" {
for_each = module.vpc.protected_route_table_ids # { az-a = rtb-..., az-b = rtb-... }
route_table_id = each.value
destination_cidr_block = "0.0.0.0/0"
vpc_endpoint_id = module.nfw.firewall_endpoint_ids[each.key]
}
⚠️ Pin the source with?ref=v1.0.0— never a branch.
Consumes
| Input | Type | Source module |
|---|---|---|
vpc_id |
string |
terraform-aws-vpc (id / vpc_id) |
subnet_mapping[*].subnet_id |
string |
terraform-aws-vpc — dedicated firewall subnets, one per AZ |
transit_gateway_id (alt. to vpc_id) |
string |
terraform-aws-transit-gateway (id) |
kms_key_arn (optional CMK) |
string |
terraform-aws-kms (arn) |
logging.log_destinations[*].log_destination |
map(string) |
terraform-aws-cloudwatch-log-group / terraform-aws-s3-bucket / terraform-aws-kinesis-firehose |
firewall_policy.*_rule_group_references[*].resource_arn |
string (optional) |
externally-created rule group |
firewall_policy.tls_inspection_configuration_arn |
string (optional, create-only) |
externally-managed TLS inspection configuration |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Firewall ARN (id == arn on this resource) | reference / import |
arn |
Firewall ARN — cross-resource reference type | IAM policy conditions, terraform-aws-cloudtrail filters |
name |
Firewall friendly name | tagging / dashboards |
firewall_endpoint_ids |
Map of subnet key → endpoint id (vpce-...) |
the caller's aws_route resources |
firewall_status |
Full nested status object | diagnostics |
firewall_policy_arn / firewall_policy_id |
Policy ARN / id | reference / import / reuse |
rule_group_arns / rule_group_ids |
Map of rule-group key → ARN / id | policy references / audit |
logging_configuration_id |
Logging config id (== firewall ARN) or null | audit |
update_token |
Optimistic-locking token | rarely needed |
tags_all |
All tags incl. provider default_tags |
governance / audit |
1 · Minimal VPC-attached firewall (subnets from terraform-aws-vpc)
module "nfw" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-baseline-nfw"
vpc_id = module.vpc.id
subnet_mapping = {
az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] }
az_b = { subnet_id = module.vpc.firewall_subnet_ids["az-b"] }
}
# firewall_policy defaults to {}: stateless traffic is forwarded to the
# stateful engine (aws:forward_to_sfe). delete/subnet/policy change protection
# all default to true. No rules yet — add rule_groups + references next.
}2 · STRICT_ORDER deny-by-default stateful posture
module "nfw" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-strict-nfw"
vpc_id = module.vpc.id
subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }
rule_groups = {
allow_egress = {
type = "STATEFUL"
capacity = 100
rule_group = {
stateful_rule_options = { rule_order = "STRICT_ORDER" } # MUST match the policy
rules_source = {
stateful_rule = [{
action = "PASS"
header = { source = "10.0.0.0/16", source_port = "ANY", direction = "FORWARD", protocol = "TCP", destination = "ANY", destination_port = "443" }
rule_option = [{ keyword = "sid", settings = ["1"] }]
}]
}
}
}
}
firewall_policy = {
# Deny everything not explicitly passed above:
stateful_engine_options = { rule_order = "STRICT_ORDER" }
stateful_default_actions = ["aws:drop_established", "aws:alert_established"]
stateful_rule_group_references = {
egress = { rule_group_key = "allow_egress", priority = 100 } # priority required under STRICT_ORDER
}
}
}STRICT_ORDER is viral — every rule group referenced by a STRICT_ORDER policy must itself set stateful_rule_options.rule_order = "STRICT_ORDER", and stateful_default_actions is only valid under STRICT_ORDER.
3 · DEFAULT_ACTION_ORDER 5-tuple stateful rule group
rule_groups = {
block_ssh = {
type = "STATEFUL"
capacity = 100
rule_group = {
# No stateful_rule_options => DEFAULT_ACTION_ORDER (the schema default).
rules_source = {
stateful_rule = [{
action = "DROP"
header = { source = "ANY", source_port = "ANY", direction = "ANY", protocol = "TCP", destination = "ANY", destination_port = "22" }
rule_option = [
{ keyword = "sid", settings = ["100"] },
{ keyword = "msg", settings = ["\"block inbound ssh\""] },
]
}]
}
}
}
}
firewall_policy = {
stateful_rule_group_references = { ssh = { rule_group_key = "block_ssh" } } # no priority needed
}4 · Stateless rule group (5-tuple + match attributes)
rule_groups = {
drop_udp = {
type = "STATELESS"
capacity = 100
rule_group = {
rules_source = {
stateless_rules_and_custom_actions = {
stateless_rule = [{
priority = 1
rule_definition = {
actions = ["aws:drop"]
match_attributes = {
protocols = [17] # UDP
source = [{ address_definition = "0.0.0.0/0" }]
destination = [{ address_definition = "10.0.0.0/16" }]
destination_port = [{ from_port = 53, to_port = 53 }]
}
}
}]
}
}
}
}
}
firewall_policy = {
stateless_rule_group_references = {
udp = { rule_group_key = "drop_udp", priority = 10 } # priority required for stateless refs
}
}5 · Domain allow-list / deny-list (rules_source_list)
rule_groups = {
allow_only_partners = {
type = "STATEFUL"
capacity = 200
rule_group = {
rules_source = {
rules_source_list = {
generated_rules_type = "ALLOWLIST" # or DENYLIST
target_types = ["TLS_SNI", "HTTP_HOST"]
targets = ["farmcredit.com", ".partner.example"]
}
}
}
}
}target_types are TLS_SNI and/or HTTP_HOST. A leading dot (.partner.example) matches all subdomains.
6 · Raw Suricata rules file (STATEFUL only)
rule_groups = {
suricata = {
type = "STATEFUL"
capacity = 500
rules = file("${path.module}/rules/nfw.rules") # a Suricata flat file
# `rules` and `rule_group` are mutually exclusive — set exactly one.
}
}7 · Logging — FLOW + ALERT to CloudWatch (the baseline)
logging = {
enable_monitoring_dashboard = true
log_destinations = {
FLOW = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.flow_logs.name } }
ALERT = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.alert_logs.name } }
}
}One destination per log type. S3 uses { bucketName = "...", prefix = "nfw/" }; Firehose uses { deliveryStream = "..." }. Omit logging entirely and no logging configuration is created.
8 · Customer-managed KMS encryption
module "nfw" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-cmk-nfw"
vpc_id = module.vpc.id
subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }
kms_key_arn = module.kms.arn # from terraform-aws-kms
# -> encryption_configuration { type = "CUSTOMER_KMS", key_id = <arn> } is rendered
# on the firewall, the policy, AND every rule group. The key policy must grant
# network-firewall.amazonaws.com kms:GenerateDataKey / kms:Decrypt.
}9 · Secure-by-default opt-out — delete_protection = false (dev, documented exception)
# ⚠️ Exception posture for a throwaway/dev firewall you intend to tear down often.
# Document the risk acceptance; production firewalls keep all protections on.
module "nfw_dev" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-dev-nfw"
vpc_id = module.vpc.id
subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }
delete_protection = false # ← opt-out (default true)
subnet_change_protection = false
firewall_policy_change_protection = false
}10 · tags — merge with provider default_tags
provider "aws" {
region = "us-east-2"
default_tags { tags = { managed_by = "terraform", cost_center = "1234" } }
}
module "nfw" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-portal-nfw"
vpc_id = module.vpc.id
subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }
tags = { application = "member-portal", data_class = "npi" }
# tags_all = default_tags ∪ tags; on key conflict, these resource tags win.
rule_groups = {
rg = {
type = "STATEFUL"
capacity = 100
rules = "alert tcp any any -> any any (msg:\"x\"; sid:1;)"
tags = { tier = "inspection" } # per-rule-group tags merge OVER module tags
}
}
}11 · for_each pattern — several rule groups, referenced by key
rule_groups = {
bad_domains = {
type = "STATEFUL", capacity = 100
rule_group = { rules_source = { rules_source_list = { generated_rules_type = "DENYLIST", target_types = ["TLS_SNI"], targets = [".malware.test"] } } }
}
block_ssh = {
type = "STATEFUL", capacity = 100
rule_group = { rules_source = { stateful_rule = [{ action = "DROP", header = { source = "ANY", source_port = "ANY", direction = "ANY", protocol = "TCP", destination = "ANY", destination_port = "22" }, rule_option = [{ keyword = "sid", settings = ["1"] }] }] } }
}
rate_limit = {
type = "STATELESS", capacity = 100
rule_group = { rules_source = { stateless_rules_and_custom_actions = { stateless_rule = [{ priority = 1, rule_definition = { actions = ["aws:drop"], match_attributes = { source = [{ address_definition = "192.0.2.0/24" }] } } }] } } }
}
}
firewall_policy = {
stateful_rule_group_references = { bad = { rule_group_key = "bad_domains" }, ssh = { rule_group_key = "block_ssh" } }
stateless_rule_group_references = { rl = { rule_group_key = "rate_limit", priority = 10 } }
}
# module.nfw.rule_group_arns => { bad_domains = "arn:...", block_ssh = "arn:...", rate_limit = "arn:..." }12 · import an existing firewall
# Network Firewall resources import by their ARN (id == arn).
import {
to = module.nfw.aws_networkfirewall_firewall.this
id = "arn:aws:network-firewall:us-east-2:111122223333:firewall/casey-prod-nfw"
}13 · Full routing — the caller's own aws_route resources
# This module NEVER creates routes. Redirect protected-subnet egress through the
# firewall endpoints, using route table ids from terraform-aws-vpc and endpoint ids
# from this module (same stable keys as subnet_mapping):
resource "aws_route" "protected_default_via_fw" {
for_each = module.vpc.protected_route_table_ids # { az-a = "rtb-...", az-b = "rtb-..." }
route_table_id = each.value
destination_cidr_block = "0.0.0.0/0"
vpc_endpoint_id = module.nfw.firewall_endpoint_ids[each.key]
}
# Ingress return path: the IGW edge route table sends inbound traffic to the
# firewall endpoint in the matching AZ before it reaches the protected subnet.
resource "aws_route" "igw_edge_to_fw" {
for_each = module.vpc.protected_subnet_cidrs # { az-a = "10.0.1.0/24",... }
route_table_id = module.vpc.igw_edge_route_table_id
destination_cidr_block = each.value
vpc_endpoint_id = module.nfw.firewall_endpoint_ids[each.key]
}14 · End-to-end composition (VPC → KMS → log group → firewall → routing)
provider "aws" { region = "us-east-2" }
module "vpc" {
source = "git::https://github.com/microsoftexpert/terraform-aws-vpc?ref=v1.0.0"
name = "casey-inspection-vpc"
#... provisions protected subnets AND dedicated /28 firewall subnets per AZ...
}
module "kms" {
source = "git::https://github.com/microsoftexpert/terraform-aws-kms?ref=v1.0.0"
name = "casey-nfw"
key_service_principals = ["network-firewall.amazonaws.com"] # allow the service to use the CMK
}
module "flow_logs" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudwatch-log-group?ref=v1.0.0"
name = "/casey/network-firewall/flow"
kms_key_arn = module.kms.arn
}
module "alert_logs" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudwatch-log-group?ref=v1.0.0"
name = "/casey/network-firewall/alert"
kms_key_arn = module.kms.arn
}
module "nfw" {
source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
name = "casey-prod-nfw"
vpc_id = module.vpc.id
subnet_mapping = {
for k, id in module.vpc.firewall_subnet_ids : k => { subnet_id = id }
}
kms_key_arn = module.kms.arn
rule_groups = {
block_bad_domains = {
type = "STATEFUL"
capacity = 200
rule_group = {
stateful_rule_options = { rule_order = "STRICT_ORDER" }
rules_source = { rules_source_list = { generated_rules_type = "DENYLIST", target_types = ["TLS_SNI", "HTTP_HOST"], targets = [".malware.test"] } }
}
}
}
firewall_policy = {
stateful_engine_options = { rule_order = "STRICT_ORDER", stream_exception_policy = "DROP" }
stateful_default_actions = ["aws:drop_established", "aws:alert_established"]
stateful_rule_group_references = { bad = { rule_group_key = "block_bad_domains", priority = 100 } }
}
logging = {
log_destinations = {
FLOW = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.flow_logs.name } }
ALERT = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.alert_logs.name } }
}
}
tags = { application = "member-portal", data_class = "npi" }
}
# The caller owns routing (this module does not):
resource "aws_route" "protected_via_fw" {
for_each = module.vpc.protected_route_table_ids
route_table_id = each.value
destination_cidr_block = "0.0.0.0/0"
vpc_endpoint_id = module.nfw.firewall_endpoint_ids[each.key]
}ℹ️ High-level groups:
- Identity —
name(required, FORCE-NEW). - Attachment (set exactly one mode) —
vpc_id+subnet_mapping(VPC-attached) ortransit_gateway_id+availability_zone_mapping(transit-gateway-attached). - Firewall policy —
firewall_policy(singleton object: stateless/stateful default actions,stateful_engine_options,policy_variables, stateful/stateless rule group references by key or ARN, custom actions,tls_inspection_configuration_arn). - Rule groups —
rule_groups(map(object),for_each:type,capacity(FORCE-NEW), and one ofrulesor a structuredrule_group). - Protection (secure defaults, all ON) —
delete_protection,subnet_change_protection,firewall_policy_change_protection,availability_zone_change_protection. - Configuration —
description,enabled_analysis_types. - Encryption —
kms_key_arn(CMK; null = AWS-owned key). - Logging —
logging(per-log-type destinations + monitoring dashboard). - Tags / timeouts —
tags(universal),timeouts.
id— firewall ARN (id == arn on this resource).arn— firewall ARN (the cross-resource reference type).name— firewall friendly name.firewall_endpoint_ids— map ofsubnet_mappingkey → endpoint id (vpce-...); the routing output. Unknown until after apply; VPC-attached mode only.firewall_status— full nested status object (per-AZ sync state, TGW attachment sync states).firewall_policy_arn/firewall_policy_id— policy ARN / id.rule_group_arns/rule_group_ids— maps keyed byrule_groupskey (empty when none).logging_configuration_id—try(..., null); logging config id or null.update_token— optimistic-locking token.tags_all— merged tags including providerdefault_tags.
No output is sensitive — the module emits no secrets.
id==arn. Unlike most AWS resources,aws_networkfirewall_firewall(and its policy / rule-group siblings) return the full ARN for bothidandarn— there is no separate short id (nofw-xxxx). Import uses the ARN.- ARN format:
arn:<partition>:network-firewall:<region>:<account>:firewall/<name>(and.../firewall-policy/<name>,.../stateful-rulegroup/<name>or.../stateless-rulegroup/<name>). - FORCE-NEW fields:
name,vpc_id, andtransit_gateway_idon the firewall;capacityon every rule group;firewall_arnon the logging configuration. Switching VPC↔TGW attachment or renaming the firewall destroys and recreates it (new ARN, new endpoints), breaking everyaws_routethat targets an old endpoint id — plan topology up front. - Route tables are out of scope — permanently. This module owns inspection resources only. Route tables belong to
terraform-aws-vpcand the caller's ownaws_routeresources, so a firewall change never re-routes unrelated VPC traffic, and the same module serves single-AZ, multi-AZ, IGW-fronted, or TGW-attached topologies (each needs a different route shape this module has no visibility into). firewall_endpoint_idskeying. Endpoint ids appear only after apply, three levels deep infirewall_status.sync_states[*].attachment.endpoint_id(a set, not stable-ordered). The module matches each sync state'ssubnet_idback tovar.subnet_mappingand re-keys the result by the caller's stable keys, so route resources get a predictablefirewall_endpoint_ids["az-a"]reference.- Eventual consistency on subnet association.
AssociateSubnets/DisassociateSubnets(driven bysubnet_mappingchanges) can lag beforefirewall_statusreflects the new endpoint — a next-run refresh may be needed before route resources see a new endpoint id. STRICT_ORDERis viral, andstateful_default_actionsrequires it. ASTRICT_ORDERpolicy rejects (at the API, not just discouraged) any referenced rule group not itself created withstateful_rule_options.rule_order = "STRICT_ORDER"; andstateful_default_actionscannot be set underDEFAULT_ACTION_ORDER. Both couplings are enforced by variablevalidation.tls_inspection_configuration_arnis a one-way door. Per AWS, it can only be set when the policy is first created — it cannot be added to an existing policy nor removed once set. Model it deliberately; there is no safe "unset" path.tags↔tags_all↔default_tags.var.tagsflows to the firewall, policy, and rule groups; per-rule-grouptagsmerge over module tags.tags_allis the computed union with providerdefault_tags, where resource tags win on key conflict. The logging configuration is not taggable.default_tagsis the caller's provider-block concern, never set here.- Destroy ordering. The logging configuration is destroyed before/with the firewall; the firewall is destroyed before its firewall subnets can be deleted (endpoint ENIs are still attached). Critically, the caller must remove their
aws_routeentries pointing at the firewall endpoints before/while destroying this module — otherwise routes point at endpoints that no longer exist (a live outage in a running VPC, not just a Terraform error).
Secure-by-default posture and the exact opt-out for each hardened default:
| Hardened default | Variable | Opt-out |
|---|---|---|
delete_protection = true |
delete_protection |
set false (example 9 — dev/throwaway only) |
subnet_change_protection = true |
subnet_change_protection |
set false to allow in-place subnet changes |
firewall_policy_change_protection = true |
firewall_policy_change_protection |
set false to allow re-pointing the policy |
availability_zone_change_protection = true |
availability_zone_change_protection |
set false (TGW mode) |
Stateless default = aws:forward_to_sfe (inspect, don't implicitly pass) |
firewall_policy.stateless_default_actions / _fragment_default_actions |
override to ["aws:pass"] / ["aws:drop"] |
| Logging created when a destination is supplied (FLOW + ALERT baseline) | logging |
omit logging (no config created) |
Deny-by-default (STRICT_ORDER + stateful_default_actions) available as opt-in |
firewall_policy.stateful_engine_options.rule_order |
leave at DEFAULT_ACTION_ORDER (schema default, backward-compatible) |
| CMK encryption available on every in-scope resource | kms_key_arn |
leave null (AWS-owned key) |
Rule group capacity is required — no silent under-provisioning |
rule_groups[*].capacity |
n/a — correctness requirement, not a toggle |
Additional principles:
- One composite, one posture — firewall + policy + rule groups + logging come from a single call, so a complete, observable inspection layer is atomic.
- Reference by key, not ARN — rule groups are referenced from the policy by a stable key the module resolves to the created ARN, eliminating manual ARN wiring and destroy-ordering hazards; supply
resource_arnfor externally-created groups. - Routing by the caller, never by the module — the blast radius of a firewall change never includes VPC re-routing.
- No credentials, no region variable — regional service; credentials resolve through the standard chain at the caller.
cd C:/GitHubCode/newawsmodules/terraform-aws-network-firewall
terraform init -backend=false
terraform validate
terraform fmt -check
# plan/apply require valid AWS credentials (profile / SSO / OIDC) AND a region:
terraform plan
terraform apply
terraform output firewall_endpoint_ids # then wire your aws_route resources
⚠️ Pin the module source with?ref=v1.0.0, never a branch. ℹ️ After apply, remember the firewall inspects nothing until the caller'saws_routeresources point traffic atfirewall_endpoint_ids.
terraform init -backend=false && terraform validate— offline structural validation, no credentials needed.terraform fmt -check— style gate.- Variable
validationblocks enforce: attachment-mode XOR, rule-grouprules/rule_groupXOR, exactly-onerules_sourcevariant,STRICT_ORDER↔stateful_default_actionscoupling, and all enums. (Cross-variable rules — e.g. attachment XOR — evaluate atplan/consoletime, notvalidate.) - For an apply-time smoke test: create a two-AZ VPC-attached firewall with one deny-list rule group and FLOW+ALERT logging, confirm
firewall_endpoint_idsreturns onevpce-...per subnet key, wire a testaws_route, thenterraform destroy(removing routes first).
arn = "arn:aws:network-firewall:us-east-2:123456789012:firewall/casey-prod-nfw"
id = "arn:aws:network-firewall:us-east-2:123456789012:firewall/casey-prod-nfw"
name = "casey-prod-nfw"
firewall_endpoint_ids = {
"az_a" = "vpce-0a1b2c3d4e5f60011"
"az_b" = "vpce-0a1b2c3d4e5f60022"
}
firewall_policy_arn = "arn:aws:network-firewall:us-east-2:123456789012:firewall-policy/casey-prod-nfw-policy"
rule_group_arns = {
"block_bad_domains" = "arn:aws:network-firewall:us-east-2:123456789012:stateful-rulegroup/block_bad_domains"
}
logging_configuration_id = "arn:aws:network-firewall:us-east-2:123456789012:firewall/casey-prod-nfw"
tags_all = { application = "member-portal", cost_center = "1234", data_class = "npi", managed_by = "terraform" }
- Firewall applies but inspects no traffic — you have not routed traffic through the endpoints. This module creates endpoints, not routes. Wire
aws_routeresources (protected subnet → endpoint, IGW edge → endpoint) usingfirewall_endpoint_ids. See example 13. firewall_endpoint_idsis empty / unknown at plan — endpoint ids only exist after apply (nested infirewall_status.sync_states). Reference them from resources created in the same or a later apply; a first plan shows them as unknown, which is expected.InvalidRequestException: only one of vpc_id / transit_gateway_id(or the plan-time validation error) — set exactly one attachment mode:vpc_id+subnet_mappingortransit_gateway_id+availability_zone_mapping.InsufficientCapacityException/ rule group rejected — the rule group'scapacityis too small for its rules, andcapacityis FORCE-NEW. Raise it (which replaces the group) and re-reference it from the policy.InvalidRequestExceptionmixing rule orders — aSTRICT_ORDERpolicy references a rule group created withDEFAULT_ACTION_ORDER. Setstateful_rule_options.rule_order = "STRICT_ORDER"on every referenced group.STRICT_ORDERis viral.stateful_default_actionsrejected — it is only valid whenstateful_engine_options.rule_order = "STRICT_ORDER". The module'svalidationcatches this at plan time.- Cannot delete the firewall —
delete_protection(orsubnet/firewall_policychange protection) is on (this module defaults them totrue). Set the relevant flag tofalse, apply, then destroy. - Destroy fails on the firewall subnet / a dangling route — the firewall must be destroyed before its subnets (endpoint ENIs), and the caller's
aws_routeentries pointing at the endpoints must be removed first. Order the teardown: routes → this module → firewall subnets. AccessDeniedExceptionat apply (validate passed) — the executing role is missing an action from the Required IAM Permissions table; grant it least-privilege, do not broaden to*.- KMS
AccessDeniedon a CMK-encrypted firewall — the key policy must grantnetwork-firewall.amazonaws.comkms:GenerateDataKey/kms:Decrypt. Wirekey_service_principals = ["network-firewall.amazonaws.com"]onterraform-aws-kms. - Cannot remove
tls_inspection_configuration_arn— it is create-only and cannot be removed once set. Replacing the policy is the only path. - Tag drift on apply — a key set both in provider
default_tagsandvar.tagsshows the resource value winning intags_all. Expected; align or drop the duplicate at the caller.
- Terraform Registry —
aws_networkfirewall_firewall,aws_networkfirewall_firewall_policy,aws_networkfirewall_rule_group,aws_networkfirewall_logging_configuration - AWS Network Firewall Developer Guide — Firewall subnets and route tables, Stateful rule evaluation order (default vs strict), Logging network traffic, Encryption at rest
- AWS General Reference — AWS Network Firewall service quotas
- Sibling modules —
terraform-aws-vpc,terraform-aws-transit-gateway,terraform-aws-kms,terraform-aws-cloudwatch-log-group,terraform-aws-s3-bucket,terraform-aws-kinesis-firehose,terraform-aws-cloudtrail
🧡 "Infrastructure as Code should be standardized, consistent, and secure."