IaC GCP

Terraform Module: GCP Apigee — provision a managed API gateway org in one block

Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for Google Cloud Apigee: provision the org, instance, environment, and environment-group attachment with VPC peering and CMEK, all var-driven. 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 "apigee" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-apigee?ref=v1.0.0"

  project_id = "..."  # GCP project ID hosting the Apigee org (1:1).
  org_name   = "..."  # Short logical name used to derive instance/range names.
}

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

What this module is

Apigee is Google Cloud’s full-lifecycle API management platform. You deploy API proxies in front of your backend services and Apigee handles the hard cross-cutting concerns: OAuth/JWT verification, quota and spike-arrest rate limiting, request/response transformation, caching, monetization, and analytics. The unit you provision is the Apigee organization (google_apigee_organization) — a one-per-GCP-project, regionless control-plane entity that owns everything below it: runtime instances (the regional data plane that actually serves traffic), environments (deployment targets like test and prod), and environment groups (hostname routing front doors).

Standing this up by hand is deceptively painful. The org provision is a long-running operation that can take 30–45 minutes, it is not deletable without a force flag, and for the standard (non-SUBSCRIPTION/non-trial) path it requires a pre-provisioned /22 (or larger) VPC peering range and Service Networking already wired up. Getting the ordering wrong — instance before org, environment-group before environment, peering after org — produces opaque failures. Wrapping all of it in a module gives you a single, reviewed, var-driven block that encodes the correct dependency graph, sane defaults (CMEK-ready, deletion policy, billing type), and validations that fail fast in plan instead of 40 minutes into apply.

When to use it

Module structure

terraform-module-gcp-apigee/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # org, instance, environment, env-group + attachment
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # org id/name, instance host, env names

versions.tf

terraform {
  required_version = ">= 1.3.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

# Reserve a private IP range for Apigee's runtime peering (PAID/standard orgs).
# Apigee requires a non-overlapping /22 (or two /23s) inside the peered VPC.
resource "google_compute_global_address" "apigee_range" {
  count = var.create_peering_range ? 1 : 0

  name          = "${var.org_name}-apigee-range"
  project       = var.project_id
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = var.peering_prefix_length
  network       = var.authorized_network
}

# Establish the Service Networking connection the org will peer over.
resource "google_service_networking_connection" "apigee_vpc" {
  count = var.create_peering_range ? 1 : 0

  network                 = var.authorized_network
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.apigee_range[0].name]
}

# The Apigee organization: one per project, owns instances/environments.
resource "google_apigee_organization" "this" {
  project_id   = var.project_id
  display_name = var.display_name
  description  = var.description

  # Regionless control plane; analytics data is pinned to this location.
  analytics_region = var.analytics_region
  billing_type     = var.billing_type

  # For PAID orgs, attach the VPC the runtime instances peer into.
  authorized_network = var.billing_type == "EVALUATION" ? null : var.authorized_network

  # CMEK for the runtime database (omit -> Google-managed keys).
  runtime_database_encryption_key = var.runtime_database_encryption_key

  # Guard rail: refuse to destroy an org with live data unless forced.
  retention                = var.retention
  disable_vpc_peering      = var.billing_type == "EVALUATION" ? true : var.disable_vpc_peering

  depends_on = [google_service_networking_connection.apigee_vpc]
}

# Regional runtime data plane. This is what actually serves API traffic.
resource "google_apigee_instance" "this" {
  count = var.create_instance ? 1 : 0

  name     = "${var.org_name}-${var.instance_location}"
  org_id   = google_apigee_organization.this.id
  location = var.instance_location

  # CMEK for the instance disk (separate key from the org DB key).
  disk_encryption_key_name = var.disk_encryption_key_name

  # Optional: pin the runtime to a /22 inside the peered range.
  ip_range = var.instance_ip_range
}

# Deployment target(s). Proxies are deployed *into* an environment.
resource "google_apigee_environment" "this" {
  for_each = var.environments

  name         = each.key
  org_id       = google_apigee_organization.this.id
  display_name = coalesce(each.value.display_name, each.key)
  description  = each.value.description

  # ARCHIVE = lighter/cheaper; PROXY = full programmable runtime.
  deployment_type = each.value.deployment_type
  api_proxy_type  = each.value.api_proxy_type
}

# Attach environments to the regional instance so they can serve traffic.
resource "google_apigee_instance_attachment" "this" {
  for_each = var.create_instance ? var.environments : {}

  instance_id = google_apigee_instance.this[0].id
  environment = google_apigee_environment.this[each.key].name
}

# Hostname front door: routes inbound hosts to one or more environments.
resource "google_apigee_envgroup" "this" {
  for_each = var.environment_groups

  name      = each.key
  org_id    = google_apigee_organization.this.id
  hostnames = each.value.hostnames
}

# Bind environments into the env-group that fronts them.
resource "google_apigee_envgroup_attachment" "this" {
  for_each = local.envgroup_attachments

  envgroup_id = google_apigee_envgroup.this[each.value.group].id
  environment = google_apigee_environment.this[each.value.environment].name
}

