IaC GCP

Terraform Module: GCP Cloud Load Balancing — one global anycast IP for HTTP(S) at the edge

GCP’s Global External HTTP(S) Load Balancer is one of those services that looks like a single product in the console but is actually six or seven distinct API resources stitched together with self-links. Get the wiring order wrong, forget the global IP, or point a proxy at the wrong URL map, and you spend an afternoon chasing a 502 that has nothing to do with your application. A Terraform module turns that brittle chain into a single, version-pinned interface.

Quick take — Build a reusable Terraform module for GCP’s Global External HTTP(S) Load Balancer — forwarding rule, target HTTPS proxy, URL map, backend service, managed SSL certs and health checks wired end to end. 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 "load_balancer" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-load-balancer?ref=v1.0.0"

  project_id = "..."           # GCP project that owns the LB resources.
  name       = "..."           # RFC1035 prefix for all resource names.
  backends   = ["...", "..."]  # Backend groups (instance-group or NEG self-links) with …
}

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

What this module is

A global external HTTP(S) load balancer on Google Cloud is a proxy-based, anycast load balancer that lives at Google’s network edge. Unlike a regional LB, a single frontend IP is announced from over 100 points of presence worldwide, and Google routes each client to the nearest healthy backend. There is no single region to fail; the data plane is the Google front-end (GFE) fleet itself.

The catch is that “one load balancer” decomposes into a chain of resources, each referencing the next by self_link:

You wrap this in a module because the chain is order-sensitive and unforgiving. The forwarding rule must reference the proxy, the proxy the URL map and certs, the URL map the backend service, and the backend service both the health check and the IP family must agree. Hand-writing all seven resources per environment invites copy-paste drift. A module exposes a handful of variables (project, domains, backend group, CDN on/off) and guarantees the internals are connected correctly every time. It also gives you one place to enforce conventions: HTTP→HTTPS redirect, sane health-check defaults, and a single LB IP output that DNS and downstream modules can consume.

When to use it

Reach for this module when:

Skip it for purely internal/private traffic (use an internal LB), for non-HTTP protocols like raw TCP/UDP (use the network LB), or when a single region with a regional external LB is sufficient and you don’t need global anycast.

Module structure

terraform-module-gcp-load-balancer/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # the full LB resource chain
├── variables.tf     # inputs (project, domains, backends, CDN, Armor)
├── outputs.tf       # LB IP, backend service id, cert status
└── README.md        # usage docs

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  name = var.name

  # Use a managed cert only when domains are supplied AND no pre-existing
  # cert self-links were passed in.
  use_managed_cert = length(var.managed_cert_domains) > 0 && length(var.ssl_certificates) == 0

  # The cert self-links the HTTPS proxy will reference.
  certificate_links = (
    local.use_managed_cert
    ? [google_compute_managed_ssl_certificate.default[0].self_link]
    : var.ssl_certificates
  )
}

# ---------------------------------------------------------------------------
# Frontend: global anycast IP
# ---------------------------------------------------------------------------
resource "google_compute_global_address" "default" {
  project    = var.project_id
  name       = "${local.name}-ip"
  ip_version = "IPV4"
}

# ---------------------------------------------------------------------------
# Health check (HTTP or HTTPS, var-driven)
# ---------------------------------------------------------------------------
resource "google_compute_health_check" "default" {
  project             = var.project_id
  name                = "${local.name}-hc"
  check_interval_sec  = var.health_check.check_interval_sec
  timeout_sec         = var.health_check.timeout_sec
  healthy_threshold   = var.health_check.healthy_threshold
  unhealthy_threshold = var.health_check.unhealthy_threshold

  dynamic "http_health_check" {
    for_each = var.health_check.protocol == "HTTP" ? [1] : []
    content {
      port               = var.health_check.port
      request_path       = var.health_check.request_path
      port_specification = "USE_FIXED_PORT"
    }
  }

  dynamic "https_health_check" {
    for_each = var.health_check.protocol == "HTTPS" ? [1] : []
    content {
      port               = var.health_check.port
      request_path       = var.health_check.request_path
      port_specification = "USE_FIXED_PORT"
    }
  }

  log_config {
    enable = var.health_check.enable_logging
  }
}

