IaC GCP

Terraform Module: GCP API Gateway — managed front door for serverless backends

Quick take — Reusable Terraform module for GCP API Gateway: ship an OpenAPI-driven managed gateway over Cloud Run, Cloud Functions, or App Engine with versioned configs, a dedicated service account, and zero-downtime rollouts. 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 "api_gateway" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-api-gateway?ref=v1.0.0"

  project_id       = "..."  # GCP project ID where the gateway resources are created.
  api_id           = "..."  # ID of the logical API (lowercase, starts with a letter,…
  gateway_id       = "..."  # ID of the gateway (lowercase, starts with a letter, max…
  openapi_document = "..."  # Full OpenAPI 2.0 spec (with `x-google-backend` etc.) as…
}

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

What this module is

GCP API Gateway is a fully managed service that fronts your serverless backends — Cloud Run, Cloud Functions (1st/2nd gen), and App Engine — behind a single HTTPS endpoint defined by an OpenAPI 2.0 (Swagger) spec. It handles request routing, JWT/API-key authentication, and per-key quotas without you running any proxy infrastructure. Under the hood it provisions an Envoy-based data plane and an Service Management / Service Control control plane, so you get managed scaling and telemetry for free.

The catch is that the three resources involved — google_api_gateway_api, google_api_gateway_api_config, and google_api_gateway_gateway — have a non-obvious lifecycle. The config is immutable: once created with a given OpenAPI document and backend service account, it cannot be updated in place. Change the spec and Terraform must create a new config and re-point the gateway at it. If you don’t manage that carefully you get either 409 ALREADY_EXISTS errors on re-apply or a gateway briefly serving a stale spec.

This module wraps all three resources plus the supporting IAM so the messy parts are solved once: it hashes the OpenAPI document into the config’s api_config_id_prefix so every spec change yields a fresh, uniquely-named config; it wires create_before_destroy so rollouts are zero-downtime; and it provisions (or accepts) the backend invoker service account with the right roles. You hand it a rendered OpenAPI spec and a backend URL, and you get a working https://<gateway>.<region>.gateway.dev endpoint.

When to use it

Module structure

terraform-module-gcp-api-gateway/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # api, api_config, gateway, invoker SA + IAM
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # gateway id, default_hostname, config id, SA email
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # Hash the rendered OpenAPI document so that ANY change to the spec
  # produces a new, uniquely-named (immutable) api_config. This is what
  # makes zero-downtime spec rollouts work.
  spec_hash = substr(sha256(var.openapi_document), 0, 8)

  # Either use the caller-supplied SA, or the one we create below.
  gateway_sa_email = var.create_service_account ? google_service_account.gateway[0].email : var.gateway_service_account_email
}

# ---------------------------------------------------------------------------
# Backend invoker service account
# The gateway calls your backend AS this identity; it needs run.invoker /
# cloudfunctions.invoker on the target service.
# ---------------------------------------------------------------------------
resource "google_service_account" "gateway" {
  count = var.create_service_account ? 1 : 0

  project      = var.project_id
  account_id   = "${var.api_id}-gw-invoker"
  display_name = "API Gateway invoker for ${var.api_id}"
}

# ---------------------------------------------------------------------------
# 1. The logical API
# ---------------------------------------------------------------------------
resource "google_api_gateway_api" "this" {
  provider = google-beta

  project      = var.project_id
  api_id       = var.api_id
  display_name = coalesce(var.display_name, var.api_id)

  labels = var.labels
}

# ---------------------------------------------------------------------------
# 2. The immutable API config (OpenAPI document + backend identity)
#    api_config_id_prefix + spec hash => new config on every spec change.
# ---------------------------------------------------------------------------
resource "google_api_gateway_api_config" "this" {
  provider = google-beta

  project       = var.project_id
  api           = google_api_gateway_api.this.api_id
  api_config_id = "${var.api_id}-cfg-${local.spec_hash}"

  display_name = "${var.api_id} config ${local.spec_hash}"
  labels       = var.labels

  openapi_documents {
    document {
      path     = "openapi.yaml"
      contents = base64encode(var.openapi_document)
    }
  }

  gateway_config {
    backend_config {
      google_service_account = local.gateway_sa_email
    }
  }

  lifecycle {
    create_before_destroy = true
  }
}

