IaC GCP

Terraform Module: GCP Cloud Router — dynamic BGP routing for hybrid and NAT in one place

Quick take — A reusable Terraform module for google_compute_router on hashicorp/google ~> 5.0: var-driven BGP config, advertised IP ranges, and Cloud NAT wiring for hybrid connectivity and private egress. 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 "cloud_router" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-router?ref=v1.0.0"

  project_id = "..."  # GCP project ID that owns the Cloud Router.
  name       = "..."  # Router name (1-63 chars, lowercase RFC1035).
  region     = "..."  # Region for the router (e.g. asia-south1).
  network    = "..."  # Self-link or name of the VPC network.
  bgp_asn    = 0      # Local BGP ASN; private 16-bit (64512-65534) or 4-byte (…
}

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

What this module is

Cloud Router is GCP’s fully managed, software-defined router that runs the Border Gateway Protocol (BGP) on your behalf inside a VPC network and region. It does not move data plane packets itself — instead it programs dynamic routes into your VPC. Two jobs dominate in production: (1) exchanging routes over BGP with on-prem or another cloud across Cloud VPN tunnels or a Cloud Interconnect VLAN attachment, so new subnets propagate without anyone editing static routes; and (2) acting as the control plane that Cloud NAT attaches to, giving private VMs and GKE nodes outbound internet access without external IPs.

Wrapping google_compute_router in a module is worth it because the raw resource hides several footguns: the bgp block’s asn must sit in a valid private/16-bit or 32-bit range, advertise_mode = "CUSTOM" silently stops advertising subnet routes unless you also re-add advertised_groups, and the BGP keepalive/route-priority knobs are easy to get inconsistent across regions. This module turns all of that into validated variables, optionally provisions a paired google_compute_router_nat, and exports the IDs and self-links downstream resources (VPN tunnels, interconnect attachments, NAT log sinks) need to reference.

When to use it

Module structure

terraform-module-gcp-cloud-router/
├── 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 {
  # Cloud NAT requires a router; only build the NAT block when asked for it.
  create_nat = var.enable_nat
}

resource "google_compute_router" "this" {
  name    = var.name
  project = var.project_id
  region  = var.region
  network = var.network

  description = var.description

  bgp {
    asn               = var.bgp_asn
    advertise_mode    = var.bgp_advertise_mode
    keepalive_interval = var.bgp_keepalive_interval

    # Groups are only valid when advertise_mode = CUSTOM.
    advertised_groups = (
      var.bgp_advertise_mode == "CUSTOM" ? var.bgp_advertised_groups : null
    )

    # Aggregate IP ranges to advertise (CUSTOM mode only).
    dynamic "advertised_ip_ranges" {
      for_each = var.bgp_advertise_mode == "CUSTOM" ? var.bgp_advertised_ip_ranges : []
      content {
        range       = advertised_ip_ranges.value.range
        description = try(advertised_ip_ranges.value.description, null)
      }
    }
  }
}

resource "google_compute_router_nat" "this" {
  count = local.create_nat ? 1 : 0

  name    = coalesce(var.nat_name, "${var.name}-nat")
  project = var.project_id
  region  = var.region
  router  = google_compute_router.this.name

  nat_ip_allocate_option = var.nat_ip_allocate_option
  nat_ips = (
    var.nat_ip_allocate_option == "MANUAL_ONLY" ? var.nat_ips : null
  )

  source_subnetwork_ip_ranges_to_nat = var.nat_source_subnetwork_ip_ranges_to_nat

  # Per-subnet NAT selection (only when LIST_OF_SUBNETWORKS is chosen).
  dynamic "subnetwork" {
    for_each = (
      var.nat_source_subnetwork_ip_ranges_to_nat == "LIST_OF_SUBNETWORKS"
      ? var.nat_subnetworks
      : []
    )
    content {
      name                    = subnetwork.value.name
      source_ip_ranges_to_nat = subnetwork.value.source_ip_ranges_to_nat
      secondary_ip_range_names = try(subnetwork.value.secondary_ip_range_names, null)
    }
  }

  min_ports_per_vm                 = var.nat_min_ports_per_vm
  enable_endpoint_independent_mapping = var.nat_enable_endpoint_independent_mapping
  udp_idle_timeout_sec            = var.nat_udp_idle_timeout_sec
  tcp_established_idle_timeout_sec = var.nat_tcp_established_idle_timeout_sec
  tcp_transitory_idle_timeout_sec  = var.nat_tcp_transitory_idle_timeout_sec

  log_config {
    enable = var.nat_log_enable
    filter = var.nat_log_filter
  }
}
# variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID that owns the Cloud Router."
}

