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
- You are standing up a landing zone or shared-VPC host project and need a consistent, custom-mode network as the base layer.
- You want to guarantee every VPC in the org disables auto-subnets and uses global dynamic routing so Cloud Router learns routes across all regions.
- You need a network whose CIDRs you fully control for VPC peering, Cloud VPN/Interconnect, or Private Service Connect, where overlapping auto ranges would block connectivity.
- You are running large-MTU workloads (GKE Dataplane V2, HPC, storage) and need a 1460/8896 MTU set at creation time.
- Standardize this once rather than letting each team hand-roll
google_compute_networkblocks with inconsistent routing modes.
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 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/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
- Always run custom mode (
auto_create_subnetworks = false). Auto mode’s fixed10.128.0.0/9ranges overlap across VPCs and break peering, VPN, and Interconnect — and switching modes later forces destructive recreation of everything attached. - Prefer GLOBAL routing for landing zones and Shared VPC hosts so Cloud Routers advertise subnets across all regions; only choose REGIONAL when you deliberately want region-isolated route domains.
- Layer firewalls defensively: keep the lowest-priority deny-all ingress and add narrow, higher-priority allows (IAP, internal LB health-check ranges) — never rely on the implied-allow for egress without auditing it. Enable firewall/flow logs on sensitive paths.
- Plan CIDRs before subnets exist: the network is global and cheap, but renaming it forces replacement. Pick a non-overlapping supernet up front and document which
/20s belong to each region and environment. - Use the
self_linkoutput, not the raw name, when wiring subnets, Cloud Routers, peering, and Private Service Connect — it is the canonical reference and avoids project-scoping ambiguity. - Set MTU at creation: changing MTU later recreates the network. Choose 8896 only when every attached workload (GKE Dataplane V2, HPC, storage) supports jumbo frames end to end; otherwise stay at 1460.