IaC GCP

Terraform Module: GCP Firewall Rule — consistent, auditable VPC ingress/egress policy

Quick take — Build a reusable Terraform module for google_compute_firewall on hashicorp/google ~> 5.0: target-tag scoping, ingress/egress direction, logging, priority and validated protocol/port rules. 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 "firewall_rule" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-firewall-rule?ref=v1.0.0"

  name    = "..."           # Rule name; lowercase RFC1035. Convention `{direction}-{…
  network = "..."           # Self-link or name of the VPC network the rule attaches …
  rules   = ["...", "..."]  # Protocol/port specs; `ports` optional for icmp/all. At …
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

A GCP firewall rule (google_compute_firewall) is a stateful packet filter attached to a VPC network, not to a subnet or an instance. Each rule has a direction (INGRESS or EGRESS), a numeric priority (0–65535, lower wins), an action (allow or deny), one or more protocol/port allow/deny blocks, and a scoping mechanism that decides which VMs it applies to — either target tags, a target service account, or the whole network. The catch is that GCP’s implied rules already deny all ingress and allow all egress, so almost every real rule you write is a deliberate exception that needs to be tight, named consistently, and logged.

Wrapping it in a module matters because firewall rules are the part of a VPC most likely to drift, sprawl, and rot. Teams hand-create “allow-temp-debug” rules at priority 1000 with 0.0.0.0/0 and never remove them. A module forces every rule through the same shape: validated CIDR/protocol inputs, an explicit direction, mandatory naming, an opt-out for Firewall Rules Logging, and outputs you can reference downstream. It turns a free-for-all into a reviewable, greppable pattern.

When to use it

If you are building org-wide guardrails that must apply regardless of project, prefer google_compute_firewall_policy (hierarchical) instead — this module is for per-VPC rules.

Module structure

terraform-module-gcp-firewall-rule/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # GCP allows at most one source scoping mechanism on INGRESS and one
  # destination on EGRESS. Normalize empty lists to null so we omit the
  # argument entirely rather than sending [].
  source_ranges      = length(var.source_ranges) > 0 ? var.source_ranges : null
  destination_ranges = length(var.destination_ranges) > 0 ? var.destination_ranges : null
  source_tags        = length(var.source_tags) > 0 ? var.source_tags : null
  source_sa          = length(var.source_service_accounts) > 0 ? var.source_service_accounts : null
  target_tags        = length(var.target_tags) > 0 ? var.target_tags : null
  target_sa          = length(var.target_service_accounts) > 0 ? var.target_service_accounts : null
}

resource "google_compute_firewall" "this" {
  name        = var.name
  description = var.description
  network     = var.network
  project     = var.project
  direction   = var.direction
  priority    = var.priority
  disabled    = var.disabled

  # Source scoping (INGRESS) — at most one of ranges/tags/SAs in practice.
  source_ranges           = var.direction == "INGRESS" ? local.source_ranges : null
  source_tags             = var.direction == "INGRESS" ? local.source_tags : null
  source_service_accounts = var.direction == "INGRESS" ? local.source_sa : null

  # Destination scoping (EGRESS).
  destination_ranges = var.direction == "EGRESS" ? local.destination_ranges : null

  # Target scoping — which VMs the rule applies to. Mutually exclusive in GCP.
  target_tags             = local.target_sa == null ? local.target_tags : null
  target_service_accounts = local.target_sa

  # Exactly one of allow/deny is populated based on var.action.
  dynamic "allow" {
    for_each = var.action == "allow" ? var.rules : []
    content {
      protocol = allow.value.protocol
      ports    = allow.value.ports
    }
  }

  dynamic "deny" {
    for_each = var.action == "deny" ? var.rules : []
    content {
      protocol = deny.value.protocol
      ports    = deny.value.ports
    }
  }

  # Firewall Rules Logging. INCLUDE_ALL_METADATA is verbose/costly; default
  # to metadata-excluded when logging is on.
  dynamic "log_config" {
    for_each = var.enable_logging ? [1] : []
    content {
      metadata = var.log_metadata
    }
  }
}

variables.tf

variable "name" {
  description = "Name of the firewall rule. Lowercase, RFC1035; convention: {direction}-{allow|deny}-{purpose}, e.g. ingress-allow-https-web."
  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 RFC1035 (start with a letter, end alphanumeric)."
  }
}

variable "network" {
  description = "Self-link or name of the VPC network this rule attaches to (e.g. projects/p/global/networks/prod-vpc or just prod-vpc)."
  type        = string
}

variable "project" {
  description = "Project ID that hosts the network. Defaults to the provider project when null."
  type        = string
  default     = null
}

variable "description" {
  description = "Human-readable description; surfaced in console and audit logs. State the source and intent."
  type        = string
  default     = "Managed by Terraform"
}

