IaC GCP

Terraform Module: GCP Network Firewall Policy (NGFW) — one stateful, hierarchy-ready ruleset for your VPCs

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

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 configlive/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 configlive/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

TerraformGCPNetwork Firewall Policy (NGFW)ModuleIaC
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