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
- You manage more than a handful of VPC firewall rules and want them named, tagged, and logged identically across projects and environments.
- You scope access with target tags or target service accounts (e.g.,
allow-httpsonly on instances taggedweb) rather than opening ports network-wide. - You need Firewall Rules Logging on sensitive rules (SSH, RDP, database ports) for audit and incident response.
- You want to centralize both
INGRESSallow rules and high-priorityEGRESSdeny rules (e.g., block egress to the metadata-exfil range or to public internet from a data tier). - You are not ready to migrate to hierarchical firewall policies / network firewall policies and still need classic VPC firewall rules per network.
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 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/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
- Scope with target tags or service accounts, never network-wide — an allow rule with no
target_tags/target_service_accountsopens the port on every VM in the VPC. Prefer service-account targeting for the strongest binding, since tags can be self-assigned by anyone who can edit an instance. - Use priority deliberately and put denies first — lower numbers win, so keep override
denyrules numerically below theallowrules they must beat (e.g., deny at 100, allow at 1000). Reserve a band (e.g., 0–199) for guardrail denies. - Restrict SSH/RDP to IAP, not
0.0.0.0/0— allow TCP/22 and TCP/3389 only from35.235.240.0/20and tag-scope to bastions; this removes public brute-force surface while keeping access via Identity-Aware Proxy. - Turn on Firewall Rules Logging for sensitive and deny rules, but keep metadata excluded —
EXCLUDE_ALL_METADATAgives you connection-level audit trails at a fraction of the log-ingestion cost ofINCLUDE_ALL_METADATA; reserve full metadata for active investigations. - Enforce naming and validate inputs at the module boundary — the
{direction}-{action}-{purpose}convention plus CIDR/protocol validations make rules greppable, reviewable in PRs, and impossible to create with a malformed range. - Avoid
0.0.0.0/0source ranges except for genuinely public services or LB health-check ranges — for everything else use named CIDRs or tags, and review every public rule on a recurring cadence to catch drift and stale break-glass entries.