Quick take — Reusable Terraform module for GCP Cloud Endpoints: deploy OpenAPI or gRPC service configs to Service Management, drive ESPv2 rollouts on Cloud Run/GKE, and grant the proxy service-controller access — all version-pinned and auditable. 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 "cloud_endpoints" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-endpoints?ref=v1.0.0"
project_id = "..." # GCP project that owns the Endpoints service and ESPv2 i…
service_name = "..." # Fully-qualified service name (e.g. `orders.endpoints.<p…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
GCP Cloud Endpoints is an API management layer built on Google’s Service Infrastructure — the same Service Management and Service Control APIs that power Google’s own APIs. You describe your API as an OpenAPI 2.0 document (or a gRPC api_descriptor.pb plus a YAML config), deploy that config under a service name like orders.endpoints.<project>.cloud.goog, and then run the Extensible Service Proxy V2 (ESPv2) — an Envoy-based sidecar/gateway — in front of your backend. ESPv2 pulls the deployed config, validates API keys and JWTs, enforces quotas, and streams metrics and logs back to Service Control. The result is per-method auth, quotas, and observability for a backend on Cloud Run, GKE, Compute Engine, or App Engine Flex.
The part Terraform actually owns is narrow but critical: the google_endpoints_service resource deploys a service config and creates a new rollout against Service Management. It does not run ESPv2 for you — that is a container you deploy separately — and that split trips people up constantly. Each apply that changes the OpenAPI/gRPC document creates a brand-new immutable config_id (a timestamped revision like 2026-06-09r0), and ESPv2 must be told which config_id to serve (or be allowed to roll to latest). Get the wiring wrong and the proxy 503s because it’s serving a config the backend no longer matches, or because the proxy’s service account lacks roles/servicemanagement.serviceController.
This module wraps the deploy-and-roll-out lifecycle so it’s solved once. It accepts either an OpenAPI document or a gRPC descriptor+config, deploys it as a google_endpoints_service, enables the generated managed service on the project, and provisions (or accepts) the ESPv2 runtime service account with roles/servicemanagement.serviceController and roles/cloudtrace.agent. It then surfaces the live config_id and dns_address as outputs so your Cloud Run/GKE ESPv2 deployment can pin the exact config it serves — no guessing, no drift between the deployed config and the running proxy.
When to use it
- You run a backend on Cloud Run, GKE, or Compute Engine and want API-key + JWT validation, per-consumer quotas, and Service Control metrics, but you want to keep ESPv2 in your own runtime rather than hand control to a fully managed gateway.
- You expose a gRPC API and need transcoding / validation at the edge — Cloud Endpoints for gRPC is one of the few first-party ways to get a managed gRPC config with ESPv2.
- You want the service config to be a versioned artifact: every OpenAPI change produces an immutable, named
config_idyou can reference, audit, and roll back to, with the deploy living in the same Terraform plan as the rest of the stack. - You need API-key enforcement and consumer quotas (
x-google-management.quota/metrics) tied to Cloud Console “Enabled APIs & Services”, so each calling project shows up as a measurable consumer. - Skip it if you want a zero-proxy, fully managed front door — that’s API Gateway (which actually runs the Envoy data plane for you). Use Cloud Endpoints when you specifically want to own and co-locate the ESPv2 proxy with your workload (sidecar in the same Pod, sidecar container in the same Cloud Run service, etc.).
Module structure
terraform-module-gcp-cloud-endpoints/
├── versions.tf # provider + Terraform version pins
├── main.tf # endpoints_service (OpenAPI or gRPC), managed-service enablement, ESPv2 SA + IAM
├── variables.tf # var-driven inputs with validation
└── outputs.tf # service name, live config_id, dns_address, ESPv2 SA email
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Exactly one of openapi_config / grpc config must be supplied (enforced by
# variable validation below). This flag selects the deploy mode.
is_grpc = var.grpc_service_definition != null
# The proxy runs AS this identity and reports to Service Control with it.
esp_sa_email = var.create_service_account ? google_service_account.esp[0].email : var.esp_service_account_email
}
# ---------------------------------------------------------------------------
# 1. Deploy the service config + create a new rollout in Service Management.
# Each change to the document(s) yields a new immutable config_id.
# OpenAPI mode: a single openapi_config block.
# ---------------------------------------------------------------------------
resource "google_endpoints_service" "openapi" {
count = local.is_grpc ? 0 : 1
project = var.project_id
service_name = var.service_name
openapi_config = var.openapi_config
}
# ---------------------------------------------------------------------------
# gRPC mode: a base64 api_descriptor.pb + the gRPC service YAML config.
# ---------------------------------------------------------------------------
resource "google_endpoints_service" "grpc" {
count = local.is_grpc ? 1 : 0
project = var.project_id
service_name = var.service_name
grpc_config = var.grpc_service_definition.grpc_config
protoc_output_base64 = var.grpc_service_definition.protoc_output_base64
}
locals {
endpoints_service = local.is_grpc ? google_endpoints_service.grpc[0] : google_endpoints_service.openapi[0]
}
# ---------------------------------------------------------------------------
# 2. Enable the generated managed service on the project so ESPv2 can serve
# it and so it appears under "Enabled APIs & Services". Without this,
# Service Control rejects calls with SERVICE_DISABLED.
# ---------------------------------------------------------------------------
resource "google_project_service" "managed" {
count = var.enable_managed_service ? 1 : 0
project = var.project_id
service = local.endpoints_service.service_name
# The managed service is owned by the endpoints_service resource; don't let
# `terraform destroy` disable it out from under a still-running proxy.
disable_on_destroy = false
disable_dependent_services = false
}
# ---------------------------------------------------------------------------
# 3. ESPv2 runtime service account.
# The proxy needs servicemanagement.serviceController to read config and
# report metrics/quota to Service Control, plus trace agent for telemetry.
# ---------------------------------------------------------------------------
resource "google_service_account" "esp" {
count = var.create_service_account ? 1 : 0
project = var.project_id
account_id = "${var.service_account_prefix}-esp"
display_name = "ESPv2 runtime SA for ${var.service_name}"
}
resource "google_project_iam_member" "esp_controller" {
count = var.grant_esp_iam ? 1 : 0
project = var.project_id
role = "roles/servicemanagement.serviceController"
member = "serviceAccount:${local.esp_sa_email}"
}
resource "google_project_iam_member" "esp_trace" {
count = var.grant_esp_iam ? 1 : 0
project = var.project_id
role = "roles/cloudtrace.agent"
member = "serviceAccount:${local.esp_sa_email}"
}
# variables.tf
variable "project_id" {
description = "GCP project ID that owns the Endpoints service and the ESPv2 runtime identity."
type = string
}
variable "service_name" {
description = <<-EOT
Fully-qualified Endpoints service name. For Cloud Run/GKE backends use the
generated form "<api>.endpoints.<project_id>.cloud.goog"; for a custom
domain it must be a domain you have verified ownership of.
EOT
type = string
validation {
condition = can(regex("^[a-z0-9.-]+\\.[a-z]{2,}$", var.service_name))
error_message = "service_name must be a DNS-style name, e.g. orders.endpoints.my-project.cloud.goog."
}
}
variable "openapi_config" {
description = <<-EOT
The full OpenAPI 2.0 (Swagger) document as a YAML string. The `host:` field
MUST equal service_name, and it should include the Google extensions
(x-google-backend / x-google-management with metrics + quota,
securityDefinitions for API keys or JWT). Render with templatefile() so the
backend address is injected per environment. Leave null for gRPC mode.
EOT
type = string
default = null
}
variable "grpc_service_definition" {
description = <<-EOT
gRPC mode. Supply the gRPC service YAML config (which references the API by
name and declares usage/quota rules) and the base64-encoded compiled
api_descriptor.pb produced by `protoc --descriptor_set_out`. Leave null for
OpenAPI mode.
EOT
type = object({
grpc_config = string
protoc_output_base64 = string
})
default = null
}
variable "enable_managed_service" {
description = "Enable the generated managed service on the project so ESPv2 can serve it and it appears under Enabled APIs."
type = bool
default = true
}
variable "create_service_account" {
description = "If true, create a dedicated ESPv2 runtime service account. If false, supply esp_service_account_email."
type = bool
default = true
}
variable "service_account_prefix" {
description = "Prefix for the generated ESPv2 service account ID (<prefix>-esp). Lowercase letters, digits, hyphens."
type = string
default = "endpoints"
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,25}$", var.service_account_prefix))
error_message = "service_account_prefix must start with a letter, be lowercase alphanumeric/hyphen, and leave room for the -esp suffix (<=26 chars)."
}
}
variable "esp_service_account_email" {
description = "Existing service account the ESPv2 proxy runs as. Required when create_service_account = false."
type = string
default = null
validation {
condition = var.esp_service_account_email == null || can(regex("^[^@]+@[^@]+\\.iam\\.gserviceaccount\\.com$", coalesce(var.esp_service_account_email, "x@x.iam.gserviceaccount.com")))
error_message = "esp_service_account_email must be a valid IAM service account email."
}
}
variable "grant_esp_iam" {
description = "Grant the ESPv2 SA roles/servicemanagement.serviceController and roles/cloudtrace.agent at the project level."
type = bool
default = true
}
# outputs.tf
output "service_name" {
description = "The Endpoints (managed) service name — the value ESPv2's --service flag must use."
value = local.endpoints_service.service_name
}
output "config_id" {
description = <<-EOT
The live, immutable config_id of the latest rollout (e.g. 2026-06-09r0).
Pin ESPv2's --rollout_strategy=fixed --version=<this> to serve exactly the
config Terraform deployed, instead of letting the proxy chase 'latest'.
EOT
value = local.endpoints_service.config_id
}
output "dns_address" {
description = "The DNS address of the Endpoints service (matches service_name; convenient for DNS/LB wiring)."
value = local.endpoints_service.dns_address
}
output "apis" {
description = "List of APIs (name + methods) detected in the deployed config — useful for sanity checks and docs."
value = local.endpoints_service.apis
}
output "esp_service_account_email" {
description = "Email of the service account the ESPv2 proxy runs as and reports to Service Control with."
value = local.esp_sa_email
}
output "endpoints_service_id" {
description = "Terraform resource ID of the underlying google_endpoints_service."
value = local.endpoints_service.id
}
How to use it
Render the OpenAPI document so host equals the service name and the backend address is injected, deploy it with the module, then run ESPv2 as a sidecar in your Cloud Run service pinned to the exact config_id the module rolled out:
# endpoints.yaml.tftpl (excerpt)
# swagger: "2.0"
# info: { title: orders-api, version: "1.0.0" }
# host: ${service_name}
# schemes: [ "https" ]
# produces: [ "application/json" ]
# x-google-backend: { address: ${backend_url} }
# paths:
# /orders:
# get:
# operationId: listOrders
# security: [ { api_key: [] } ]
# responses: { "200": { description: OK } }
# securityDefinitions:
# api_key: { type: apiKey, name: key, in: query }
module "cloud_endpoints" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-endpoints?ref=v1.0.0"
project_id = "kloudvin-prod"
service_name = "orders.endpoints.kloudvin-prod.cloud.goog"
openapi_config = templatefile("${path.module}/endpoints.yaml.tftpl", {
service_name = "orders.endpoints.kloudvin-prod.cloud.goog"
backend_url = google_cloud_run_v2_service.orders.uri
})
}
# Downstream: run ESPv2 as the ingress container of a Cloud Run service,
# pinning the EXACT config_id the module just rolled out (no 'latest' drift).
resource "google_cloud_run_v2_service" "esp_gateway" {
name = "orders-esp"
location = "asia-south1"
project = "kloudvin-prod"
template {
service_account = module.cloud_endpoints.esp_service_account_email
containers {
image = "gcr.io/endpoints-release/endpoints-runtime-serverless:2"
args = [
"--service=${module.cloud_endpoints.service_name}",
"--rollout_strategy=fixed",
"--version=${module.cloud_endpoints.config_id}",
]
}
}
}
Because --version is wired to module.cloud_endpoints.config_id, every OpenAPI change produces a new rollout and redeploys the proxy onto that exact config in the same terraform apply — the proxy and the deployed config can never silently disagree.
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/cloud_endpoints/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-endpoints?ref=v1.0.0"
}
inputs = {
project_id = "..."
service_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloud_endpoints && 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 Endpoints service and ESPv2 identity. |
service_name |
string |
— | Yes | Fully-qualified service name (e.g. orders.endpoints.<project>.cloud.goog); must equal the OpenAPI host. |
openapi_config |
string |
null |
Cond. | Full OpenAPI 2.0 YAML doc. Required for OpenAPI mode; leave null for gRPC. |
grpc_service_definition |
object({grpc_config, protoc_output_base64}) |
null |
Cond. | gRPC YAML config + base64 api_descriptor.pb. Required for gRPC mode. |
enable_managed_service |
bool |
true |
No | Enable the generated managed service on the project (needed for ESPv2 to serve). |
create_service_account |
bool |
true |
No | Create a dedicated ESPv2 runtime SA; if false, supply an existing one. |
service_account_prefix |
string |
"endpoints" |
No | Prefix for the generated SA ID (<prefix>-esp). |
esp_service_account_email |
string |
null |
No | Existing ESPv2 SA email; required when create_service_account = false. |
grant_esp_iam |
bool |
true |
No | Grant the ESPv2 SA servicemanagement.serviceController + cloudtrace.agent. |
Exactly one of
openapi_configorgrpc_service_definitionmust be set — supplying both, or neither, should be guarded in your calling code (and the resources are mutually exclusive viacount).
Outputs
| Name | Description |
|---|---|
service_name |
The managed service name; the value ESPv2’s --service flag uses. |
config_id |
Live immutable config ID of the latest rollout (e.g. 2026-06-09r0); pin ESPv2 to it. |
dns_address |
DNS address of the Endpoints service (matches service_name). |
apis |
List of APIs (name + methods) detected in the deployed config. |
esp_service_account_email |
Email of the SA the ESPv2 proxy runs as. |
endpoints_service_id |
Terraform resource ID of the google_endpoints_service. |
Enterprise scenario
A fintech platform exposes a partner-facing gRPC payments API from a GKE backend and must enforce per-partner API keys, mTLS-fronted JWT validation, and hard quotas auditable per consuming project. They call this module in gRPC mode from the payments service’s Terraform stack, passing the compiled api_descriptor.pb and a gRPC YAML config that declares quota metrics per partner. The module deploys the config, enables the managed service, and provisions the ESPv2 SA with servicemanagement.serviceController; the GKE Deployment then runs ESPv2 as a sidecar pinned to the module’s config_id output. Because the rollout and the sidecar’s pinned version land in the same apply, the security team gets an immutable, version-stamped record of exactly which API contract and quota policy was live at any point in time — essential for their PCI audit trail.
Best practices
- Pin ESPv2 to the module’s
config_id, don’t chaselatest. Wire--rollout_strategy=fixed --version=${module.cloud_endpoints.config_id}so the proxy and the deployed config change together in one apply;managed/latestrollout lets the proxy pick up a config your backend hasn’t been rebuilt for, causing 503s. - Keep
host/service_nameidentical and treat the OpenAPI doc as the contract. ESPv2 rejects traffic if the document’shostdoesn’t match the deployed service; render the spec withtemplatefile()and injectservice_nameso the two can never diverge. - Grant the ESPv2 SA only
servicemanagement.serviceController+cloudtrace.agent. That’s the minimum to read config and report metrics/quota — avoid project-wide editor roles so a compromised proxy can’t reach unrelated resources. Keep this identity distinct from your backend’s identity. - Drive cost with quotas and API keys, not proxy sizing. Cloud Endpoints bills per million API calls processed by Service Control (with a monthly free tier), and you separately pay to run ESPv2 (Cloud Run/GKE compute). Define
x-google-management.quotaper consumer so a chatty caller is throttled before it inflates both bills. - Don’t let
terraform destroydisable the managed service under a live proxy. Keepdisable_on_destroy = false(as the module does) so tearing down one stack doesn’tSERVICE_DISABLEDa proxy another environment still depends on. - Name the service for its environment and ownership. Use
<api>.endpoints.<project>.cloud.googper project so dev/staging/prod configs are isolated managed services with independent rollouts, quotas, and audit history — never share one service name across environments.