IaC GCP

Terraform Module: GCP Subnet — Regional Subnetworks with Secondary Ranges, Private Google Access, and Flow Logs

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

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 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/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

TerraformGCPSubnetModuleIaC
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