locals {
  # Flatten {group => [env, env]} into discrete attachment keys.
  envgroup_attachments = {
    for pair in flatten([
      for group_name, group in var.environment_groups : [
        for env_name in group.environments : {
          key         = "${group_name}/${env_name}"
          group       = group_name
          environment = env_name
        }
      ]
    ]) : pair.key => pair
  }
}

variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID that will host the Apigee organization (1:1)."
}

variable "org_name" {
  type        = string
  description = "Short logical name used to derive instance/range names (e.g. 'kv-platform')."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,28}[a-z0-9]$", var.org_name))
    error_message = "org_name must be lowercase alphanumeric/hyphens, 3-30 chars, and start with a letter."
  }
}

variable "display_name" {
  type        = string
  description = "Human-friendly display name for the Apigee organization."
  default     = null
}

variable "description" {
  type        = string
  description = "Description of the Apigee organization."
  default     = "Managed by Terraform"
}

variable "analytics_region" {
  type        = string
  description = "Region where analytics data is stored (e.g. 'us-central1'). Immutable after create."
  default     = "us-central1"
}

variable "billing_type" {
  type        = string
  description = "Apigee billing model: EVALUATION (trial), PAYG, or SUBSCRIPTION."
  default     = "PAYG"

  validation {
    condition     = contains(["EVALUATION", "PAYG", "SUBSCRIPTION"], var.billing_type)
    error_message = "billing_type must be one of EVALUATION, PAYG, or SUBSCRIPTION."
  }
}

variable "authorized_network" {
  type        = string
  description = "Self-link or name of the VPC the runtime peers into. Required for non-EVALUATION orgs."
  default     = null
}

variable "create_peering_range" {
  type        = bool
  description = "If true, create the global address + Service Networking connection for peering."
  default     = true
}

variable "peering_prefix_length" {
  type        = number
  description = "Prefix length for the reserved Apigee peering range. Apigee needs /22 or smaller-number."

  default = 22

  validation {
    condition     = var.peering_prefix_length <= 22
    error_message = "Apigee requires a /22 or larger block (prefix_length <= 22)."
  }
}

variable "runtime_database_encryption_key" {
  type        = string
  description = "CMEK key (KMS resource ID) for the runtime database. Null -> Google-managed keys."
  default     = null
}

variable "retention" {
  type        = string
  description = "Org deletion retention: DELETION_RETENTION_UNSPECIFIED or MINIMUM (24h soft-delete window)."
  default     = "MINIMUM"

  validation {
    condition     = contains(["DELETION_RETENTION_UNSPECIFIED", "MINIMUM"], var.retention)
    error_message = "retention must be DELETION_RETENTION_UNSPECIFIED or MINIMUM."
  }
}

variable "disable_vpc_peering" {
  type        = bool
  description = "Disable VPC peering (non-peered/SUBSCRIPTION orgs). Forced true for EVALUATION."
  default     = false
}

variable "create_instance" {
  type        = bool
  description = "Whether to create the regional runtime instance and attach environments to it."
  default     = true
}

variable "instance_location" {
  type        = string
  description = "Region/zone for the runtime instance (e.g. 'us-central1')."
  default     = "us-central1"
}

variable "instance_ip_range" {
  type        = string
  description = "Optional CIDR (/22 + /28) inside the peered range to pin the instance to."
  default     = null
}

variable "disk_encryption_key_name" {
  type        = string
  description = "CMEK key (KMS resource ID) for the instance disk. Null -> Google-managed keys."
  default     = null
}

variable "environments" {
  type = map(object({
    display_name    = optional(string)
    description     = optional(string, "Managed by Terraform")
    deployment_type = optional(string, "PROXY")
    api_proxy_type  = optional(string, "PROGRAMMABLE")
  }))
  description = "Map of environment name => settings. Keys become the environment names."
  default     = {}

  validation {
    condition = alltrue([
      for e in values(var.environments) :
      contains(["PROXY", "ARCHIVE"], e.deployment_type)
    ])
    error_message = "Each environment deployment_type must be PROXY or ARCHIVE."
  }
}

variable "environment_groups" {
  type = map(object({
    hostnames    = list(string)
    environments = optional(list(string), [])
  }))
  description = "Map of env-group name => { hostnames, environments to attach }."
  default     = {}

  validation {
    condition = alltrue([
      for g in values(var.environment_groups) : length(g.hostnames) > 0
    ])
    error_message = "Each environment group must declare at least one hostname."
  }
}

outputs.tf

output "org_id" {
  description = "Fully-qualified Apigee organization ID (organizations/<project_id>)."
  value       = google_apigee_organization.this.id
}

output "org_name" {
  description = "The Apigee organization name (equal to the project ID)."
  value       = google_apigee_organization.this.name
}

output "ca_certificate" {
  description = "Base64 CA certificate of the org's runtime, for mTLS to the data plane."
  value       = google_apigee_organization.this.ca_certificate
  sensitive   = true
}

output "subscription_type" {
  description = "Resolved subscription type of the organization."
  value       = google_apigee_organization.this.subscription_type
}