# ---------------------------------------------------------------------------
# Backend service (global) — attaches backends + health check + CDN + Armor
# ---------------------------------------------------------------------------
resource "google_compute_backend_service" "default" {
  project                         = var.project_id
  name                            = "${local.name}-bes"
  load_balancing_scheme           = "EXTERNAL_MANAGED"
  protocol                        = var.backend_protocol
  port_name                       = var.backend_port_name
  timeout_sec                     = var.backend_timeout_sec
  connection_draining_timeout_sec = var.connection_draining_timeout_sec
  enable_cdn                      = var.enable_cdn
  health_checks                   = [google_compute_health_check.default.self_link]

  # Optional Cloud Armor WAF / DDoS policy.
  security_policy = var.security_policy

  dynamic "backend" {
    for_each = var.backends
    content {
      group           = backend.value.group
      balancing_mode  = backend.value.balancing_mode
      capacity_scaler = backend.value.capacity_scaler
      max_utilization = backend.value.balancing_mode == "UTILIZATION" ? backend.value.max_utilization : null
    }
  }

  dynamic "cdn_policy" {
    for_each = var.enable_cdn ? [1] : []
    content {
      cache_mode                   = "CACHE_ALL_STATIC"
      client_ttl                   = 3600
      default_ttl                  = 3600
      max_ttl                      = 86400
      negative_caching             = true
      serve_while_stale            = 86400
      signed_url_cache_max_age_sec = 0
    }
  }

  log_config {
    enable      = var.enable_logging
    sample_rate = var.enable_logging ? 1.0 : 0.0
  }
}

# ---------------------------------------------------------------------------
# URL map — default route to the backend service, plus optional path rules
# ---------------------------------------------------------------------------
resource "google_compute_url_map" "default" {
  project         = var.project_id
  name            = "${local.name}-urlmap"
  default_service = google_compute_backend_service.default.self_link

  dynamic "host_rule" {
    for_each = length(var.path_rules) > 0 ? [1] : []
    content {
      hosts        = var.managed_cert_domains
      path_matcher = "main"
    }
  }

  dynamic "path_matcher" {
    for_each = length(var.path_rules) > 0 ? [1] : []
    content {
      name            = "main"
      default_service = google_compute_backend_service.default.self_link

      dynamic "path_rule" {
        for_each = var.path_rules
        content {
          paths   = path_rule.value.paths
          service = path_rule.value.service
        }
      }
    }
  }
}

# ---------------------------------------------------------------------------
# Managed SSL certificate (optional)
# ---------------------------------------------------------------------------
resource "google_compute_managed_ssl_certificate" "default" {
  count   = local.use_managed_cert ? 1 : 0
  project = var.project_id
  name    = "${local.name}-cert"

  managed {
    domains = var.managed_cert_domains
  }

  # Cert provisioning blocks until DNS resolves to the LB IP; let Terraform
  # replace cleanly rather than fail an in-place update.
  lifecycle {
    create_before_destroy = true
  }
}

# ---------------------------------------------------------------------------
# Target HTTPS proxy — terminates TLS, points at the URL map
# ---------------------------------------------------------------------------
resource "google_compute_target_https_proxy" "default" {
  project          = var.project_id
  name             = "${local.name}-https-proxy"
  url_map          = google_compute_url_map.default.self_link
  ssl_certificates = local.certificate_links
  ssl_policy       = var.ssl_policy
}

# ---------------------------------------------------------------------------
# Global forwarding rule (443) — the public HTTPS frontend
# ---------------------------------------------------------------------------
resource "google_compute_global_forwarding_rule" "https" {
  project               = var.project_id
  name                  = "${local.name}-https-fr"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  ip_address            = google_compute_global_address.default.address
  port_range            = "443"
  target                = google_compute_target_https_proxy.default.self_link
}

# ---------------------------------------------------------------------------
# Optional HTTP -> HTTPS redirect frontend (port 80)
# ---------------------------------------------------------------------------
resource "google_compute_url_map" "redirect" {
  count   = var.enable_http_redirect ? 1 : 0
  project = var.project_id
  name    = "${local.name}-redirect-urlmap"

  default_url_redirect {
    https_redirect         = true
    redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
    strip_query            = false
  }
}

resource "google_compute_target_http_proxy" "redirect" {
  count   = var.enable_http_redirect ? 1 : 0
  project = var.project_id
  name    = "${local.name}-http-proxy"
  url_map = google_compute_url_map.redirect[0].self_link
}

