IaC GCP

Terraform Module: GCP Cloud Endpoints — version-controlled ESPv2 service configs with managed rollouts

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

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 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/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_config or grpc_service_definition must be set — supplying both, or neither, should be guarded in your calling code (and the resources are mutually exclusive via count).

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

TerraformGCPCloud EndpointsModuleIaC
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