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
- You need hybrid connectivity from a GCP VPC to a data centre, branch, or another CSP and want the 99.99% SLA (which requires HA VPN + BGP, not Classic VPN).
- You are standing up VPN in multiple projects/environments (dev, staging, prod, or many landing-zone spokes) and want one audited, version-pinned pattern instead of copy-pasted HCL.
- Your peer device supports BGP and two tunnels for active/active or active/passive redundancy (any modern firewall/router: Palo Alto, FortiGate, Cisco ASR, or another cloud’s VPN gateway).
- You want secrets and topology as variables so the same module serves a single-peer-gateway setup or a dual-peer (two on-prem devices) setup without forking.
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 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_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
- Always build two tunnels across both gateway interfaces. HA VPN’s 99.99% SLA only applies when each of interface 0 and interface 1 terminates a tunnel to a redundant peer. This module enforces that shape — never trim it to a single tunnel for “simplicity.”
- Never hard-code shared secrets. Keep
shared_secretssensitive, generate them with sufficient entropy (e.g.random_passwordor your secret store), and inject viaTF_VAR_shared_secretsfrom Secret Manager / Vault. Rotate by re-applying with new values during a maintenance window. - Use BGP, not static routes, and tune the priority. Dynamic routing is required for the SLA; set
advertised_route_priority(and the peer’s MED) to make one tunnel primary and the other standby if you want active/passive instead of ECMP active/active. - Advertise narrowly with
CUSTOMmode. Advertise an aggregate or specific hub CIDRs rather than every subnet — it keeps the on-prem routing table clean and avoids leaking unintended ranges across the tunnel. - Keep link-local /30s unique and non-overlapping. Each BGP session needs its own
169.254.x.0/30; reusing a range across tunnels (or across multiple peerings on the same Cloud Router) breaks session establishment. - Pin the module and provider. Reference the module by an immutable tag (
?ref=v1.0.0) and keep the provider at~> 5.0so a gateway or router schema change never lands unreviewed in a connectivity-critical stack.