variable "direction" {
  description = "Traffic direction: INGRESS or EGRESS."
  type        = string
  default     = "INGRESS"

  validation {
    condition     = contains(["INGRESS", "EGRESS"], var.direction)
    error_message = "direction must be INGRESS or EGRESS."
  }
}

variable "action" {
  description = "Whether matching traffic is allowed or denied."
  type        = string
  default     = "allow"

  validation {
    condition     = contains(["allow", "deny"], var.action)
    error_message = "action must be allow or deny."
  }
}

variable "priority" {
  description = "Rule priority, 0-65535. Lower numbers win. Keep deny rules numerically below the allow rules they must override."
  type        = number
  default     = 1000

  validation {
    condition     = var.priority >= 0 && var.priority <= 65535
    error_message = "priority must be between 0 and 65535."
  }
}

variable "rules" {
  description = "List of protocol/port specs. protocol is tcp|udp|icmp|esp|ah|sctp|ipip|all; ports is a list like [\"443\", \"8080-8090\"] (omit/empty for icmp/all)."
  type = list(object({
    protocol = string
    ports    = optional(list(string), [])
  }))

  validation {
    condition = alltrue([
      for r in var.rules :
      contains(["tcp", "udp", "icmp", "esp", "ah", "sctp", "ipip", "all"], lower(r.protocol))
    ])
    error_message = "Each rule.protocol must be one of tcp, udp, icmp, esp, ah, sctp, ipip, all."
  }

  validation {
    condition     = length(var.rules) > 0
    error_message = "At least one protocol/port rule is required."
  }
}

variable "source_ranges" {
  description = "INGRESS only: source CIDR ranges. Avoid 0.0.0.0/0 except for public load-balancer health checks or intentional public services."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for c in var.source_ranges : can(cidrnetmask(c))])
    error_message = "source_ranges must be valid IPv4 CIDRs (e.g. 10.0.0.0/8)."
  }
}

variable "destination_ranges" {
  description = "EGRESS only: destination CIDR ranges the rule applies to."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for c in var.destination_ranges : can(cidrnetmask(c))])
    error_message = "destination_ranges must be valid IPv4 CIDRs."
  }
}

variable "source_tags" {
  description = "INGRESS only: network tags identifying source VMs. Cannot be combined with source_service_accounts."
  type        = list(string)
  default     = []
}

variable "source_service_accounts" {
  description = "INGRESS only: source VM service-account emails. Cannot be combined with source_tags."
  type        = list(string)
  default     = []
}

variable "target_tags" {
  description = "Network tags of the VMs this rule applies to. Empty + no target SA means the whole network. Ignored if target_service_accounts is set."
  type        = list(string)
  default     = []
}

variable "target_service_accounts" {
  description = "Service-account emails of the VMs this rule applies to. Takes precedence over target_tags; the two are mutually exclusive in GCP."
  type        = list(string)
  default     = []
}

variable "enable_logging" {
  description = "Enable Firewall Rules Logging for this rule. Recommended for SSH/RDP/database and any deny rule."
  type        = bool
  default     = false
}

variable "log_metadata" {
  description = "Metadata verbosity when logging is on: EXCLUDE_ALL_METADATA (cheaper) or INCLUDE_ALL_METADATA."
  type        = string
  default     = "EXCLUDE_ALL_METADATA"

  validation {
    condition     = contains(["EXCLUDE_ALL_METADATA", "INCLUDE_ALL_METADATA"], var.log_metadata)
    error_message = "log_metadata must be EXCLUDE_ALL_METADATA or INCLUDE_ALL_METADATA."
  }
}

variable "disabled" {
  description = "If true, the rule exists but is not enforced. Useful for staged rollout or break-glass."
  type        = bool
  default     = false
}

outputs.tf

output "id" {
  description = "Fully-qualified ID of the firewall rule."
  value       = google_compute_firewall.this.id
}

output "name" {
  description = "Name of the firewall rule."
  value       = google_compute_firewall.this.name
}

output "self_link" {
  description = "URI (self-link) of the firewall rule, useful for references and audit tooling."
  value       = google_compute_firewall.this.self_link
}

output "network" {
  description = "Network the rule is attached to."
  value       = google_compute_firewall.this.network
}

output "direction" {
  description = "Effective direction of the rule (INGRESS or EGRESS)."
  value       = google_compute_firewall.this.direction
}

output "priority" {
  description = "Effective priority of the rule."
  value       = google_compute_firewall.this.priority
}

output "target_tags" {
  description = "Target network tags the rule applies to (empty when scoped by SA or whole-network)."
  value       = google_compute_firewall.this.target_tags
}

output "creation_timestamp" {
  description = "RFC3339 creation timestamp of the rule."
  value       = google_compute_firewall.this.creation_timestamp
}

How to use it

