Quick take — A reusable Terraform module for google_compute_address and google_compute_global_address on hashicorp/google ~> 5.0: regional vs global, EXTERNAL vs INTERNAL, purpose and tier driven by variables, with address and self_link outputs. 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 "static_ip" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-static-ip?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the reserved address.
name = "..." # Address name (1-63 chars, lowercase RFC1035).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
On Google Cloud a “static IP” is a reserved address — an IP that GCP holds for your project until you explicitly release it, instead of an ephemeral address that disappears when the resource it was attached to is deleted. Reservation is the only way to keep a stable, predictable IP that DNS, firewall allow-lists, and partner integrations can depend on. The catch is that GCP splits this single concept across two completely different resources, and picking the wrong one wastes hours.
google_compute_address is regional. It lives in one region and is what you reserve for VM network interfaces, regional external/internal forwarding rules (regional Load Balancing, including the regional external Application/Network Load Balancers), Cloud NAT egress IPs, and internal addresses you want to pin inside a subnet. google_compute_global_address is global and is a different beast: it backs the global external Application Load Balancer (anycast frontend IP), and — with purpose = "VPC_PEERING" plus prefix_length — it reserves the private CIDR range that Private Service Access (PSA) hands to managed services like Cloud SQL, Memorystore, and Vertex AI. The two resources do not share a schema: global addresses cannot specify a region, and only regional INTERNAL addresses take a subnetwork.
Both axes — scope (regional vs global) and type (EXTERNAL vs INTERNAL) — are driven by variables here, plus purpose, network_tier, an optional fixed address, and subnetwork/prefix_length. Wrapping all of it in one module is worth it because the raw resources are full of invalid combinations the provider only rejects at apply time: network_tier is meaningless on internal and global addresses, subnetwork is illegal unless the address is regional+internal, address_type does not even exist on the global resource, and purpose = "VPC_PEERING" requires prefix_length on a global internal address. The module encodes those rules as validation and count, and exports the one thing every caller actually wants downstream: the address string and the self_link.
When to use it
- You need a stable external IP for a VM, a regional load balancer, or Cloud NAT that survives instance/forwarding-rule recreation and can be added to DNS or a partner’s allow-list.
- You are fronting a global external Application Load Balancer and need its single anycast IPv4 (or IPv6) frontend reserved ahead of the forwarding rule.
- You are enabling Private Service Access for Cloud SQL / Memorystore / a managed peering and must reserve an internal
/16(or similar) range withpurpose = "VPC_PEERING". - You want a pinned internal IP inside a subnet — for a database VM, an internal load balancer VIP, or an appliance whose address other systems hard-code.
- You run a landing zone where dozens of reserved IPs are created the same way and you want one audited, validated module rather than scattered
google_compute_addressblocks with inconsistent tiers and purposes. - Skip it when an ephemeral address is fine (short-lived dev VMs, throwaway test rigs) — reserving an IP you never stabilise just adds a resource to track and, if left unattached, a small standing charge.
Module structure
terraform-module-gcp-static-ip/
├── 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 {
# Exactly one of the two resources is created, decided by var.scope.
is_regional = var.scope == "REGIONAL"
is_global = var.scope == "GLOBAL"
# network_tier only applies to EXTERNAL addresses on the PREMIUM/STANDARD
# network service tiers, and STANDARD is regional-only. Internal addresses
# and global addresses must leave it null.
effective_network_tier = (
local.is_regional && var.address_type == "EXTERNAL" ? var.network_tier : null
)
# subnetwork is only valid for a regional INTERNAL address.
effective_subnetwork = (
local.is_regional && var.address_type == "INTERNAL" ? var.subnetwork : null
)
# The resource that actually got created, so outputs can read from one place.
created = local.is_regional ? google_compute_address.this : google_compute_global_address.this
}
# --- Regional reserved address: VMs, regional LBs, Cloud NAT, internal VIPs ---
resource "google_compute_address" "this" {
count = local.is_regional ? 1 : 0
name = var.name
project = var.project_id
region = var.region
description = var.description
address_type = var.address_type
purpose = var.purpose
network_tier = local.effective_network_tier
# INTERNAL only: pin the address inside this subnetwork.
subnetwork = local.effective_subnetwork
# Optional fixed IP; omit to let GCP allocate one from the pool/subnet.
address = var.address
labels = var.labels
}
# --- Global reserved address: global external ALB frontend, or PSA range ------
resource "google_compute_global_address" "this" {
count = local.is_global ? 1 : 0
name = var.name
project = var.project_id
description = var.description
address_type = var.address_type
purpose = var.purpose
ip_version = var.ip_version
# purpose = "VPC_PEERING" (Private Service Access) requires a network +
# prefix_length; GCP allocates a block of this size from the named network.
network = var.purpose == "VPC_PEERING" ? var.network : null
prefix_length = var.purpose == "VPC_PEERING" ? var.prefix_length : null
# Optional fixed IP (or the start of the reserved range for PSA).
address = var.address
labels = var.labels
}
# variables.tf
variable "project_id" {
type = string
description = "GCP project ID that owns the reserved address."
}
variable "name" {
type = string
description = "Name of the reserved address. Lowercase RFC1035 (letters, digits, hyphens; starts 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 letters, digits or hyphens, starting with a letter."
}
}
variable "scope" {
type = string
description = "REGIONAL (google_compute_address) or GLOBAL (google_compute_global_address)."
default = "REGIONAL"
validation {
condition = contains(["REGIONAL", "GLOBAL"], var.scope)
error_message = "scope must be REGIONAL or GLOBAL."
}
}
variable "region" {
type = string
description = "Region for a REGIONAL address (e.g. asia-south1). Ignored when scope = GLOBAL."
default = null
validation {
condition = var.scope == "GLOBAL" || var.region != null
error_message = "region is required when scope = REGIONAL."
}
}
variable "address_type" {
type = string
description = "EXTERNAL (internet-routable) or INTERNAL (RFC1918 within a VPC)."
default = "EXTERNAL"
validation {
condition = contains(["EXTERNAL", "INTERNAL"], var.address_type)
error_message = "address_type must be EXTERNAL or INTERNAL."
}
}
variable "purpose" {
type = string
description = "Purpose of the address. Leave null for plain external/VM use. Common values: GCE_ENDPOINT (regional internal VM/ILB IP), SHARED_LOADBALANCER_VIP (regional internal LB VIP), VPC_PEERING (global internal range for Private Service Access), PRIVATE_SERVICE_CONNECT (global internal PSC endpoint)."
default = null
validation {
condition = var.purpose == null || contains([
"GCE_ENDPOINT",
"SHARED_LOADBALANCER_VIP",
"VPC_PEERING",
"PRIVATE_SERVICE_CONNECT",
], coalesce(var.purpose, "null"))
error_message = "purpose must be one of GCE_ENDPOINT, SHARED_LOADBALANCER_VIP, VPC_PEERING, PRIVATE_SERVICE_CONNECT, or null."
}
}
variable "network_tier" {
type = string
description = "Network service tier for a regional EXTERNAL address: PREMIUM (global backbone) or STANDARD (regional, cheaper). Ignored for internal or global addresses."
default = "PREMIUM"
validation {
condition = contains(["PREMIUM", "STANDARD"], var.network_tier)
error_message = "network_tier must be PREMIUM or STANDARD."
}
}
variable "subnetwork" {
type = string
description = "Self-link or name of the subnetwork to allocate an INTERNAL regional address from. Required for regional INTERNAL addresses; ignored otherwise."
default = null
validation {
condition = !(var.scope == "REGIONAL" && var.address_type == "INTERNAL") || var.subnetwork != null
error_message = "subnetwork is required for a REGIONAL INTERNAL address."
}
}
variable "address" {
type = string
description = "Optional fixed IP to reserve (e.g. 10.20.0.5). Omit to let GCP allocate one. For VPC_PEERING this is the start of the reserved range."
default = null
}
variable "prefix_length" {
type = number
description = "Prefix length of the reserved block, used only with purpose = VPC_PEERING (e.g. 16 reserves a /16 for Private Service Access)."
default = null
validation {
condition = var.purpose != "VPC_PEERING" || (var.prefix_length != null && var.prefix_length >= 8 && var.prefix_length <= 30)
error_message = "prefix_length is required for purpose = VPC_PEERING and must be between 8 and 30."
}
}
variable "network" {
type = string
description = "Self-link or name of the VPC network the PSA range belongs to. Required when purpose = VPC_PEERING; ignored otherwise."
default = null
validation {
condition = var.purpose != "VPC_PEERING" || var.network != null
error_message = "network is required for purpose = VPC_PEERING."
}
}
variable "ip_version" {
type = string
description = "IP version for a GLOBAL external address: IPV4 or IPV6. Ignored for regional addresses."
default = "IPV4"
validation {
condition = contains(["IPV4", "IPV6"], var.ip_version)
error_message = "ip_version must be IPV4 or IPV6."
}
}
variable "description" {
type = string
description = "Optional human-readable description for the address."
default = null
}
variable "labels" {
type = map(string)
description = "Labels to apply to the address for cost tracking and ownership."
default = {}
}
# outputs.tf
output "id" {
description = "Fully-qualified ID of the reserved address."
value = local.created[0].id
}
output "name" {
description = "Name of the reserved address."
value = local.created[0].name
}
output "address" {
description = "The reserved IP address string (e.g. 34.120.0.10). Add this to DNS, allow-lists, or forwarding rules."
value = local.created[0].address
}
output "self_link" {
description = "URI (self-link) of the reserved address; pass this to forwarding rules, instances, and Cloud NAT."
value = local.created[0].self_link
}
output "address_type" {
description = "EXTERNAL or INTERNAL, as resolved on the created address."
value = local.created[0].address_type
}
output "scope" {
description = "REGIONAL or GLOBAL — which underlying resource was created."
value = var.scope
}
output "prefix_length" {
description = "Reserved prefix length (set only for purpose = VPC_PEERING), else null."
value = local.is_global ? google_compute_global_address.this[0].prefix_length : null
}
How to use it
# 1) Global external IP for the global external Application Load Balancer.
module "static_ip" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-static-ip?ref=v1.0.0"
project_id = "kloudvin-prod-net"
name = "addr-prod-alb-frontend"
scope = "GLOBAL"
address_type = "EXTERNAL"
ip_version = "IPV4"
description = "Anycast frontend IP for the prod global external ALB"
labels = {
env = "prod"
owner = "platform"
}
}
# Downstream: the global forwarding rule references the reserved IP string.
resource "google_compute_global_forwarding_rule" "https" {
name = "fr-prod-alb-https"
project = "kloudvin-prod-net"
ip_address = module.static_ip.address # the stable anycast IP
ip_protocol = "TCP"
port_range = "443"
load_balancing_scheme = "EXTERNAL_MANAGED"
target = google_compute_target_https_proxy.https.id
}
# 2) Regional external IP pinned to a Compute Engine VM's nic0.
module "vm_ip" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-static-ip?ref=v1.0.0"
project_id = "kloudvin-prod-net"
name = "addr-prod-bastion-asia-south1"
scope = "REGIONAL"
region = "asia-south1"
address_type = "EXTERNAL"
network_tier = "STANDARD" # regional egress, cheaper tier
}
resource "google_compute_instance" "bastion" {
name = "vm-prod-bastion"
project = "kloudvin-prod-net"
zone = "asia-south1-a"
machine_type = "e2-small"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
subnetwork = google_compute_subnetwork.mgmt.id
access_config {
nat_ip = module.vm_ip.address # stable public IP on nic0
network_tier = "STANDARD"
}
}
}
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/static_ip/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-static-ip?ref=v1.0.0"
}
inputs = {
project_id = "..."
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/static_ip && 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 reserved address. |
| name | string | — | yes | Address name (1-63 chars, lowercase RFC1035). |
| scope | string | “REGIONAL” | no | REGIONAL (google_compute_address) or GLOBAL (google_compute_global_address). |
| region | string | null | conditional | Region for a REGIONAL address; required when scope = REGIONAL. |
| address_type | string | “EXTERNAL” | no | EXTERNAL or INTERNAL. |
| purpose | string | null | no | GCE_ENDPOINT, SHARED_LOADBALANCER_VIP, VPC_PEERING, PRIVATE_SERVICE_CONNECT, or null. |
| network_tier | string | “PREMIUM” | no | PREMIUM or STANDARD; applies only to regional EXTERNAL addresses. |
| subnetwork | string | null | conditional | Subnetwork to allocate from; required for a REGIONAL INTERNAL address. |
| address | string | null | no | Optional fixed IP to reserve; omit to auto-allocate. |
| prefix_length | number | null | conditional | Reserved block size; required for purpose = VPC_PEERING (8-30). |
| network | string | null | conditional | VPC network for the PSA range; required for purpose = VPC_PEERING. |
| ip_version | string | “IPV4” | no | IPV4 or IPV6 for a GLOBAL external address. |
| description | string | null | no | Optional description for the address. |
| labels | map(string) | {} | no | Labels for cost tracking and ownership. |
Outputs
| Name | Description |
|---|---|
| id | Fully-qualified ID of the reserved address. |
| name | Name of the reserved address. |
| address | The reserved IP address string — add to DNS, allow-lists, or forwarding rules. |
| self_link | URI (self-link) of the address; pass to forwarding rules, instances, and Cloud NAT. |
| address_type | EXTERNAL or INTERNAL, as resolved on the created address. |
| scope | REGIONAL or GLOBAL — which underlying resource was created. |
| prefix_length | Reserved prefix length (set only for purpose = VPC_PEERING), else null. |
Enterprise scenario
A fintech platform team runs a Shared VPC landing zone and exposes a single public API behind the global external Application Load Balancer. They instantiate this module once with scope = "GLOBAL" to reserve the anycast frontend IP, hand that exact address to the bank’s network team to allow-list, and never have it change across blue/green frontend redeploys. In the same root module they call it again with scope = "REGIONAL", address_type = "INTERNAL", and purpose = "VPC_PEERING" + prefix_length = 16 (global) to reserve the Private Service Access block that Cloud SQL and Memorystore draw their private IPs from — so a future managed-service addition slots into a pre-sized, already-audited range instead of a last-minute scramble.
Best practices
- Reserve before you attach, and attach promptly. A reserved external IP that sits unattached still bills at a small hourly rate, while an IP that is in use is generally free. Don’t pre-reserve a pile of addresses you won’t wire up for weeks; create them as part of the same plan that consumes them.
- Pick
network_tierdeliberately for external regional IPs.STANDARDegresses from the local region and is cheaper but cannot back a global load balancer or give cross-region resilience;PREMIUMrides Google’s backbone. The tier is fixed at reservation, so changing it later means a new IP — choose up-front based on whether the address must be global-capable. - Always reserve the IP for anything DNS or a partner allow-lists. The whole point of a static address is stability: never let a load balancer or VM frontend run on an ephemeral IP if its value is published anywhere, because an ephemeral IP is released the moment the resource is recreated.
- Match
purposeto the consumer exactly. Regional internal VM/ILB IPs useGCE_ENDPOINTorSHARED_LOADBALANCER_VIP; Private Service Access ranges use a globalVPC_PEERINGaddress withprefix_length; PSC endpoints usePRIVATE_SERVICE_CONNECT. A mismatched purpose is rejected only at apply time, so the module’s validation saves a failed run. - Right-size the PSA
prefix_length. TheVPC_PEERINGblock is consumed by every managed service you peer (Cloud SQL, Memorystore, Vertex AI, …). A/24runs out fast in a busy project; a/16is the common safe choice and is far cheaper to allocate now than to renumber later. - Label every reserved IP and guard production ones. Apply
labelsfor env/owner cost attribution, and addlifecycle { prevent_destroy = true }in the calling module for IPs that external systems depend on — releasing a live public address hands it back to GCP’s pool and you will almost never get the same one again.