IaC GCP

Terraform Module: GCP Cloud VPN — Redundant HA VPN with BGP in One Reusable Block

Quick take — Build a production-grade GCP HA VPN with Terraform: a reusable module wiring a google_compute_ha_vpn_gateway, dual tunnels, Cloud Router, and BGP peers for redundant hybrid connectivity that hits the 99.99% SLA. 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_vpn" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-vpn?ref=v1.0.0"

  project_id       = "..."           # GCP project ID that hosts the VPN resources.
  name_prefix      = "..."           # Prefix applied to all resource names.
  region           = "..."           # Region for the HA VPN gateway, tunnels, and Cloud Route…
  network          = "..."           # Self-link or name of the VPC network to attach to.
  peer_gateway_ips = ["...", "..."]  # Public IP(s) of the peer VPN device(s); one or two.
  shared_secrets   = ["...", "..."]  # IKE pre-shared keys, one per tunnel.
  peer_asn         = 0               # BGP ASN of the peer router.
}

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

What this module is

GCP’s HA VPN is the supported way to connect a VPC to an on-premises network (or another cloud) over IPsec with a published 99.99% availability SLA. Unlike the older Classic VPN, HA VPN gives you a gateway with two interfaces (each on a distinct Google-managed external IP, in separate availability zones), and it expects you to terminate at least two tunnels so a single tunnel or interface failure never drops the connection. Dynamic routing over BGP — provided by Cloud Router — is mandatory; there is no static-route mode for HA VPN at the 99.99% SLA.

Wiring this up by hand means coordinating four resource types that all reference each other: google_compute_ha_vpn_gateway (the gateway), google_compute_external_vpn_gateway (the peer’s description), google_compute_router (the BGP speaker), google_compute_vpn_tunnel (the IPsec tunnels, each bound to a gateway interface), plus google_compute_router_interface and google_compute_router_peer (the BGP session over each tunnel). Get the interface indexes, the vpn_gateway_interface mapping, or the link-local /30 addressing wrong and the tunnels come up but BGP never establishes — or worse, only one path works and you silently lose redundancy.

This module encapsulates that wiring. You hand it a peer gateway IP (or two), a shared secret per tunnel, your Cloud Router ASN, and the peer ASN; it stands up the HA VPN gateway, two tunnels across both gateway interfaces, the router, and both BGP peering sessions — consistently, every time, in every project. It exports the gateway’s two external IPs (so the network team can configure the far side), the tunnel self-links, and the per-session BGP peer IPs.

When to use it

Reach for Dedicated/Partner Interconnect instead when you need >3 Gbps of stable throughput, lower latency, or traffic that must not traverse the public internet. Use Classic VPN only for legacy static-route peers that cannot speak BGP.

Module structure

terraform-module-gcp-cloud-vpn/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # HA VPN gateway, tunnels, Cloud Router, BGP peers
├── variables.tf     # peer gateway, shared secrets, ASNs, BGP addressing
└── outputs.tf       # gateway IPs, tunnel self-links, BGP peer IPs
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # HA VPN always has two interfaces (0 and 1). We build a tunnel on each.
  # Each tunnel entry maps a gateway interface to a peer-gateway interface
  # and carries its own link-local BGP addressing + shared secret.
  tunnels = {
    "0" = {
      vpn_gateway_interface      = 0
      peer_external_gw_interface = var.peer_gateway_interfaces[0]
      shared_secret              = var.shared_secrets[0]
      bgp_session_range          = var.bgp_session_ranges[0]
      bgp_peer_ip                = var.bgp_peer_ips[0]
    }
    "1" = {
      vpn_gateway_interface      = 1
      peer_external_gw_interface = length(var.peer_gateway_interfaces) > 1 ? var.peer_gateway_interfaces[1] : var.peer_gateway_interfaces[0]
      shared_secret              = var.shared_secrets[1]
      bgp_session_range          = var.bgp_session_ranges[1]
      bgp_peer_ip                = var.bgp_peer_ips[1]
    }
  }
}

