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
- You are building hybrid connectivity with HA VPN or Dedicated/Partner Interconnect and want BGP-learned routes instead of brittle static routes.
- You need Cloud NAT so private GKE clusters, Cloud SQL clients, or bastion-less workloads can reach the internet for package pulls and API calls without public IPs.
- You run a landing-zone / shared-VPC topology where each region needs its own router and you want one audited, policy-checked module rather than copy-pasted HCL.
- You want fine-grained route advertisement — advertising only specific aggregate ranges (e.g. a single summarized CIDR) to a peer rather than every subnet.
- Skip it when you only need default internet egress via external IPs, or a single static route to a peered VPC — Cloud Router adds BGP machinery you would not use.
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 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/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
- Use a deterministic private ASN per region and document it. Reusing or colliding ASNs across routers that peer with the same on-prem device breaks BGP session establishment; pick from 64512-65534 (or the 4-byte private range) and keep an ASN registry.
- Prefer
MANUAL_ONLYNAT IPs for anything an external party allow-lists. AUTO_ONLY addresses can change as NAT scales, silently breaking partner firewall rules; reserve static IPs and size them for your peak concurrent connections. - Size
nat_min_ports_per_vmfor your egress fan-out and disable endpoint-independent mapping to enable dynamic port allocation. Connection-heavy GKE nodes exhaust the default 64 ports fast — port exhaustion shows up as intermittent, hard-to-debug connection failures. - Keep NAT logging at
ERRORS_ONLYin steady state, switching toALLonly while troubleshooting. Full translation logging on a busy gateway generates large Cloud Logging volumes and real cost. - Use
CUSTOMadvertise mode withALL_SUBNETSplus summarized ranges rather than advertising every subnet individually — it shrinks the on-prem routing table and keeps route churn low when you add subnets. - Deploy one router per region, not per workload, and name it with a clear
rtr-<env>-<region>convention so HA VPN, Interconnect attachments, and NAT all converge on a single, auditable control plane.