resource "google_compute_global_forwarding_rule" "http" {
  count                 = var.enable_http_redirect ? 1 : 0
  project               = var.project_id
  name                  = "${local.name}-http-fr"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  ip_address            = google_compute_global_address.default.address
  port_range            = "80"
  target                = google_compute_target_http_proxy.redirect[0].self_link
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns the load balancer resources."
  type        = string
}

variable "name" {
  description = "Base name used as a prefix for all LB resources (must be RFC1035: lowercase, hyphens)."
  type        = string

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.name))
    error_message = "name must be a valid RFC1035 label: lowercase letters, digits and hyphens."
  }
}

variable "backends" {
  description = "List of backend groups (instance-group or NEG self-links) attached to the backend service."
  type = list(object({
    group           = string
    balancing_mode  = optional(string, "UTILIZATION")
    capacity_scaler = optional(number, 1.0)
    max_utilization = optional(number, 0.8)
  }))

  validation {
    condition     = length(var.backends) > 0
    error_message = "At least one backend group must be supplied."
  }
}

variable "managed_cert_domains" {
  description = "Domains for a Google-managed SSL certificate. Leave empty to supply your own ssl_certificates."
  type        = list(string)
  default     = []
}

variable "ssl_certificates" {
  description = "Self-links of pre-existing SSL certificates. Ignored when managed_cert_domains is set."
  type        = list(string)
  default     = []
}

variable "ssl_policy" {
  description = "Self-link of an optional google_compute_ssl_policy (enforce TLS 1.2+ / modern ciphers)."
  type        = string
  default     = null
}

variable "backend_protocol" {
  description = "Protocol from the LB to the backends."
  type        = string
  default     = "HTTPS"

  validation {
    condition     = contains(["HTTP", "HTTPS", "HTTP2"], var.backend_protocol)
    error_message = "backend_protocol must be HTTP, HTTPS or HTTP2."
  }
}

variable "backend_port_name" {
  description = "Named port on the backend instance group the LB should target."
  type        = string
  default     = "https"
}

variable "backend_timeout_sec" {
  description = "Backend request timeout in seconds."
  type        = number
  default     = 30
}

variable "connection_draining_timeout_sec" {
  description = "Seconds to drain in-flight connections when a backend is removed."
  type        = number
  default     = 60
}

variable "enable_cdn" {
  description = "Enable Cloud CDN on the backend service (caches static content at the edge)."
  type        = bool
  default     = false
}

variable "security_policy" {
  description = "Self-link of an optional Cloud Armor security policy (WAF / DDoS / rate limiting)."
  type        = string
  default     = null
}

variable "enable_http_redirect" {
  description = "Create a port-80 frontend that 301-redirects all HTTP traffic to HTTPS."
  type        = bool
  default     = true
}

variable "enable_logging" {
  description = "Enable backend-service request logging (sampled at 100% when true)."
  type        = bool
  default     = true
}

variable "path_rules" {
  description = "Optional L7 path-based routing rules to additional backend services."
  type = list(object({
    paths   = list(string)
    service = string
  }))
  default = []
}

variable "health_check" {
  description = "Health-check configuration for the backend service."
  type = object({
    protocol            = optional(string, "HTTP")
    port                = optional(number, 80)
    request_path        = optional(string, "/healthz")
    check_interval_sec  = optional(number, 5)
    timeout_sec         = optional(number, 5)
    healthy_threshold   = optional(number, 2)
    unhealthy_threshold = optional(number, 3)
    enable_logging      = optional(bool, false)
  })
  default = {}

  validation {
    condition     = contains(["HTTP", "HTTPS"], var.health_check.protocol)
    error_message = "health_check.protocol must be HTTP or HTTPS."
  }
}

outputs.tf

output "load_balancer_ip" {
  description = "The global anycast IPv4 address of the load balancer. Point your DNS A record here."
  value       = google_compute_global_address.default.address
}

output "load_balancer_ip_name" {
  description = "Name of the reserved global address resource."
  value       = google_compute_global_address.default.name
}

output "backend_service_id" {
  description = "ID of the global backend service (for attaching Cloud Armor or referencing elsewhere)."
  value       = google_compute_backend_service.default.id
}

output "backend_service_self_link" {
  description = "Self-link of the backend service."
  value       = google_compute_backend_service.default.self_link
}

output "url_map_self_link" {
  description = "Self-link of the URL map (extend with more host/path rules downstream)."
  value       = google_compute_url_map.default.self_link
}