# ---------------------------------------------------------------------------
# 3. The gateway (the actual *.gateway.dev endpoint)
# ---------------------------------------------------------------------------
resource "google_api_gateway_gateway" "this" {
  provider = google-beta

  project    = var.project_id
  region     = var.region
  gateway_id = var.gateway_id
  api_config = google_api_gateway_api_config.this.id

  display_name = coalesce(var.display_name, var.gateway_id)
  labels       = var.labels

  lifecycle {
    create_before_destroy = true
  }
}

# ---------------------------------------------------------------------------
# Let the managed API Gateway service control plane be enabled on the
# generated managed service so the gateway can serve traffic.
# (The api_config above auto-creates a managed service; we grant the
# invoker SA permission on the backend Cloud Run service.)
# ---------------------------------------------------------------------------
resource "google_cloud_run_service_iam_member" "invoker" {
  for_each = var.cloud_run_backend == null ? {} : { backend = var.cloud_run_backend }

  project  = var.project_id
  location = each.value.location
  service  = each.value.service_name
  role     = "roles/run.invoker"
  member   = "serviceAccount:${local.gateway_sa_email}"
}
# variables.tf

variable "project_id" {
  description = "GCP project ID where the API Gateway resources are created."
  type        = string
}

variable "region" {
  description = "Region for the gateway (the *.gateway.dev endpoint). API Gateway is regional."
  type        = string
  default     = "us-central1"

  validation {
    condition     = can(regex("^[a-z]+-[a-z]+[0-9]$", var.region))
    error_message = "region must be a valid GCP region like us-central1 or europe-west1."
  }
}

variable "api_id" {
  description = "Identifier for the logical API. Lowercase letters, digits and hyphens; must start with a letter."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,62}$", var.api_id))
    error_message = "api_id must start with a letter and contain only lowercase letters, digits and hyphens (max 63 chars)."
  }
}

variable "gateway_id" {
  description = "Identifier for the gateway. Lowercase letters, digits and hyphens; must start with a letter."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,62}$", var.gateway_id))
    error_message = "gateway_id must start with a letter and contain only lowercase letters, digits and hyphens (max 63 chars)."
  }
}

variable "display_name" {
  description = "Human-friendly display name applied to the API and gateway. Defaults to the IDs."
  type        = string
  default     = null
}

variable "openapi_document" {
  description = <<-EOT
    The full OpenAPI 2.0 (Swagger) document as a string. Must include the
    Google extensions (x-google-backend with the backend address, and
    optionally x-google-management.quota / securityDefinitions). Typically
    produced via templatefile() so the backend URL is injected per environment.
  EOT
  type        = string

  validation {
    condition     = length(var.openapi_document) > 0
    error_message = "openapi_document must not be empty."
  }
}

variable "create_service_account" {
  description = "If true, create a dedicated backend-invoker service account. If false, supply gateway_service_account_email."
  type        = bool
  default     = true
}

variable "gateway_service_account_email" {
  description = "Existing service account email the gateway uses to call the backend. Required when create_service_account = false."
  type        = string
  default     = null

  validation {
    condition     = var.gateway_service_account_email == null || can(regex("^[^@]+@[^@]+\\.iam\\.gserviceaccount\\.com$", coalesce(var.gateway_service_account_email, "x@x.iam.gserviceaccount.com")))
    error_message = "gateway_service_account_email must be a valid IAM service account email."
  }
}

variable "cloud_run_backend" {
  description = <<-EOT
    Optional Cloud Run service to grant roles/run.invoker to the gateway SA.
    Object with service_name and location. Leave null if the backend is a
    Cloud Function or App Engine app, or if you grant the role elsewhere.
  EOT
  type = object({
    service_name = string
    location     = string
  })
  default = null
}

