Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for google_compute_subnetwork: regional CIDRs, GKE secondary IP ranges, Private Google Access, and VPC Flow Logs in one wrapper. 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 "subnet" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-subnet?ref=v1.0.0"
name = "..." # Subnetwork name; lowercase letter first, then lowercase…
project_id = "..." # GCP project ID that owns the subnetwork.
region = "..." # Region for the subnet (subnets are regional).
network = "..." # Self-link or name of the custom-mode VPC network.
ip_cidr_range = "..." # Primary IPv4 CIDR, e.g. `10.20.0.0/20`.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A GCP subnet (google_compute_subnetwork) is a regional slice of an existing VPC network that carves out an internal IPv4 (and optionally IPv6) CIDR range. Unlike AWS subnets, GCP subnets are not zonal — a single subnet spans every zone in its region, which means your IP planning happens per-region, not per-AZ. This is also where you make three decisions that are painful to retrofit later: the primary CIDR, any secondary IP ranges (used by GKE for Pod and Service aliasing), and whether Private Google Access is on so VMs without external IPs can still reach Google APIs.
Wrapping google_compute_subnetwork in a module matters because these settings get repeated across every region and environment, and the bits people forget — turning on VPC Flow Logs with sane sampling, enabling Private Google Access, naming secondary ranges consistently so GKE clusters can reference them — are exactly the ones that cause production incidents or security-audit findings. A module makes the safe defaults the default and keeps Pod/Service range naming identical across dev, staging, and prod so your GKE Terraform never has to special-case anything.
When to use it
- You are standing up a shared VPC or hub-and-spoke topology and need one consistent way to create subnets across multiple regions and projects.
- You run GKE and need subnets with named secondary ranges for Pods and Services (VPC-native / alias IP clusters require these).
- You want VMs without external IPs to reach Google APIs and services (Cloud Storage, Artifact Registry, BigQuery) via Private Google Access.
- You have a security or compliance requirement to capture VPC Flow Logs for east-west and egress traffic.
- You are migrating from auto-mode VPC subnets to custom-mode subnets and want IP ranges declared as code instead of auto-generated.
If you only need a throwaway sandbox in an auto-mode network, a raw resource is fine — but anything that GKE or a security team touches benefits from this module.
Module structure
terraform-module-gcp-subnet/
├── 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
resource "google_compute_subnetwork" "this" {
name = var.name
project = var.project_id
region = var.region
network = var.network
ip_cidr_range = var.ip_cidr_range
description = var.description
private_ip_google_access = var.private_ip_google_access
# Dual-stack / IPv6 support (only set when a stack other than IPv4 is requested)
stack_type = var.stack_type
ipv6_access_type = var.stack_type == "IPV4_IPV6" ? var.ipv6_access_type : null
# Named secondary ranges — required by VPC-native GKE for Pods and Services
dynamic "secondary_ip_range" {
for_each = var.secondary_ip_ranges
content {
range_name = secondary_ip_range.value.range_name
ip_cidr_range = secondary_ip_range.value.ip_cidr_range
}
}
# VPC Flow Logs — enabled by toggling the log_config block on
dynamic "log_config" {
for_each = var.flow_logs_enabled ? [1] : []
content {
aggregation_interval = var.flow_logs_aggregation_interval
flow_sampling = var.flow_logs_sampling
metadata = var.flow_logs_metadata
metadata_fields = var.flow_logs_metadata == "CUSTOM_METADATA" ? var.flow_logs_metadata_fields : null
filter_expr = var.flow_logs_filter_expr
}
}
}
# variables.tf
variable "name" {
type = string
description = "Name of the subnetwork. Lowercase letters, digits, and hyphens; must start with a letter."
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 letter first, then lowercase letters, digits, or hyphens."
}
}
variable "project_id" {
type = string
description = "The GCP project ID that owns the subnetwork."
}
variable "region" {
type = string
description = "Region for the subnetwork (e.g. asia-south1). Subnets are regional, not zonal."
}
variable "network" {
type = string
description = "Self-link or name of the VPC network. Must be a custom-mode (auto_create_subnetworks = false) network to add custom subnets."
}
variable "ip_cidr_range" {
type = string
description = "Primary IPv4 CIDR range for the subnet, e.g. 10.20.0.0/20."
validation {
condition = can(cidrhost(var.ip_cidr_range, 0))
error_message = "ip_cidr_range must be a valid IPv4 CIDR, e.g. 10.20.0.0/20."
}
}
variable "description" {
type = string
description = "Optional human-readable description of the subnetwork."
default = null
}
variable "private_ip_google_access" {
type = bool
description = "Allow VMs without external IPs to reach Google APIs and services via internal routing."
default = true
}
variable "stack_type" {
type = string
description = "IP stack for the subnet: IPV4_ONLY or IPV4_IPV6."
default = "IPV4_ONLY"
validation {
condition = contains(["IPV4_ONLY", "IPV4_IPV6"], var.stack_type)
error_message = "stack_type must be IPV4_ONLY or IPV4_IPV6."
}
}
variable "ipv6_access_type" {
type = string
description = "IPv6 access type when stack_type is IPV4_IPV6: INTERNAL or EXTERNAL."
default = "INTERNAL"
validation {
condition = contains(["INTERNAL", "EXTERNAL"], var.ipv6_access_type)
error_message = "ipv6_access_type must be INTERNAL or EXTERNAL."
}
}
variable "secondary_ip_ranges" {
type = list(object({
range_name = string
ip_cidr_range = string
}))
description = "Named secondary ranges for alias IPs (GKE Pods/Services). Each needs a unique range_name and a valid CIDR."
default = []
validation {
condition = alltrue([
for r in var.secondary_ip_ranges : can(cidrhost(r.ip_cidr_range, 0))
])
error_message = "Every secondary_ip_ranges entry must have a valid IPv4 CIDR in ip_cidr_range."
}
}
variable "flow_logs_enabled" {
type = bool
description = "Enable VPC Flow Logs for this subnet."
default = true
}
variable "flow_logs_aggregation_interval" {
type = string
description = "Flow log aggregation interval."
default = "INTERVAL_5_SEC"
validation {
condition = contains([
"INTERVAL_5_SEC", "INTERVAL_30_SEC", "INTERVAL_1_MIN",
"INTERVAL_5_MIN", "INTERVAL_10_MIN", "INTERVAL_15_MIN"
], var.flow_logs_aggregation_interval)
error_message = "Invalid flow_logs_aggregation_interval."
}
}
variable "flow_logs_sampling" {
type = number
description = "Fraction of flows to sample (0.0 to 1.0). Lower values reduce logging cost."
default = 0.5
validation {
condition = var.flow_logs_sampling >= 0 && var.flow_logs_sampling <= 1
error_message = "flow_logs_sampling must be between 0.0 and 1.0."
}
}
variable "flow_logs_metadata" {
type = string
description = "Metadata to include in flow logs: INCLUDE_ALL_METADATA, EXCLUDE_ALL_METADATA, or CUSTOM_METADATA."
default = "INCLUDE_ALL_METADATA"
validation {
condition = contains([
"INCLUDE_ALL_METADATA", "EXCLUDE_ALL_METADATA", "CUSTOM_METADATA"
], var.flow_logs_metadata)
error_message = "flow_logs_metadata must be INCLUDE_ALL_METADATA, EXCLUDE_ALL_METADATA, or CUSTOM_METADATA."
}
}
variable "flow_logs_metadata_fields" {
type = list(string)
description = "Metadata fields to include when flow_logs_metadata is CUSTOM_METADATA."
default = []
}
variable "flow_logs_filter_expr" {
type = string
description = "CEL expression to filter which flows are logged. Defaults to logging all flows."
default = "true"
}
# outputs.tf
output "id" {
description = "The fully qualified identifier of the subnetwork."
value = google_compute_subnetwork.this.id
}
output "name" {
description = "The name of the subnetwork."
value = google_compute_subnetwork.this.name
}
output "self_link" {
description = "The URI/self-link of the subnetwork, used by GKE, instances, and routers."
value = google_compute_subnetwork.this.self_link
}
output "ip_cidr_range" {
description = "The primary IPv4 CIDR range of the subnetwork."
value = google_compute_subnetwork.this.ip_cidr_range
}
output "gateway_address" {
description = "The gateway (first usable) address of the primary range."
value = google_compute_subnetwork.this.gateway_address
}
output "region" {
description = "The region the subnetwork lives in."
value = google_compute_subnetwork.this.region
}
output "secondary_range_names" {
description = "Map of range_name => ip_cidr_range for all secondary ranges (e.g. GKE Pods/Services)."
value = {
for r in google_compute_subnetwork.this.secondary_ip_range : r.range_name => r.ip_cidr_range
}
}
How to use it
module "subnet" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-subnet?ref=v1.0.0"
name = "gke-prod-asia-south1"
project_id = "kloudvin-prod"
region = "asia-south1"
network = google_compute_network.vpc.self_link
ip_cidr_range = "10.20.0.0/20"
private_ip_google_access = true
secondary_ip_ranges = [
{
range_name = "gke-pods"
ip_cidr_range = "10.40.0.0/14"
},
{
range_name = "gke-services"
ip_cidr_range = "10.44.0.0/20"
},
]
flow_logs_enabled = true
flow_logs_sampling = 0.5
flow_logs_aggregation_interval = "INTERVAL_5_SEC"
}
# Downstream: a VPC-native GKE cluster consuming the module's outputs
resource "google_container_cluster" "primary" {
name = "kloudvin-prod"
project = "kloudvin-prod"
location = "asia-south1"
network = google_compute_network.vpc.self_link
subnetwork = module.subnet.self_link
networking_mode = "VPC_NATIVE"
ip_allocation_policy {
cluster_secondary_range_name = "gke-pods"
services_secondary_range_name = "gke-services"
}
initial_node_count = 1
remove_default_node_pool = true
}
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/subnet/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-subnet?ref=v1.0.0"
}
inputs = {
name = "..."
project_id = "..."
region = "..."
network = "..."
ip_cidr_range = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/subnet && 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 | Subnetwork name; lowercase letter first, then lowercase letters/digits/hyphens (1-63 chars). |
project_id |
string |
— | Yes | GCP project ID that owns the subnetwork. |
region |
string |
— | Yes | Region for the subnet (subnets are regional). |
network |
string |
— | Yes | Self-link or name of the custom-mode VPC network. |
ip_cidr_range |
string |
— | Yes | Primary IPv4 CIDR, e.g. 10.20.0.0/20. |
description |
string |
null |
No | Human-readable description of the subnet. |
private_ip_google_access |
bool |
true |
No | Let VMs without external IPs reach Google APIs internally. |
stack_type |
string |
"IPV4_ONLY" |
No | IPV4_ONLY or IPV4_IPV6. |
ipv6_access_type |
string |
"INTERNAL" |
No | INTERNAL or EXTERNAL; applied only when stack_type is IPV4_IPV6. |
secondary_ip_ranges |
list(object({ range_name, ip_cidr_range })) |
[] |
No | Named secondary ranges for alias IPs (GKE Pods/Services). |
flow_logs_enabled |
bool |
true |
No | Enable VPC Flow Logs on the subnet. |
flow_logs_aggregation_interval |
string |
"INTERVAL_5_SEC" |
No | Flow log aggregation interval. |
flow_logs_sampling |
number |
0.5 |
No | Fraction of flows sampled (0.0-1.0); lower reduces cost. |
flow_logs_metadata |
string |
"INCLUDE_ALL_METADATA" |
No | INCLUDE_ALL_METADATA, EXCLUDE_ALL_METADATA, or CUSTOM_METADATA. |
flow_logs_metadata_fields |
list(string) |
[] |
No | Fields to include when metadata is CUSTOM_METADATA. |
flow_logs_filter_expr |
string |
"true" |
No | CEL expression selecting which flows to log. |
Outputs
| Name | Description |
|---|---|
id |
Fully qualified identifier of the subnetwork. |
name |
The subnetwork name. |
self_link |
URI/self-link of the subnet, consumed by GKE, instances, and routers. |
ip_cidr_range |
Primary IPv4 CIDR range of the subnet. |
gateway_address |
Gateway (first usable) address of the primary range. |
region |
Region the subnet lives in. |
secondary_range_names |
Map of range_name => ip_cidr_range for all secondary ranges. |
Enterprise scenario
KloudVin runs a Shared VPC where the host project owns all networking and service projects attach to it. The platform team instantiates this module once per region (asia-south1, us-central1, europe-west1) with non-overlapping /20 primary ranges and consistent gke-pods / gke-services secondary range names, so every GKE cluster across the estate references the same range names regardless of region. Private Google Access is on everywhere so node pools without external IPs can pull images from Artifact Registry, and Flow Logs are sampled at 50% with 5-second aggregation to feed the SecOps SIEM without ballooning Cloud Logging spend.
Best practices
- Plan CIDRs per region, not per zone. Because GCP subnets are regional, size the primary range for the whole region’s VM footprint and reserve generous, non-overlapping secondary ranges — GKE Pod ranges (a
/14here) cannot be resized without recreating the subnet. - Keep secondary range names identical across environments. Standardize on
gke-podsandgke-servicesso GKE Terraform andip_allocation_policynever have to branch on environment. - Leave Private Google Access enabled. It lets external-IP-free VMs and GKE nodes reach Google APIs over internal paths, which is both a security win (no public egress needed) and a cost win (no Cloud NAT data charges for Google-bound traffic).
- Tune Flow Logs sampling for cost.
INCLUDE_ALL_METADATAat high sampling on a busy subnet is expensive; dropflow_logs_samplingto 0.1-0.5 or useflow_logs_filter_exprto log only the traffic your security team actually reviews. - Use custom-mode VPCs. Set
auto_create_subnetworks = falseon the network so these explicit subnets are the only ones present — auto-mode’s pre-baked ranges frequently collide with on-prem and peered CIDRs. - Name for region and purpose. A name like
gke-prod-asia-south1makes ownership, environment, and region obvious in the console, IAM conditions, and firewall target tags.