# Allow HTTPS from anywhere only to instances tagged "web", with logging on.
module "firewall_rule_web_https" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-firewall-rule?ref=v1.0.0"

  name      = "ingress-allow-https-web"
  network   = google_compute_network.prod_vpc.self_link
  project   = var.project_id
  direction = "INGRESS"
  action    = "allow"
  priority  = 1000

  rules = [
    { protocol = "tcp", ports = ["443"] },
  ]

  source_ranges = ["0.0.0.0/0"]
  target_tags   = ["web"]

  enable_logging = true
  log_metadata   = "EXCLUDE_ALL_METADATA"
}

# Allow SSH ONLY from Google's IAP TCP-forwarding range to bastion hosts.
module "firewall_rule_iap_ssh" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-firewall-rule?ref=v1.0.0"

  name      = "ingress-allow-ssh-iap-bastion"
  network   = google_compute_network.prod_vpc.self_link
  project   = var.project_id
  direction = "INGRESS"
  action    = "allow"
  priority  = 900

  rules         = [{ protocol = "tcp", ports = ["22"] }]
  source_ranges = ["35.235.240.0/20"] # IAP TCP forwarding
  target_tags   = ["bastion"]

  enable_logging = true
}

# High-priority EGRESS deny: stop the data tier reaching the public internet.
module "firewall_rule_data_egress_deny" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-firewall-rule?ref=v1.0.0"

  name               = "egress-deny-internet-data"
  network            = google_compute_network.prod_vpc.self_link
  project            = var.project_id
  direction          = "EGRESS"
  action             = "deny"
  priority           = 100
  rules              = [{ protocol = "all" }]
  destination_ranges = ["0.0.0.0/0"]
  target_tags        = ["data"]
  enable_logging     = true
}

# Downstream reference: feed the rule self-links into an audit/export sink.
resource "google_logging_project_sink" "fw_audit" {
  name        = "firewall-rule-audit"
  destination = "storage.googleapis.com/${google_storage_bucket.audit.name}"
  filter      = "resource.type=\"gce_firewall_rule\""

  description = "Tracks ${module.firewall_rule_iap_ssh.name} and related rules"
}

output "iap_ssh_rule_self_link" {
  value = module.firewall_rule_iap_ssh.self_link
}

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/firewall_rule/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-firewall-rule?ref=v1.0.0"
}

inputs = {
  name = "..."
  network = "..."
  rules = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/firewall_rule && 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 Rule name; lowercase RFC1035. Convention {direction}-{allow|deny}-{purpose}.
network string Yes Self-link or name of the VPC network the rule attaches to.
project string null No Project ID hosting the network; defaults to provider project.
description string "Managed by Terraform" No Human-readable description shown in console and audit logs.
direction string "INGRESS" No INGRESS or EGRESS.
action string "allow" No allow or deny; selects the allow/deny block.
priority number 1000 No 0–65535, lower wins. Keep denies below the allows they override.
rules list(object({protocol, ports})) Yes Protocol/port specs; ports optional for icmp/all. At least one required.
source_ranges list(string) [] No INGRESS source CIDRs; validated as IPv4 CIDRs.
destination_ranges list(string) [] No EGRESS destination CIDRs; validated as IPv4 CIDRs.
source_tags list(string) [] No INGRESS source network tags; mutually exclusive with source SAs.
source_service_accounts list(string) [] No INGRESS source SA emails; mutually exclusive with source tags.
target_tags list(string) [] No Tags of VMs the rule applies to; empty = whole network. Ignored if target SA set.
target_service_accounts list(string) [] No SA emails of VMs the rule applies to; precedence over target_tags.
enable_logging bool false No Enable Firewall Rules Logging for this rule.
log_metadata string "EXCLUDE_ALL_METADATA" No EXCLUDE_ALL_METADATA or INCLUDE_ALL_METADATA.
disabled bool false No If true, rule exists but is not enforced.

Outputs

Name Description
id Fully-qualified ID of the firewall rule.
name Name of the firewall rule.
self_link URI (self-link) of the rule, for references and audit tooling.
network Network the rule is attached to.
direction Effective direction (INGRESS or EGRESS).
priority Effective priority of the rule.
target_tags Target network tags the rule applies to.
creation_timestamp RFC3339 creation timestamp of the rule.

Enterprise scenario

A fintech platform team runs ~40 GCP projects under a shared-VPC host project and must prove to auditors that every internet-facing port is intentional and logged. They standardize on this module so that all ingress rules are tag-scoped (web, api, bastion), SSH is permitted only from the IAP range 35.235.240.0/20, and a priority-100 egress-deny-internet-data rule blocks the PCI data tier from reaching 0.0.0.0/0. Because logging is forced on for every SSH/RDP/database and deny rule, their SIEM ingests gce_firewall_rule events directly, and a quarterly terraform plan across all projects flags any out-of-band “temp debug” rule that someone added in the console.

Best practices

TerraformGCPFirewall RuleModuleIaC
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