output "health_check_self_link" {
  description = "Self-link of the backend health check."
  value       = google_compute_health_check.default.self_link
}

output "managed_certificate_id" {
  description = "ID of the Google-managed SSL certificate, or null when an external cert was supplied."
  value       = local.use_managed_cert ? google_compute_managed_ssl_certificate.default[0].id : null
}

output "https_proxy_self_link" {
  description = "Self-link of the target HTTPS proxy."
  value       = google_compute_target_https_proxy.default.self_link
}

How to use it

Consume the module from the shared registry by pinning a Git tag, pass it your backend instance group and the domains you want a managed cert for, then hand the LB IP to your DNS module:

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

  project_id           = "kloudvin-prod"
  name                 = "kloudvin-web"
  managed_cert_domains = ["www.kloudvin.com", "kloudvin.com"]

  backends = [
    {
      group          = google_compute_instance_group_manager.web.instance_group
      balancing_mode = "UTILIZATION"
    }
  ]

  backend_protocol  = "HTTPS"
  backend_port_name = "https"

  health_check = {
    protocol     = "HTTP"
    port         = 8080
    request_path = "/healthz"
  }

  enable_cdn           = true
  enable_http_redirect = true
  security_policy      = google_compute_security_policy.edge_waf.self_link
}

# Downstream: publish the LB's anycast IP as the apex A record.
resource "google_dns_record_set" "apex" {
  project      = "kloudvin-prod"
  managed_zone = "kloudvin-com"
  name         = "kloudvin.com."
  type         = "A"
  ttl          = 300
  rrdatas      = [module.lb.load_balancer_ip]
}

output "site_ip" {
  value = module.lb.load_balancer_ip
}

The managed certificate stays in PROVISIONING until the A record above resolves to load_balancer_ip; allow 15–60 minutes after apply for it to flip to ACTIVE. The LB returns a TLS error on that domain until then — expected, not a bug.

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/load_balancer/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  backends = ["...", "..."]
}

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

cd live/prod/load_balancer && 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 that owns the LB resources.
name string Yes RFC1035 prefix for all resource names.
backends list(object) Yes Backend groups (instance-group or NEG self-links) with balancing mode.
managed_cert_domains list(string) [] No Domains for a Google-managed cert. Empty → supply ssl_certificates.
ssl_certificates list(string) [] No Pre-existing cert self-links (ignored if managed domains set).
ssl_policy string null No Self-link of an SSL policy to enforce modern TLS.
backend_protocol string "HTTPS" No LB-to-backend protocol: HTTP, HTTPS or HTTP2.
backend_port_name string "https" No Named port on the backend instance group.
backend_timeout_sec number 30 No Backend request timeout in seconds.
connection_draining_timeout_sec number 60 No Connection-draining window when removing a backend.
enable_cdn bool false No Turn on Cloud CDN edge caching.
security_policy string null No Cloud Armor policy self-link (WAF / DDoS / rate limit).
enable_http_redirect bool true No Add a port-80 frontend that 301s to HTTPS.
enable_logging bool true No Enable backend-service request logging.
path_rules list(object) [] No L7 path → backend-service routing rules.
health_check object {} No Health-check protocol, port, path and thresholds.

Outputs

Name Description
load_balancer_ip Global anycast IPv4 address — point DNS A records here.
load_balancer_ip_name Name of the reserved global address resource.
backend_service_id ID of the global backend service.
backend_service_self_link Self-link of the backend service.
url_map_self_link Self-link of the URL map for downstream extension.
health_check_self_link Self-link of the backend health check.
managed_certificate_id ID of the managed SSL cert, or null when external certs supplied.
https_proxy_self_link Self-link of the target HTTPS proxy.

Enterprise scenario

A retail platform runs its storefront on a GKE-backed managed instance group spread across three regions and serves customers on four continents. They deploy this module once per environment from a Terraform mono-repo: production gets enable_cdn = true to cache product imagery and static bundles at Google’s edge, a Cloud Armor security_policy enforcing geo and rate-limit rules to absorb credential-stuffing bursts, and managed certs for both the apex and www. Because the entire frontend — anycast IP, TLS, routing, WAF binding — is one versioned module, promoting a change from staging to prod is a tag bump and a reviewed PR rather than a console click marathon, and the single load_balancer_ip output feeds their DNS-as-code module directly.

Best practices

TerraformGCPCloud Load BalancingModuleIaC
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