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:
google_compute_global_address— the static anycast IPv4 (or IPv6) address clients resolve to. Global, not regional.google_compute_global_forwarding_rule— binds that IP and a port (443) to a target proxy. This is the actual frontend.google_compute_target_https_proxy— terminates TLS using one or more SSL certificates, then hands the decrypted request to a URL map.google_compute_managed_ssl_certificate— a Google-managed cert that auto-provisions and auto-renews once DNS points at the LB IP. No manual cert rotation.google_compute_url_map— the L7 routing brain: host/path rules deciding which backend service serves a given request. Even a “single backend” LB needs a default service here.google_compute_backend_service— the global backend: balancing mode, timeout, CDN toggle, Cloud Armor binding, and the set of backends (instance groups or serverless NEGs).google_compute_health_check— determines backend health. A backend service is useless without one; unhealthy backends are pulled from rotation automatically.
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:
- You serve public HTTP(S) traffic to a global audience and want edge termination, anycast routing, and built-in DDoS protection from Google’s front end.
- You need managed TLS — Google-managed certificates that provision and renew themselves, so nobody is paged at 2 a.m. for an expired cert.
- You want L7 host/path routing to multiple backends (an API on
/api/*, a SPA on/, static assets behind Cloud CDN) from a single IP. - You’re fronting GKE, managed instance groups, or Cloud Run/serverless NEGs and want a consistent, code-reviewed frontend across dev/stage/prod.
- You plan to attach Cloud Armor WAF policies or enable Cloud CDN and want those toggles to live in version control.
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 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/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
- Prefer Google-managed certificates over self-managed ones for public domains — they auto-provision and auto-renew, eliminating expiry incidents. Just remember the cert only goes
ACTIVEonce DNS points at the LB IP, so create the A record in the same apply. - Attach a Cloud Armor security policy to the backend service for any internet-facing LB. Start with adaptive protection and a sensible rate-limit rule; the global LB already absorbs L3/L4 DDoS at Google’s edge, but L7 WAF rules are yours to set.
- Tune health checks to your app, not the defaults. A dedicated
/healthzendpoint that checks real dependencies, withunhealthy_thresholdandcheck_interval_secmatched to your startup time, prevents both flapping and slow failover. Health-check probes come from Google’s well-known ranges — allow them in your firewall. - Enable Cloud CDN for static-heavy backends via
enable_cdn, but scopecache_modedeliberately (CACHE_ALL_STATICis a safe default) and set TTLs that match your cache-busting strategy so you don’t serve stale assets. - Pin the provider (
~> 5.0) and the module tag (?ref=v1.0.0). The compute API surface shifts between provider majors; an unpinnedgoogleprovider can silently rewrite forwarding rules or backend schemes on the nextterraform init. - Use
EXTERNAL_MANAGED, not the legacyEXTERNALscheme. It is the current global LB data plane (Envoy-based) and unlocks advanced routing, traffic splitting and header manipulation that the classic scheme cannot do.