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
- You expose Cloud Run or Cloud Functions services and want a stable, custom-branded API surface with auth and quotas, but don’t want to run or pay for a full Apigee deployment.
- You need API-key or Google-ID-token / JWT validation enforced at the edge before traffic ever reaches your function, so the backend code stays focused on business logic.
- You want multiple gateways per API (e.g. a
dev,staging, andprodgateway sharing one logical API definition but pointing at different backends or regions). - You are standardising an internal platform where dozens of teams each ship a serverless API, and you want every gateway to be created identically — same logging, same naming, same SA hygiene.
- Skip it if you only need raw L7 load balancing without auth/quotas (use a Serverless NEG behind a Global LB instead), or if you need advanced mediation, monetization, or developer portals (that’s Apigee territory).
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 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/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
- Treat the OpenAPI doc as the contract and render it deterministically. Build it with
templatefile()and let the module’s spec-hash drive config naming — never hand-editapi_config_id, or you’ll hit immutable-config409errors on re-apply. - Use a dedicated, least-privilege invoker SA per API. Keep
create_service_account = trueand grant it onlyroles/run.invoker(orcloudfunctions.invoker) on the specific backend — not project-wide — so a compromised gateway can’t reach unrelated services. - Enforce auth at the edge, not just the backend. Define
securityDefinitions(API key orx-google-issuerJWT) in the spec and require it on every path; pair API keys withx-google-management.quotato throttle abusive callers before they hit your function. - Front the gateway with a custom domain via Cloud DNS or a Global LB. The raw
*.gateway.devhost is fine for testing, but consume thedefault_hostnameoutput behind your own domain so you can swap gateways (blue/green) without changing client config. - Watch the cost driver: per-call pricing, not idle infrastructure. API Gateway bills per API call (with a generous free tier), so the expensive surprise is a chatty or unauthenticated public endpoint — quotas and auth are also your cost controls. There is no per-hour gateway charge.
- Pin the gateway to the same region as the backend to avoid cross-region latency and egress, and keep
region, the Cloud Runlocation, and any regional LB aligned for the lowest round-trip and clearest blast radius.