IaC GCP

Terraform Module: GCP Static IP — one wrapper for regional and global reserved addresses

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

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 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/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

TerraformGCPStatic IPModuleIaC
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