IaC GCP

Terraform Module: GCP VPC Network — Custom-Mode Foundation Networking Done Right

Quick take — A reusable hashicorp/google Terraform module for google_compute_network: custom-mode VPC with regional routing, global/regional routing modes, MTU control, optional auto-created firewall rules, and clean outputs for downstream subnets and peering. 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 "vpc_network" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-network?ref=v1.0.0"

  project_id = "..."  # ID of the GCP project that will own the VPC network.
}

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

What this module is

A Google Cloud VPC network (google_compute_network) is the global, software-defined backbone that every other networked resource hangs off of — subnets, Compute Engine VMs, GKE clusters, Cloud SQL via private services access, internal load balancers, and VPC peering all attach to it. Unlike AWS or Azure, a GCP VPC is a global resource: a single network spans all regions, and subnets are the regional constructs carved out of it.

The single most important decision a VPC makes is its subnet mode. In auto mode GCP silently creates a /20 subnet in every region using a fixed, overlapping 10.128.0.0/9 range — which collides the moment you try to peer two auto-mode VPCs or connect on-prem via Cloud VPN/Interconnect. Production networks almost always want custom mode (auto_create_subnetworks = false) so you own every CIDR explicitly.

This module wraps google_compute_network so that custom mode, the routing mode, MTU, and other foundational toggles are enforced by default and driven by variables. That turns a network that is easy to misconfigure (and painful to recreate, since it forces replacement of everything attached) into a single audited, versioned, reusable building block.

When to use it

Use plain subnet/firewall modules on top of this; this module deliberately owns the network resource (and optionally its default deny-all hardening), not the subnets.

Module structure

terraform-module-gcp-vpc-network/
├── 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 {
  # Compose a deterministic name: "<prefix>-vpc" unless an explicit name is given.
  network_name = coalesce(var.network_name, "${var.name_prefix}-vpc")
}

resource "google_compute_network" "this" {
  project = var.project_id
  name    = local.network_name

  description = var.description

  # Custom mode is the production default: own every CIDR explicitly.
  auto_create_subnetworks = var.auto_create_subnetworks

  # REGIONAL = Cloud Routers advertise only same-region subnets.
  # GLOBAL   = routes are learned/advertised across all regions.
  routing_mode = var.routing_mode

  # 1460 (default), 1500, or jumbo 8896 for GKE Dataplane V2 / HPC.
  mtu = var.mtu

  # Skip creation of the implicit default route to the internet gateway
  # when you want fully isolated egress controlled by custom routes.
  delete_default_routes_on_create = var.delete_default_routes_on_create

  # Network Connectivity Center hub membership for the VPC, when used.
  network_firewall_policy_enforcement_order = var.firewall_policy_enforcement_order
}

# Optional hardening: an explicit deny-all ingress rule at the lowest
# priority so nothing is reachable until a higher-priority allow exists.
resource "google_compute_firewall" "deny_all_ingress" {
  count = var.create_deny_all_ingress ? 1 : 0

  project = var.project_id
  name    = "${local.network_name}-deny-all-ingress"
  network = google_compute_network.this.id

  direction = "INGRESS"
  priority  = 65534

  deny {
    protocol = "all"
  }

  source_ranges = ["0.0.0.0/0"]

  log_config {
    metadata = "INCLUDE_ALL_METADATA"
  }
}

# Optional: allow Google's IAP TCP forwarding range so SSH/RDP can be
# brokered through Identity-Aware Proxy instead of public IPs.
resource "google_compute_firewall" "allow_iap" {
  count = var.allow_iap_ingress ? 1 : 0

  project = var.project_id
  name    = "${local.network_name}-allow-iap-ingress"
  network = google_compute_network.this.id

  direction = "INGRESS"
  priority  = 1000

  allow {
    protocol = "tcp"
    ports    = var.iap_allowed_ports
  }

  # 35.235.240.0/20 is the fixed source range for IAP TCP forwarding.
  source_ranges = ["35.235.240.0/20"]

  log_config {
    metadata = "INCLUDE_ALL_METADATA"
  }
}

variables.tf

variable "project_id" {
  type        = string
  description = "ID of the GCP project that will own the VPC network."
}

variable "name_prefix" {
  type        = string
  description = "Short prefix used to derive the network name (e.g. \"prod-shared\") when network_name is not set."
  default     = "kv"

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]{0,20})$", var.name_prefix))
    error_message = "name_prefix must be lowercase, start with a letter, and be 1-21 chars (letters, digits, hyphens)."
  }
}

variable "network_name" {
  type        = string
  description = "Explicit network name. If null, the name is derived as \"<name_prefix>-vpc\"."
  default     = null

  validation {
    condition     = var.network_name == null || can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.network_name))
    error_message = "network_name must match RFC1035: lowercase, start with a letter, end alphanumeric, max 63 chars."
  }
}

variable "description" {
  type        = string
  description = "Free-text description attached to the VPC network."
  default     = "Managed by Terraform (kloudvin terraform-module-gcp-vpc-network)."
}

variable "auto_create_subnetworks" {
  type        = bool
  description = "Whether to use auto subnet mode. Keep false for production (custom mode) so CIDRs are explicit."
  default     = false
}

variable "routing_mode" {
  type        = string
  description = "Dynamic routing mode for Cloud Routers attached to this VPC: REGIONAL or GLOBAL."
  default     = "GLOBAL"

  validation {
    condition     = contains(["REGIONAL", "GLOBAL"], var.routing_mode)
    error_message = "routing_mode must be either REGIONAL or GLOBAL."
  }
}

variable "mtu" {
  type        = number
  description = "Network MTU in bytes. 1460 (default), 1500, or 8896 for jumbo frames."
  default     = 1460

  validation {
    condition     = var.mtu >= 1300 && var.mtu <= 8896
    error_message = "mtu must be between 1300 and 8896 bytes."
  }
}