# --- HA VPN gateway: two Google-managed external IPs across two zones ---
resource "google_compute_ha_vpn_gateway" "this" {
  project  = var.project_id
  name     = "${var.name_prefix}-havpn-gw"
  region   = var.region
  network  = var.network

  stack_type = var.stack_type
}

# --- Description of the on-prem / peer device(s) ---
resource "google_compute_external_vpn_gateway" "peer" {
  project         = var.project_id
  name            = "${var.name_prefix}-peer-gw"
  redundancy_type = var.peer_redundancy_type

  dynamic "interface" {
    for_each = var.peer_gateway_ips
    content {
      id         = interface.key
      ip_address = interface.value
    }
  }
}

# --- Cloud Router: the BGP speaker on the GCP side ---
resource "google_compute_router" "this" {
  project = var.project_id
  name    = "${var.name_prefix}-cr"
  region  = var.region
  network = var.network

  bgp {
    asn               = var.cloud_router_asn
    advertise_mode    = var.advertise_mode
    advertised_groups = var.advertise_mode == "CUSTOM" ? ["ALL_SUBNETS"] : []

    dynamic "advertised_ip_ranges" {
      for_each = var.advertise_mode == "CUSTOM" ? var.advertised_ip_ranges : []
      content {
        range = advertised_ip_ranges.value
      }
    }
  }
}

# --- Two IPsec tunnels, one per HA VPN gateway interface ---
resource "google_compute_vpn_tunnel" "this" {
  for_each = local.tunnels

  project               = var.project_id
  name                  = "${var.name_prefix}-tunnel-${each.key}"
  region                = var.region
  vpn_gateway           = google_compute_ha_vpn_gateway.this.id
  vpn_gateway_interface = each.value.vpn_gateway_interface

  peer_external_gateway           = google_compute_external_vpn_gateway.peer.id
  peer_external_gateway_interface = each.value.peer_external_gw_interface

  shared_secret = each.value.shared_secret
  router        = google_compute_router.this.id

  ike_version = var.ike_version
}

# --- One BGP interface per tunnel (the GCP-side link-local /30) ---
resource "google_compute_router_interface" "this" {
  for_each = local.tunnels

  project    = var.project_id
  name       = "${var.name_prefix}-if-${each.key}"
  region     = var.region
  router     = google_compute_router.this.name
  ip_range   = each.value.bgp_session_range
  vpn_tunnel = google_compute_vpn_tunnel.this[each.key].name
}

# --- One BGP peer per tunnel (the on-prem ASN + peer link-local IP) ---
resource "google_compute_router_peer" "this" {
  for_each = local.tunnels

  project                   = var.project_id
  name                      = "${var.name_prefix}-peer-${each.key}"
  region                    = var.region
  router                    = google_compute_router.this.name
  interface                 = google_compute_router_interface.this[each.key].name
  peer_ip_address           = each.value.bgp_peer_ip
  peer_asn                  = var.peer_asn
  advertised_route_priority = var.advertised_route_priority
}
# variables.tf

variable "project_id" {
  description = "GCP project ID that hosts the VPN resources."
  type        = string
}

variable "name_prefix" {
  description = "Prefix applied to all resource names (e.g. \"onprem-prod\")."
  type        = string
}

variable "region" {
  description = "Region for the HA VPN gateway, tunnels, and Cloud Router."
  type        = string
}

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