output "instance_id" {
  description = "ID of the regional runtime instance (null if create_instance = false)."
  value       = try(google_apigee_instance.this[0].id, null)
}

output "instance_host" {
  description = "Internal host (IP) the instance serves on, for the internal load balancer NEG."
  value       = try(google_apigee_instance.this[0].host, null)
}

output "instance_service_attachment" {
  description = "PSC service attachment of the instance, used to wire an external L7 load balancer."
  value       = try(google_apigee_instance.this[0].service_attachment, null)
}

output "environment_names" {
  description = "List of environment names created by the module."
  value       = keys(google_apigee_environment.this)
}

output "envgroup_hostnames" {
  description = "Map of env-group name => hostnames it fronts."
  value       = { for k, g in google_apigee_envgroup.this : k => g.hostnames }
}

How to use it

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

  project_id       = "kv-apigee-prod"
  org_name         = "kv-platform"
  display_name     = "KloudVin Platform APIs"
  analytics_region = "us-central1"
  billing_type     = "PAYG"

  # Peer the runtime into the platform Shared VPC.
  authorized_network   = "projects/kv-host/global/networks/platform-vpc"
  create_peering_range = true

  # CMEK on both the runtime DB and the instance disk.
  runtime_database_encryption_key = "projects/kv-host/locations/us-central1/keyRings/apigee/cryptoKeys/runtime-db"
  disk_encryption_key_name        = "projects/kv-host/locations/us-central1/keyRings/apigee/cryptoKeys/instance-disk"

  create_instance   = true
  instance_location = "us-central1"

  environments = {
    test = { description = "Pre-prod proxy testing" }
    prod = { description = "Production traffic" }
  }

  environment_groups = {
    external = {
      hostnames    = ["api.kloudvin.com"]
      environments = ["prod"]
    }
    internal = {
      hostnames    = ["api-test.internal.kloudvin.com"]
      environments = ["test"]
    }
  }
}

# Downstream: build the PSC NEG that fronts Apigee with an external L7 LB,
# using the instance's service attachment exported by the module.
resource "google_compute_region_network_endpoint_group" "apigee_psc" {
  name                  = "apigee-psc-neg"
  project               = "kv-apigee-prod"
  region                = "us-central1"
  network_endpoint_type = "PRIVATE_SERVICE_CONNECT"
  psc_target_service    = module.apigee.instance_service_attachment
  network               = "projects/kv-host/global/networks/platform-vpc"
  subnetwork            = "projects/kv-host/regions/us-central1/subnetworks/apigee-psc"
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  org_name = "..."
}

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

cd live/prod/apigee && 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 hosting the Apigee org (1:1).
org_name string Yes Short logical name used to derive instance/range names.
display_name string null No Display name for the organization.
description string “Managed by Terraform” No Org description.
analytics_region string “us-central1” No Region for analytics data (immutable).
billing_type string “PAYG” No EVALUATION, PAYG, or SUBSCRIPTION.
authorized_network string null No VPC the runtime peers into; required for non-EVALUATION.
create_peering_range bool true No Create the global address + Service Networking connection.
peering_prefix_length number 22 No Prefix length for the reserved peering range (must be ≤ 22).
runtime_database_encryption_key string null No CMEK key for the runtime database.
retention string “MINIMUM” No Deletion retention window (MINIMUM = 24h soft delete).
disable_vpc_peering bool false No Disable peering (SUBSCRIPTION/non-peered); forced true for EVALUATION.
create_instance bool true No Create the regional runtime instance and attach environments.
instance_location string “us-central1” No Region for the runtime instance.
instance_ip_range string null No Optional CIDR inside the peered range to pin the instance.
disk_encryption_key_name string null No CMEK key for the instance disk.
environments map(object) {} No Environment name => settings (deployment_type, api_proxy_type, etc).
environment_groups map(object) {} No Env-group name => { hostnames, environments to attach }.

Outputs

Name Description
org_id Fully-qualified org ID (organizations/<project_id>).
org_name The organization name (equals the project ID).
ca_certificate Base64 CA cert of the runtime, for mTLS to the data plane (sensitive).
subscription_type Resolved subscription type of the organization.
instance_id ID of the regional runtime instance (null if not created).
instance_host Internal host/IP the instance serves on.
instance_service_attachment PSC service attachment for wiring an external L7 load balancer.
environment_names List of environment names created.
envgroup_hostnames Map of env-group name => hostnames it fronts.

Enterprise scenario

A fintech platform team consolidates a dozen partner-facing REST APIs behind a single Apigee prod environment fronted by the external env-group on api.partners.bank.example. They stamp this module out across three projects (apigee-dev, apigee-stg, apigee-prod) from the same code, each peered into its environment’s Shared VPC and locked to org-owned CMEK keys so the runtime database and disk satisfy the bank’s “no Google-managed keys” control. The exported instance_service_attachment is consumed by a separate networking stack that terminates TLS at an external HTTPS load balancer with Cloud Armor, so the API team never touches load-balancer plumbing — they only deploy proxies into the environment the module created.

Best practices

TerraformGCPApigeeModuleIaC
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