variable "name" {
  type        = string
  description = "Name of the Cloud Router. Must be a valid GCP resource name."

  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 letters, digits or hyphens, starting with a letter."
  }
}

variable "region" {
  type        = string
  description = "Region in which to create the Cloud Router (e.g. asia-south1)."
}

variable "network" {
  type        = string
  description = "Self-link or name of the VPC network the router attaches to."
}

variable "description" {
  type        = string
  description = "Optional human-readable description for the router."
  default     = null
}

# ---- BGP -------------------------------------------------------------------

variable "bgp_asn" {
  type        = number
  description = "Local BGP ASN for the router. Use a private ASN (64512-65534) or a 4-byte private ASN (4200000000-4294967294)."

  validation {
    condition = (
      (var.bgp_asn >= 64512 && var.bgp_asn <= 65534) ||
      (var.bgp_asn >= 4200000000 && var.bgp_asn <= 4294967294)
    )
    error_message = "bgp_asn must be in a private ASN range: 64512-65534 or 4200000000-4294967294."
  }
}

variable "bgp_advertise_mode" {
  type        = string
  description = "Route advertisement mode: DEFAULT advertises all subnet routes; CUSTOM advertises only what you specify."
  default     = "DEFAULT"

  validation {
    condition     = contains(["DEFAULT", "CUSTOM"], var.bgp_advertise_mode)
    error_message = "bgp_advertise_mode must be DEFAULT or CUSTOM."
  }
}

variable "bgp_advertised_groups" {
  type        = list(string)
  description = "Groups to advertise in CUSTOM mode. Typically [\"ALL_SUBNETS\"] to keep subnet routes plus custom ranges."
  default     = []

  validation {
    condition = alltrue([
      for g in var.bgp_advertised_groups : contains(["ALL_SUBNETS"], g)
    ])
    error_message = "bgp_advertised_groups currently only supports the value ALL_SUBNETS."
  }
}

variable "bgp_advertised_ip_ranges" {
  type = list(object({
    range       = string
    description = optional(string)
  }))
  description = "Aggregate CIDR ranges to advertise to BGP peers (CUSTOM mode only)."
  default     = []
}

variable "bgp_keepalive_interval" {
  type        = number
  description = "BGP keepalive interval in seconds (20-60). The hold time is 3x this value."
  default     = 20

  validation {
    condition     = var.bgp_keepalive_interval >= 20 && var.bgp_keepalive_interval <= 60
    error_message = "bgp_keepalive_interval must be between 20 and 60 seconds."
  }
}

# ---- Cloud NAT (optional) --------------------------------------------------

variable "enable_nat" {
  type        = bool
  description = "If true, create a Cloud NAT gateway attached to this router."
  default     = false
}

variable "nat_name" {
  type        = string
  description = "Name for the Cloud NAT gateway. Defaults to <router-name>-nat."
  default     = null
}

variable "nat_ip_allocate_option" {
  type        = string
  description = "How NAT IPs are allocated: AUTO_ONLY (Google-managed) or MANUAL_ONLY (reserved static IPs)."
  default     = "AUTO_ONLY"

  validation {
    condition     = contains(["AUTO_ONLY", "MANUAL_ONLY"], var.nat_ip_allocate_option)
    error_message = "nat_ip_allocate_option must be AUTO_ONLY or MANUAL_ONLY."
  }
}

variable "nat_ips" {
  type        = list(string)
  description = "Self-links of reserved external IPs to use when nat_ip_allocate_option is MANUAL_ONLY."
  default     = []
}

variable "nat_source_subnetwork_ip_ranges_to_nat" {
  type        = string
  description = "Which subnet ranges get NAT: ALL_SUBNETWORKS_ALL_IP_RANGES, ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES, or LIST_OF_SUBNETWORKS."
  default     = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  validation {
    condition = contains([
      "ALL_SUBNETWORKS_ALL_IP_RANGES",
      "ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES",
      "LIST_OF_SUBNETWORKS",
    ], var.nat_source_subnetwork_ip_ranges_to_nat)
    error_message = "Invalid nat_source_subnetwork_ip_ranges_to_nat value."
  }
}