variable "stack_type" {
  description = "IP stack for the HA VPN gateway: IPV4_ONLY or IPV4_IPV6."
  type        = string
  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 "peer_gateway_ips" {
  description = "Public IP(s) of the peer (on-prem) VPN device(s). One IP for a single-device peer, two for a redundant dual-device peer."
  type        = list(string)

  validation {
    condition     = length(var.peer_gateway_ips) >= 1 && length(var.peer_gateway_ips) <= 2
    error_message = "Provide one or two peer gateway IPs."
  }
}

variable "peer_redundancy_type" {
  description = "Redundancy of the external (peer) gateway: SINGLE_IP_INTERNALLY_REDUNDANT, TWO_IPS_REDUNDANCY, or FOUR_IPS_REDUNDANCY."
  type        = string
  default     = "TWO_IPS_REDUNDANCY"
}

variable "peer_gateway_interfaces" {
  description = "Peer external gateway interface index each GCP tunnel connects to. [0, 1] for a two-IP peer; [0, 0] for a single-IP peer."
  type        = list(number)
  default     = [0, 1]
}

variable "shared_secrets" {
  description = "IKE pre-shared keys, one per tunnel (index 0 and 1). Pass via TF_VAR_ from a secret store; never hard-code."
  type        = list(string)
  sensitive   = true

  validation {
    condition     = length(var.shared_secrets) == 2
    error_message = "Exactly two shared secrets are required (one per tunnel)."
  }
}

variable "cloud_router_asn" {
  description = "BGP ASN for the GCP Cloud Router (private range 64512-65534 or a 4-byte private ASN)."
  type        = number
  default     = 64514
}

variable "peer_asn" {
  description = "BGP ASN of the peer (on-prem) router."
  type        = number
}

variable "bgp_session_ranges" {
  description = "Link-local /30 ranges for the GCP side of each BGP session (one per tunnel), e.g. 169.254.0.1/30 and 169.254.1.1/30."
  type        = list(string)
  default     = ["169.254.0.1/30", "169.254.1.1/30"]

  validation {
    condition     = length(var.bgp_session_ranges) == 2
    error_message = "Exactly two BGP session ranges are required (one per tunnel)."
  }
}

variable "bgp_peer_ips" {
  description = "Link-local peer IP for each BGP session (the on-prem side of each /30), e.g. 169.254.0.2 and 169.254.1.2."
  type        = list(string)
  default     = ["169.254.0.2", "169.254.1.2"]

  validation {
    condition     = length(var.bgp_peer_ips) == 2
    error_message = "Exactly two BGP peer IPs are required (one per tunnel)."
  }
}

variable "advertise_mode" {
  description = "Cloud Router advertise mode: DEFAULT (advertise the VPC subnets) or CUSTOM (advertise advertised_ip_ranges)."
  type        = string
  default     = "DEFAULT"

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

variable "advertised_ip_ranges" {
  description = "CIDRs to advertise to the peer when advertise_mode is CUSTOM (e.g. a hub CIDR or aggregate)."
  type        = list(string)
  default     = []
}

variable "advertised_route_priority" {
  description = "MED/priority for routes learned by the peer; lower wins. Use to bias one tunnel active and the other standby."
  type        = number
  default     = 100
}

variable "ike_version" {
  description = "IKE version for the IPsec tunnels (1 or 2). IKEv2 is recommended."
  type        = number
  default     = 2
}
# outputs.tf

output "ha_vpn_gateway_id" {
  description = "Full resource ID of the HA VPN gateway."
  value       = google_compute_ha_vpn_gateway.this.id
}

output "ha_vpn_gateway_interfaces" {
  description = "Google-managed external IPs for each HA VPN gateway interface — give these to the network team for the peer device config."
  value = {
    for iface in google_compute_ha_vpn_gateway.this.vpn_interfaces :
    iface.id => iface.ip_address
  }
}

output "cloud_router_name" {
  description = "Name of the Cloud Router (useful for additional peerings or NAT attachments)."
  value       = google_compute_router.this.name
}

output "tunnel_self_links" {
  description = "Self-link of each IPsec tunnel, keyed by interface index."
  value       = { for k, t in google_compute_vpn_tunnel.this : k => t.self_link }
}

output "bgp_peer_ip_addresses" {
  description = "GCP-side BGP peer IP addresses per session, keyed by interface index (for monitoring/alerting on session state)."
  value       = { for k, p in google_compute_router_peer.this : k => p.ip_address }
}

How to use it

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

  project_id  = "kv-prod-net-7421"
  name_prefix = "onprem-prod"
  region      = "asia-south1"
  network     = google_compute_network.hub.self_link

  # Peer (on-prem) device: two public IPs for full redundancy
  peer_gateway_ips        = ["203.0.113.10", "203.0.113.11"]
  peer_redundancy_type    = "TWO_IPS_REDUNDANCY"
  peer_gateway_interfaces = [0, 1]

  # IKE pre-shared keys sourced from Secret Manager via TF_VAR_shared_secrets
  shared_secrets = var.vpn_shared_secrets

  # BGP: GCP Cloud Router ASN <-> on-prem ASN
  cloud_router_asn = 64514
  peer_asn         = 65010

  # Advertise the hub CIDR to on-prem
  advertise_mode       = "CUSTOM"
  advertised_ip_ranges = ["10.20.0.0/16"]
}

# Downstream reference: surface the gateway IPs for the network runbook / DNS
output "vpn_gateway_external_ips" {
  description = "Hand these two IPs to the on-prem firewall team."
  value       = module.cloud_vpn.ha_vpn_gateway_interfaces
}

# ...and wire the Cloud Router into a Cloud NAT or further BGP config
resource "google_compute_router_nat" "egress" {
  name    = "onprem-prod-nat"
  project = "kv-prod-net-7421"
  region  = "asia-south1"
  router  = module.cloud_vpn.cloud_router_name

  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
}

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_vpn/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-vpn?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  name_prefix = "..."
  region = "..."
  network = "..."
  peer_gateway_ips = ["...", "..."]
  shared_secrets = ["...", "..."]
  peer_asn = 0
}

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

