Quick take — Wrap google_compute_network_firewall_policy in a reusable Terraform module: stateful global rules, named-port and IP-range matching, secure tags, association to a VPC, and clean outputs for downstream use. 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 "google" {
project = "my-project"
region = "us-central1"
}
module "network_security_policy" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-network-security-policy?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the firewall policy.
name = "..." # Policy name; validated as lowercase RFC1035 (1-63 chars…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A GCP Network Firewall Policy is the modern, global replacement for legacy VPC firewall rules (google_compute_firewall). Instead of a flat list of per-VPC rules with priority collisions, a google_compute_network_firewall_policy is a container of ordered rules that you attach to one or more VPC networks via an explicit association. It brings the next-gen firewall (NGFW) feature set that legacy rules never had: stateful inspection by default, rule evaluation by priority across the whole policy, secure tags (IAM-governed network tags that cannot be spoofed by editing an instance), and the ability to mix ALLOW, DENY, and GOTO_NEXT actions in a single, version-controlled object.
This module wraps the policy, its rules, and the network association behind a small, var-driven interface. The reason to make it a module rather than hand-writing rules is that real environments need the same baseline applied consistently — egress-deny-by-default, allow Google APIs over Private Google Access, allow health-check probes, allow internal east-west on named ports — across dozens of projects and VPCs. A module lets you express that baseline once, validate inputs (priorities in range, directions valid, actions sane), and stamp it out reproducibly while still letting each consumer pass in app-specific rules.
When to use it
- You are migrating off legacy VPC firewall rules and want stateful, policy-based management with secure tags instead of plain network tags.
- You need a shared security baseline (deny-all egress, allow Google APIs/health checks) enforced identically across many VPCs and projects.
- You want rules expressed as data, so platform/security teams own the policy and app teams only request named ports or tags.
- You require GOTO_NEXT / layered evaluation — e.g. a global firewall policy plus per-VPC policies — which legacy rules can’t model.
- You are standing up a landing zone where firewall posture must be auditable in git and applied before workloads land.
Reach for plain google_compute_firewall only for one-off lab VPCs; for anything production or multi-project, the network firewall policy is the right primitive.
Module structure
terraform-module-gcp-network-security-policy/
├── versions.tf # provider + Terraform version pins
├── main.tf # policy + rules + VPC association
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name + association + rule metadata
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Normalize: every rule gets a stable map key so for_each is deterministic
# even if the caller reorders the input list.
rules_by_key = {
for r in var.rules : tostring(r.priority) => r
}
}
resource "google_compute_network_firewall_policy" "this" {
name = var.name
project = var.project_id
description = var.description
}
resource "google_compute_network_firewall_policy_rule" "this" {
for_each = local.rules_by_key
firewall_policy = google_compute_network_firewall_policy.this.name
project = var.project_id
priority = each.value.priority
direction = each.value.direction
action = each.value.action
rule_name = each.value.rule_name
description = each.value.description
disabled = each.value.disabled
enable_logging = each.value.enable_logging
# GOTO_NEXT rules cannot target secure tags; guarded by validation below.
target_secure_tags {
name = ""
}
match {
# Layer-4 protocol/port constraints.
dynamic "layer4_configs" {
for_each = each.value.layer4_configs
content {
ip_protocol = layer4_configs.value.ip_protocol
ports = layer4_configs.value.ports
}
}
# Only set the range block that applies to the rule's direction.
src_ip_ranges = each.value.direction == "INGRESS" ? each.value.ip_ranges : null
dest_ip_ranges = each.value.direction == "EGRESS" ? each.value.ip_ranges : null
}
lifecycle {
# target_secure_tags is set to a sentinel above for schema reasons; we let
# downstream tag wiring be additive and ignore drift on the empty default.
ignore_changes = [target_secure_tags]
}
}
resource "google_compute_network_firewall_policy_association" "this" {
count = var.attached_network == null ? 0 : 1
name = "${var.name}-assoc"
project = var.project_id
firewall_policy = google_compute_network_firewall_policy.this.id
attachment_target = var.attached_network
}
# variables.tf
variable "project_id" {
description = "GCP project ID that owns the firewall policy."
type = string
}
variable "name" {
description = "Name of the network firewall policy (lowercase, RFC1035)."
type = string
validation {
condition = can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.name))
error_message = "name must be 1-63 chars, lowercase, start with a letter, RFC1035-compliant."
}
}
variable "description" {
description = "Human-readable description of the policy's intent."
type = string
default = "Managed by Terraform"
}
variable "attached_network" {
description = "Self-link of the VPC network to associate (e.g. projects/p/global/networks/vpc). Null = create policy only, attach later."
type = string
default = null
}
variable "rules" {
description = "List of firewall rules. priority must be unique across the list."
type = list(object({
priority = number
direction = string
action = string
rule_name = optional(string)
description = optional(string, "")
disabled = optional(bool, false)
enable_logging = optional(bool, true)
ip_ranges = optional(list(string), [])
layer4_configs = list(object({
ip_protocol = string
ports = optional(list(string), [])
}))
}))
default = []
validation {
condition = length(distinct([for r in var.rules : r.priority])) == length(var.rules)
error_message = "Each rule priority must be unique within the policy."
}
validation {
condition = alltrue([for r in var.rules : r.priority >= 0 && r.priority <= 2147483647])
error_message = "Rule priority must be between 0 and 2147483647."
}
validation {
condition = alltrue([for r in var.rules : contains(["INGRESS", "EGRESS"], r.direction)])
error_message = "Rule direction must be INGRESS or EGRESS."
}
validation {
condition = alltrue([for r in var.rules : contains(["allow", "deny", "goto_next"], r.action)])
error_message = "Rule action must be allow, deny, or goto_next."
}
}
# outputs.tf
output "id" {
description = "Fully-qualified ID of the network firewall policy."
value = google_compute_network_firewall_policy.this.id
}
output "name" {
description = "Name of the network firewall policy."
value = google_compute_network_firewall_policy.this.name
}
output "self_link" {
description = "Server-defined URL (self-link) of the firewall policy."
value = google_compute_network_firewall_policy.this.self_link
}
output "association_name" {
description = "Name of the VPC association, or null if no network was attached."
value = try(google_compute_network_firewall_policy_association.this[0].name, null)
}
output "rule_priorities" {
description = "Sorted list of rule priorities currently managed by this policy."
value = sort([for r in google_compute_network_firewall_policy_rule.this : r.priority])
}
How to use it
module "network_firewall_policy_ngfw_baseline" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-network-security-policy?ref=v1.0.0"
project_id = "kv-prod-net-01"
name = "kv-prod-baseline-fwp"
description = "Org baseline NGFW: deny egress by default, allow Google APIs + health checks + internal app tier."
attached_network = google_compute_network.vpc.id
rules = [
# 1000 — allow internal east-west to the app tier on named ports
{
priority = 1000
direction = "INGRESS"
action = "allow"
rule_name = "allow-internal-app"
ip_ranges = ["10.0.0.0/8"]
layer4_configs = [
{ ip_protocol = "tcp", ports = ["8080", "8443"] }
]
},
# 1100 — allow GCP load-balancer / health-check probe ranges
{
priority = 1100
direction = "INGRESS"
action = "allow"
rule_name = "allow-health-checks"
ip_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
layer4_configs = [
{ ip_protocol = "tcp", ports = ["80", "443", "8443"] }
]
},
# 2000 — allow egress to Google APIs (Private Google Access VIP)
{
priority = 2000
direction = "EGRESS"
action = "allow"
rule_name = "allow-google-apis"
ip_ranges = ["199.36.153.8/30"]
layer4_configs = [
{ ip_protocol = "tcp", ports = ["443"] }
]
},
# 65000 — deny all other egress (catch-all, logged)
{
priority = 65000
direction = "EGRESS"
action = "deny"
rule_name = "deny-all-egress"
enable_logging = true
ip_ranges = ["0.0.0.0/0"]
layer4_configs = [
{ ip_protocol = "all" }
]
}
]
}
# Downstream reference: surface the policy ID to a monitoring/inventory module
# so security dashboards can pin alerts to this exact policy.
resource "google_monitoring_dashboard" "fw_inventory" {
dashboard_json = jsonencode({
displayName = "NGFW: ${module.network_firewall_policy_ngfw_baseline.name}"
labels = { firewall_policy_id = module.network_firewall_policy_ngfw_baseline.id }
})
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/network_security_policy/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-network-security-policy?ref=v1.0.0"
}
inputs = {
project_id = "..."
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/network_security_policy && 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 |
|---|---|---|---|---|
project_id |
string |
— | Yes | GCP project ID that owns the firewall policy. |
name |
string |
— | Yes | Policy name; validated as lowercase RFC1035 (1-63 chars). |
description |
string |
"Managed by Terraform" |
No | Human-readable description of the policy’s intent. |
attached_network |
string |
null |
No | Self-link of the VPC to associate. Null creates the policy only. |
rules |
list(object) |
[] |
No | Ordered rules; each needs priority, direction, action, layer4_configs. Optional: rule_name, description, disabled, enable_logging, ip_ranges. Priorities must be unique. |
Outputs
| Name | Description |
|---|---|
id |
Fully-qualified ID of the network firewall policy. |
name |
Name of the network firewall policy. |
self_link |
Server-defined URL (self-link) of the policy. |
association_name |
Name of the VPC association, or null if no network was attached. |
rule_priorities |
Sorted list of rule priorities managed by the policy. |
Enterprise scenario
A retail platform runs 40+ application VPCs across dev, stage, and prod projects, each previously carrying drifting legacy firewall rules. The platform team adopts this module in their landing-zone pipeline: a single kv-<env>-baseline-fwp policy is stamped into every project with a deny-all-egress catch-all at priority 65000, an allow for the Private Google Access VIP (199.36.153.8/30) so workloads can reach Cloud Storage and Artifact Registry without public IPs, and the GCP health-check ranges for load balancers. Because the rules live in git as the rules list, a security review that tightens egress (say, removing port 80) is a one-line PR that rolls out identically to all 40 VPCs, and the rule_priorities output feeds a Cloud Monitoring dashboard that flags any project whose policy drifts from the expected priority set.
Best practices
- Deny egress by default, allow narrowly. Put a
deny0.0.0.0/0rule at a high priority number (e.g. 65000) and add specific low-number allows above it — NGFW evaluates lowest priority first, so the catch-all only fires when nothing else matched. - Prefer secure tags over IP ranges for east-west. Secure tags are IAM-governed and survive instance recreation, unlike CIDR-pinned rules; reserve IP-range rules for fixed Google-owned ranges (health checks
35.191.0.0/16+130.211.0.0/22, PGA VIP199.36.153.8/30). - Always log deny rules and sensitive allows (
enable_logging = true) so Firewall Rules Logging captures blocked egress for incident response — but skip logging on chatty internal allows to control Cloud Logging cost. - Keep priorities sparse and banded (1000s for app allows, 2000s for egress allows, 65000 for catch-all) so future rules slot in without renumbering and triggering churn in
for_each. - Associate the policy explicitly and early in the landing zone so VPCs are never live without a posture; use the
attached_networkvariable rather than out-of-band console clicks to keep state authoritative. - Name policies by env and purpose (
kv-prod-baseline-fwp) and pin the module with?ref=vX.Y.Zso a baseline change is a reviewable, versioned rollout across every project.