variable "nat_subnetworks" {
  type = list(object({
    name                    = string
    source_ip_ranges_to_nat = list(string)
    secondary_ip_range_names = optional(list(string))
  }))
  description = "Per-subnet NAT config, used only when nat_source_subnetwork_ip_ranges_to_nat = LIST_OF_SUBNETWORKS."
  default     = []
}

variable "nat_min_ports_per_vm" {
  type        = number
  description = "Minimum number of ports allocated to each VM. Raise this for connection-heavy workloads to avoid port exhaustion."
  default     = 64
}

variable "nat_enable_endpoint_independent_mapping" {
  type        = bool
  description = "Enable endpoint-independent mapping. Disable it to allow dynamic port allocation for better port efficiency."
  default     = false
}

variable "nat_udp_idle_timeout_sec" {
  type        = number
  description = "UDP idle timeout in seconds."
  default     = 30
}

variable "nat_tcp_established_idle_timeout_sec" {
  type        = number
  description = "TCP established connection idle timeout in seconds."
  default     = 1200
}

variable "nat_tcp_transitory_idle_timeout_sec" {
  type        = number
  description = "TCP transitory connection idle timeout in seconds."
  default     = 30
}

variable "nat_log_enable" {
  type        = bool
  description = "Enable Cloud NAT logging to Cloud Logging."
  default     = true
}

variable "nat_log_filter" {
  type        = string
  description = "Which NAT events to log: ERRORS_ONLY, TRANSLATIONS_ONLY, or ALL."
  default     = "ERRORS_ONLY"

  validation {
    condition     = contains(["ERRORS_ONLY", "TRANSLATIONS_ONLY", "ALL"], var.nat_log_filter)
    error_message = "nat_log_filter must be ERRORS_ONLY, TRANSLATIONS_ONLY, or ALL."
  }
}
# outputs.tf

output "router_id" {
  description = "The fully-qualified ID of the Cloud Router."
  value       = google_compute_router.this.id
}

output "router_name" {
  description = "The name of the Cloud Router (used by VPN tunnels and interconnect attachments)."
  value       = google_compute_router.this.name
}

output "router_self_link" {
  description = "The URI (self-link) of the Cloud Router."
  value       = google_compute_router.this.self_link
}

output "router_creation_timestamp" {
  description = "Creation timestamp of the Cloud Router in RFC3339 format."
  value       = google_compute_router.this.creation_timestamp
}

output "bgp_asn" {
  description = "The local BGP ASN configured on the router."
  value       = google_compute_router.this.bgp[0].asn
}

output "nat_id" {
  description = "ID of the Cloud NAT gateway, or null if NAT was not created."
  value       = local.create_nat ? google_compute_router_nat.this[0].id : null
}

output "nat_name" {
  description = "Name of the Cloud NAT gateway, or null if NAT was not created."
  value       = local.create_nat ? google_compute_router_nat.this[0].name : null
}

How to use it

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

  project_id  = "kloudvin-prod-net"
  name        = "rtr-prod-asia-south1"
  region      = "asia-south1"
  network     = google_compute_network.shared_vpc.self_link
  description = "Prod hybrid router + NAT for asia-south1"

  # BGP: advertise subnet routes plus a summarized aggregate to on-prem.
  bgp_asn            = 65001
  bgp_advertise_mode = "CUSTOM"
  bgp_advertised_groups = ["ALL_SUBNETS"]
  bgp_advertised_ip_ranges = [
    {
      range       = "10.180.0.0/16"
      description = "Summarized GKE + workload supernet"
    },
  ]

  # Cloud NAT for private GKE node egress (no external IPs on nodes).
  enable_nat                            = true
  nat_ip_allocate_option                = "MANUAL_ONLY"
  nat_ips                               = [google_compute_address.nat[0].self_link, google_compute_address.nat[1].self_link]
  nat_source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"
  nat_subnetworks = [
    {
      name                     = google_compute_subnetwork.gke.id
      source_ip_ranges_to_nat = ["ALL_IP_RANGES"]
    },
  ]
  nat_min_ports_per_vm = 256
  nat_log_filter       = "ALL"
}