cd live/prod/cloud_vpn && 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 hosts the VPN resources.
name_prefix string Yes Prefix applied to all resource names.
region string Yes Region for the HA VPN gateway, tunnels, and Cloud Router.
network string Yes Self-link or name of the VPC network to attach to.
stack_type string "IPV4_ONLY" No IP stack: IPV4_ONLY or IPV4_IPV6.
peer_gateway_ips list(string) Yes Public IP(s) of the peer VPN device(s); one or two.
peer_redundancy_type string "TWO_IPS_REDUNDANCY" No External gateway redundancy type.
peer_gateway_interfaces list(number) [0, 1] No Peer interface index each GCP tunnel connects to.
shared_secrets list(string) (sensitive) Yes IKE pre-shared keys, one per tunnel.
cloud_router_asn number 64514 No BGP ASN for the GCP Cloud Router.
peer_asn number Yes BGP ASN of the peer router.
bgp_session_ranges list(string) ["169.254.0.1/30", "169.254.1.1/30"] No GCP-side link-local /30 per tunnel.
bgp_peer_ips list(string) ["169.254.0.2", "169.254.1.2"] No Peer-side link-local IP per tunnel.
advertise_mode string "DEFAULT" No DEFAULT or CUSTOM route advertisement.
advertised_ip_ranges list(string) [] No CIDRs advertised when advertise_mode is CUSTOM.
advertised_route_priority number 100 No Route priority/MED; lower wins (bias active/standby).
ike_version number 2 No IKE version (1 or 2); IKEv2 recommended.

Outputs

Name Description
ha_vpn_gateway_id Full resource ID of the HA VPN gateway.
ha_vpn_gateway_interfaces Map of interface ID → Google-managed external IP, for the peer device config.
cloud_router_name Name of the Cloud Router, for additional peerings or NAT.
tunnel_self_links Map of interface index → IPsec tunnel self-link.
bgp_peer_ip_addresses Map of interface index → GCP-side BGP peer IP, for session monitoring.

Enterprise scenario

A retail group runs its order-management and inventory systems in a GCP hub VPC in asia-south1 and must reach a co-located ERP in a Mumbai data centre. The networking team deploys this module once per environment from the landing-zone pipeline: it stands up the HA VPN gateway against two redundant Palo Alto firewalls (TWO_IPS_REDUNDANCY), runs both tunnels active/active with BGP so a firewall or tunnel failure reroutes within seconds, and advertises only the hub aggregate 10.20.0.0/16 to on-prem via CUSTOM mode. The two gateway IPs and BGP peer IPs are exported straight into the change ticket so the firewall team configures the far side without back-and-forth.

Best practices

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