variable "labels" {
  description = "Labels applied to the API, config and gateway."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "api_id" {
  description = "The logical API resource ID."
  value       = google_api_gateway_api.this.api_id
}

output "gateway_id" {
  description = "The gateway resource ID."
  value       = google_api_gateway_gateway.this.gateway_id
}

output "gateway_self_link" {
  description = "Fully-qualified resource name of the gateway."
  value       = google_api_gateway_gateway.this.id
}

output "default_hostname" {
  description = "The default *.gateway.dev hostname clients call. Use this in DNS/LB config."
  value       = google_api_gateway_gateway.this.default_hostname
}

output "api_config_id" {
  description = "The current (immutable) API config ID — changes whenever the OpenAPI spec changes."
  value       = google_api_gateway_api_config.this.api_config_id
}

output "managed_service" {
  description = "The auto-generated managed service name backing this API (useful for enabling APIs / quotas)."
  value       = google_api_gateway_api.this.managed_service
}

output "gateway_service_account_email" {
  description = "Email of the service account the gateway uses to invoke the backend."
  value       = local.gateway_sa_email
}

How to use it

Render the OpenAPI spec with templatefile() so the backend Cloud Run URL is injected per environment, then pass it into the module:

# openapi.yaml.tftpl (excerpt)
# swagger: "2.0"
# info: { title: orders-api, version: "1.0.0" }
# schemes: [ "https" ]
# produces: [ "application/json" ]
# paths:
#   /orders:
#     get:
#       operationId: listOrders
#       x-google-backend:
#         address: ${backend_url}
#       responses: { "200": { description: OK } }
# securityDefinitions:
#   api_key:
#     type: apiKey
#     name: key
#     in: query

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

  project_id = "kloudvin-prod"
  region     = "asia-south1"

  api_id     = "orders-api"
  gateway_id = "orders-gw-prod"

  openapi_document = templatefile("${path.module}/openapi.yaml.tftpl", {
    backend_url = google_cloud_run_v2_service.orders.uri
  })

  # Grant the gateway SA run.invoker on the backend service.
  cloud_run_backend = {
    service_name = google_cloud_run_v2_service.orders.name
    location     = "asia-south1"
  }

  labels = {
    env  = "prod"
    team = "commerce"
  }
}

# Downstream: point a Cloud DNS record at the gateway hostname so clients
# call api.kloudvin.com instead of the raw *.gateway.dev address.
resource "google_dns_record_set" "api" {
  project      = "kloudvin-prod"
  managed_zone = "kloudvin-com"
  name         = "api.kloudvin.com."
  type         = "CNAME"
  ttl          = 300
  rrdatas      = ["${module.api_gateway.default_hostname}."]
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  api_id = "..."
  gateway_id = "..."
  openapi_document = "..."
}

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

cd live/prod/api_gateway && 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 where the gateway resources are created.
region string "us-central1" No Region for the gateway endpoint; API Gateway is regional.
api_id string Yes ID of the logical API (lowercase, starts with a letter, max 63 chars).
gateway_id string Yes ID of the gateway (lowercase, starts with a letter, max 63 chars).
display_name string null No Human-friendly name for the API and gateway; defaults to the IDs.
openapi_document string Yes Full OpenAPI 2.0 spec (with x-google-backend etc.) as a string.
create_service_account bool true No Create a dedicated backend-invoker SA; if false, supply an existing one.
gateway_service_account_email string null No Existing invoker SA email; required when create_service_account = false.
cloud_run_backend object({service_name, location}) null No Cloud Run service to grant roles/run.invoker to the gateway SA.
labels map(string) {} No Labels applied to the API, config and gateway.

Outputs

Name Description
api_id The logical API resource ID.
gateway_id The gateway resource ID.
gateway_self_link Fully-qualified resource name of the gateway.
default_hostname The *.gateway.dev hostname clients call (use in DNS/LB).
api_config_id Current immutable API config ID; changes when the spec changes.
managed_service Auto-generated managed service name backing the API.
gateway_service_account_email Email of the SA the gateway uses to invoke the backend.

Enterprise scenario

A retail platform team runs ~30 internal Cloud Run microservices and wants every one exposed through a consistent, authenticated edge without standing up Apigee. They call this module once per service in each microservice’s Terraform stack, rendering a per-service OpenAPI spec that injects the Cloud Run URL and enforces a Google-ID-token securityDefinition. Because the module hashes the spec into an immutable config name and uses create_before_destroy, the platform team can roll out a new route or quota change via CI with zero downtime, and every gateway lands with the same labels, dedicated invoker SA, and api.<service>.internal.retailco.com CNAME — giving central security one predictable pattern to audit across all 30 APIs.

Best practices

TerraformGCPAPI GatewayModuleIaC
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