# Downstream: attach an HA VPN tunnel to the router and create the BGP peer
# session using the router name exported by the module.
resource "google_compute_router_interface" "vpn_if0" {
  name       = "if-vpn-tunnel0"
  project    = "kloudvin-prod-net"
  region     = "asia-south1"
  router     = module.cloud_router.router_name
  ip_range   = "169.254.10.1/30"
  vpn_tunnel = google_compute_vpn_tunnel.tunnel0.name
}

resource "google_compute_router_peer" "onprem_peer0" {
  name                      = "peer-onprem-tunnel0"
  project                   = "kloudvin-prod-net"
  region                    = "asia-south1"
  router                    = module.cloud_router.router_name
  interface                 = google_compute_router_interface.vpn_if0.name
  peer_ip_address           = "169.254.10.2"
  peer_asn                  = 64600
  advertised_route_priority = 100
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  region = "..."
  network = "..."
  bgp_asn = 0
}

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

cd live/prod/cloud_router && 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 GCP project ID that owns the Cloud Router.
name string yes Router name (1-63 chars, lowercase RFC1035).
region string yes Region for the router (e.g. asia-south1).
network string yes Self-link or name of the VPC network.
description string null no Optional description for the router.
bgp_asn number yes Local BGP ASN; private 16-bit (64512-65534) or 4-byte (4200000000-4294967294).
bgp_advertise_mode string “DEFAULT” no DEFAULT (all subnet routes) or CUSTOM.
bgp_advertised_groups list(string) [] no Groups to advertise in CUSTOM mode (e.g. [“ALL_SUBNETS”]).
bgp_advertised_ip_ranges list(object) [] no Aggregate CIDR ranges to advertise (CUSTOM mode only).
bgp_keepalive_interval number 20 no BGP keepalive interval in seconds (20-60).
enable_nat bool false no Create a Cloud NAT gateway on this router.
nat_name string null no NAT gateway name; defaults to <router-name>-nat.
nat_ip_allocate_option string “AUTO_ONLY” no AUTO_ONLY or MANUAL_ONLY for NAT IPs.
nat_ips list(string) [] no Reserved external IP self-links when MANUAL_ONLY.
nat_source_subnetwork_ip_ranges_to_nat string “ALL_SUBNETWORKS_ALL_IP_RANGES” no Which subnet ranges receive NAT.
nat_subnetworks list(object) [] no Per-subnet NAT config for LIST_OF_SUBNETWORKS mode.
nat_min_ports_per_vm number 64 no Minimum NAT ports per VM.
nat_enable_endpoint_independent_mapping bool false no Enable endpoint-independent mapping.
nat_udp_idle_timeout_sec number 30 no UDP idle timeout (seconds).
nat_tcp_established_idle_timeout_sec number 1200 no TCP established idle timeout (seconds).
nat_tcp_transitory_idle_timeout_sec number 30 no TCP transitory idle timeout (seconds).
nat_log_enable bool true no Enable Cloud NAT logging.
nat_log_filter string “ERRORS_ONLY” no ERRORS_ONLY, TRANSLATIONS_ONLY, or ALL.

Outputs

Name Description
router_id Fully-qualified ID of the Cloud Router.
router_name Name of the router; referenced by VPN tunnels and interconnect attachments.
router_self_link URI (self-link) of the Cloud Router.
router_creation_timestamp Creation timestamp in RFC3339 format.
bgp_asn The local BGP ASN configured on the router.
nat_id ID of the Cloud NAT gateway, or null if NAT was not created.
nat_name Name of the Cloud NAT gateway, or null if NAT was not created.

Enterprise scenario

A retail enterprise runs a Shared VPC landing zone with workloads in asia-south1 and asia-southeast1. Each region instantiates this module once: the router terminates two HA VPN tunnels back to the on-prem data centre, advertising a single summarized /16 supernet (via bgp_advertised_ip_ranges) so the on-prem firewall team manages one route instead of forty. The same router carries a Cloud NAT with two reserved static egress IPs in MANUAL_ONLY mode, which the partner payment gateway allow-lists, letting private GKE nodes reach the gateway without any node ever holding a public IP.

Best practices

TerraformGCPCloud RouterModuleIaC
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