variable "delete_default_routes_on_create" {
  type        = bool
  description = "If true, the default 0.0.0.0/0 route to the internet gateway is not created with the network."
  default     = false
}

variable "firewall_policy_enforcement_order" {
  type        = string
  description = "Order in which network firewall policies and VPC firewall rules are evaluated."
  default     = "AFTER_CLASSIC_FIREWALL"

  validation {
    condition = contains(
      ["BEFORE_CLASSIC_FIREWALL", "AFTER_CLASSIC_FIREWALL"],
      var.firewall_policy_enforcement_order
    )
    error_message = "firewall_policy_enforcement_order must be BEFORE_CLASSIC_FIREWALL or AFTER_CLASSIC_FIREWALL."
  }
}

variable "create_deny_all_ingress" {
  type        = bool
  description = "Create an explicit lowest-priority deny-all ingress firewall rule for defense in depth."
  default     = true
}

variable "allow_iap_ingress" {
  type        = bool
  description = "Create a firewall rule allowing the IAP TCP forwarding range (35.235.240.0/20)."
  default     = false
}

variable "iap_allowed_ports" {
  type        = list(string)
  description = "TCP ports to allow from the IAP range when allow_iap_ingress is true (e.g. SSH 22, RDP 3389)."
  default     = ["22", "3389"]
}

outputs.tf

output "network_id" {
  description = "Fully-qualified resource ID of the VPC network (projects/<id>/global/networks/<name>)."
  value       = google_compute_network.this.id
}

output "network_name" {
  description = "Name of the VPC network, for use as the `network` argument on subnets and firewall rules."
  value       = google_compute_network.this.name
}

output "network_self_link" {
  description = "URI self_link of the VPC network, required by many subnet/router/peering resources."
  value       = google_compute_network.this.self_link
}

output "gateway_ipv4" {
  description = "IPv4 address of the gateway for the default network interface."
  value       = google_compute_network.this.gateway_ipv4
}

output "routing_mode" {
  description = "Effective dynamic routing mode (REGIONAL or GLOBAL)."
  value       = google_compute_network.this.routing_mode
}

output "deny_all_ingress_rule_name" {
  description = "Name of the deny-all ingress firewall rule, or null when not created."
  value       = try(google_compute_firewall.deny_all_ingress[0].name, null)
}

How to use it

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

  project_id   = "kv-prod-host-9f3a"
  name_prefix  = "prod-shared"
  routing_mode = "GLOBAL"
  mtu          = 8896 # jumbo frames for GKE Dataplane V2

  create_deny_all_ingress = true
  allow_iap_ingress       = true
  iap_allowed_ports       = ["22"]
}

# Downstream: carve a regional subnet out of the network this module created.
resource "google_compute_subnetwork" "gke" {
  project       = "kv-prod-host-9f3a"
  name          = "prod-gke-asia-south1"
  region        = "asia-south1"
  network       = module.vpc_network.network_self_link # output consumed here
  ip_cidr_range = "10.40.0.0/20"

  private_ip_google_access = true

  secondary_ip_range {
    range_name    = "pods"
    ip_cidr_range = "10.44.0.0/14"
  }

  secondary_ip_range {
    range_name    = "services"
    ip_cidr_range = "10.48.0.0/20"
  }

  log_config {
    aggregation_interval = "INTERVAL_10_MIN"
    flow_sampling        = 0.5
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
}

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

cd live/prod/vpc_network && 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 ID of the GCP project that will own the VPC network.
name_prefix string "kv" No Prefix used to derive the network name when network_name is null.
network_name string null No Explicit RFC1035 network name; overrides the derived name.
description string "Managed by Terraform…" No Free-text description on the network.
auto_create_subnetworks bool false No Auto subnet mode toggle; keep false for custom mode in production.
routing_mode string "GLOBAL" No Cloud Router dynamic routing mode: REGIONAL or GLOBAL.
mtu number 1460 No Network MTU in bytes (1300–8896; use 8896 for jumbo frames).
delete_default_routes_on_create bool false No Suppress the default internet-gateway route at creation.
firewall_policy_enforcement_order string "AFTER_CLASSIC_FIREWALL" No Evaluation order for firewall policies vs. classic rules.
create_deny_all_ingress bool true No Create a lowest-priority deny-all ingress rule.
allow_iap_ingress bool false No Allow the IAP TCP forwarding range (35.235.240.0/20).
iap_allowed_ports list(string) ["22","3389"] No TCP ports opened to the IAP range when allow_iap_ingress is true.

Outputs

Name Description
network_id Fully-qualified network resource ID (projects/<id>/global/networks/<name>).
network_name Network name, for use as the network argument on subnets and firewall rules.
network_self_link URI self_link required by many subnet/router/peering resources.
gateway_ipv4 IPv4 gateway address for the default network interface.
routing_mode Effective dynamic routing mode (REGIONAL or GLOBAL).
deny_all_ingress_rule_name Name of the deny-all ingress rule, or null when not created.

Enterprise scenario

A retail group runs a Shared VPC host project where the platform team owns one custom-mode network and a dozen service projects attach their own subnets. By standardizing on this module with routing_mode = "GLOBAL", every Cloud Router in asia-south1, europe-west2, and us-central1 automatically learns subnet routes across regions, so the SD-WAN spoke connected via HA VPN sees the entire internal estate without per-region route plumbing. The default create_deny_all_ingress rule and the optional IAP allow rule mean no workload is internet-reachable on day one, satisfying the security team’s “no public SSH” control while still letting engineers reach VMs through Identity-Aware Proxy.

Best practices

TerraformGCPVPC NetworkModuleIaC
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