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
- You are rolling out API management across a platform team and want every product team’s Apigee footprint to look identical and be reviewable in a PR.
- You need a repeatable non-prod / prod split — separate projects or separate environments under one org, created from the same code.
- You want CMEK encryption on the runtime database and disk by policy, and peering/IP ranges that don’t collide with the rest of your VPC.
- You are standing up Apigee in a landing zone where networking (Shared VPC, Service Networking connection) is managed elsewhere and you just need to consume an existing peering range.
- Skip it (or use a much smaller variant) if you only need Apigee trial/eval —
billing_type = "EVALUATION"doesn’t need the peering plumbing and the heavy parts of this module are wasted.
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 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/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
- Treat the org as undeletable. A PAYG/SUBSCRIPTION org takes 30–45 min to provision and won’t
destroycleanly. Keepretention = "MINIMUM"for the 24h soft-delete safety net, and never put the org in the same state as fast-churning resources — isolate it in its own state/workspace. - Size and reserve peering deliberately. Apigee needs a non-overlapping
/22(the module enforcesprefix_length <= 22). Carve it from an IPAM-managed block and document it; an overlap here surfaces only deep into theapply, not atplan. - Pin CMEK on both the DB and the disk.
runtime_database_encryption_keyanddisk_encryption_key_nameare separate keys and are immutable after create — set them on day one. Grant the Apigee service agentroles/cloudkms.cryptoKeyEncrypterDecrypteron each key first or the org create fails. - Right-size billing and environments for cost. Apigee’s runtime instance is the expensive part; use one instance with multiple environments attached rather than an instance per environment, and use
deployment_type = "ARCHIVE"for lightweight environments that don’t need the full programmable runtime. - Name for routing clarity. Keep env-group names aligned to exposure (
externalvsinternal) and put real, owned hostnames inhostnames— the env-group is the routing key, so a typo silently sends traffic nowhere. Derive instance/range names fromorg_nameso every artifact is greppable back to its org. - Front it with PSC + an external LB, not the raw instance. Export
instance_service_attachmentand terminate TLS at a Google Cloud external HTTPS load balancer with Cloud Armor in front; never expose the runtime instance host directly.