IaC GCP

Terraform Module: GCP Service Directory — One Registry for Services, Endpoints, and Access

Quick take — A production Terraform module for GCP Service Directory using google_service_directory_namespace — register a namespace, its services and endpoints, and per-namespace IAM from a single var-driven interface. 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 "service_directory" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-service-directory?ref=v1.0.0"

  project_id   = "..."  # GCP project ID that owns the namespace.
  location     = "..."  # Region for the namespace (Service Directory is regional…
  namespace_id = "..."  # Namespace resource ID; 1-63 chars, lowercase letters/di…
}

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

What this module is

Service Directory is Google Cloud’s managed service registry — a single, regional place to publish where your services live and how to reach them, queryable over both its own API and (optionally) DNS. The hierarchy is three levels deep: a namespace (google_service_directory_namespace) is the regional container; inside it you register services (google_service_directory_service), a logical name like payments-api; and each service holds one or more endpoints (google_service_directory_endpoint), the concrete address:port pairs (plus optional metadata and a VPC network) that clients resolve. It is not a load balancer and it doesn’t proxy traffic — it’s the authoritative catalogue that tells callers, across VMs, GKE, on-prem, and hybrid, that payments-api lives at 10.20.0.14:8443 on the prod-vpc network.

Clicking namespaces, services, and endpoints into the console works for a demo, but it falls apart once you have the same service registered per-environment, endpoints that move with every deploy, metadata that must stay consistent, and IAM that controls who may read the registry versus who may register into it. Wrapping the trio in a module gives you one opinionated interface that:

The result: every service registration in your org is created the same way, endpoints ship with the deploy that owns them, and namespace-level access is reviewed as code.

When to use it

Reach for this module when:

Skip it (or extend it) if you only need plain DNS records (that’s Cloud DNS and google_dns_managed_zone), or if you want the DNS-integration view of Service Directory via a Cloud DNS service-directory zone — that is configured on the google_dns_managed_zone side and is intentionally out of scope here. This module owns the registry itself (namespace → services → endpoints + IAM); the structure below makes bolting a DNS zone on top straightforward.

Module structure

terraform-module-gcp-service-directory/
├── versions.tf       # provider + Terraform version pins
├── main.tf           # namespace + services + endpoints + namespace IAM
├── variables.tf      # var-driven inputs with validations
└── outputs.tf        # namespace id/name + service & endpoint maps

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Flatten the nested services -> endpoints map into a single keyed map
  # so each endpoint becomes one google_service_directory_endpoint instance.
  # Key shape: "<service_key>/<endpoint_key>".
  endpoints = merge([
    for svc_key, svc in var.services : {
      for ep_key, ep in svc.endpoints :
      "${svc_key}/${ep_key}" => {
        service_key = svc_key
        endpoint_id = ep_key
        address     = ep.address
        port        = ep.port
        network     = ep.network
        metadata    = ep.metadata
      }
    }
  ]...)
}

resource "google_service_directory_namespace" "this" {
  provider     = google
  namespace_id = var.namespace_id
  location     = var.location
  project      = var.project_id

  labels = var.labels

  # PREVENT keeps `terraform destroy` from wiping a live registry.
  deletion_policy = var.deletion_policy
}

resource "google_service_directory_service" "this" {
  for_each = var.services

  provider   = google
  service_id = each.key
  namespace  = google_service_directory_namespace.this.id

  metadata = each.value.metadata

  deletion_policy = var.deletion_policy
}

resource "google_service_directory_endpoint" "this" {
  for_each = local.endpoints

  provider    = google
  endpoint_id = each.value.endpoint_id
  service     = google_service_directory_service.this[each.value.service_key].id

  address  = each.value.address
  port     = each.value.port
  network  = each.value.network
  metadata = each.value.metadata

  deletion_policy = var.deletion_policy
}

# Optional per-namespace IAM (reader/editor/viewer roles on the registry).
resource "google_service_directory_namespace_iam_member" "this" {
  for_each = {
    for binding in var.iam_members :
    "${binding.role}::${binding.member}" => binding
  }

  provider = google
  name     = google_service_directory_namespace.this.id
  role     = each.value.role
  member   = each.value.member
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns the Service Directory namespace."
  type        = string
}

variable "location" {
  description = "Region for the namespace (e.g. \"us-central1\", \"europe-west1\"). Service Directory is regional."
  type        = string
}

variable "namespace_id" {
  description = "Resource ID of the namespace: 1-63 chars, lowercase letters, digits, and hyphens."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{1,63}$", var.namespace_id))
    error_message = "namespace_id must be 1-63 characters of lowercase letters, digits, or hyphens."
  }
}

variable "labels" {
  description = "Resource labels applied to the namespace (max 64 labels)."
  type        = map(string)
  default     = {}

  validation {
    condition     = length(var.labels) <= 64
    error_message = "Service Directory allows at most 64 user labels on a namespace."
  }
}

variable "deletion_policy" {
  description = "Destroy behavior for namespace/services/endpoints: DELETE, ABANDON, or PREVENT."
  type        = string
  default     = "DELETE"

  validation {
    condition     = contains(["DELETE", "ABANDON", "PREVENT"], var.deletion_policy)
    error_message = "deletion_policy must be one of DELETE, ABANDON, or PREVENT."
  }
}

variable "services" {
  description = <<-EOT
    Map of services to register in the namespace. The map key is the service_id
    (1-63 chars, lowercase letters/digits/hyphens). Each service carries optional
    metadata and a map of endpoints keyed by endpoint_id.

    Example:
      {
        payments-api = {
          metadata = { team = "fintech", protocol = "grpc" }
          endpoints = {
            primary = { address = "10.20.0.14", port = 8443, network = null, metadata = { az = "a" } }
            standby = { address = "10.20.1.14", port = 8443, network = null, metadata = { az = "b" } }
          }
        }
      }
  EOT
  type = map(object({
    metadata = optional(map(string), {})
    endpoints = optional(map(object({
      address  = optional(string)
      port     = optional(number, 0)
      network  = optional(string)
      metadata = optional(map(string), {})
    })), {})
  }))
  default = {}

  validation {
    condition     = alltrue([for k in keys(var.services) : can(regex("^[a-z0-9-]{1,63}$", k))])
    error_message = "Each service_id (map key) must be 1-63 chars of lowercase letters, digits, or hyphens."
  }

  validation {
    condition = alltrue([
      for svc in values(var.services) : alltrue([
        for ep_key in keys(svc.endpoints) : can(regex("^[a-z0-9-]{1,63}$", ep_key))
      ])
    ])
    error_message = "Each endpoint_id (nested map key) must be 1-63 chars of lowercase letters, digits, or hyphens."
  }

  validation {
    condition = alltrue([
      for svc in values(var.services) : alltrue([
        for ep in values(svc.endpoints) : ep.port >= 0 && ep.port <= 65535
      ])
    ])
    error_message = "Each endpoint port must be in the range [0, 65535]."
  }
}

variable "iam_members" {
  description = <<-EOT
    Optional per-namespace IAM bindings. Each entry grants one role to one member.
    Common roles: roles/servicedirectory.viewer (read the registry),
    roles/servicedirectory.editor (register services/endpoints).

    Example:
      [
        { role = "roles/servicedirectory.viewer", member = "group:platform-readers@kloudvin.com" },
        { role = "roles/servicedirectory.editor", member = "serviceAccount:deployer@kloudvin-prod.iam.gserviceaccount.com" },
      ]
  EOT
  type = list(object({
    role   = string
    member = string
  }))
  default = []

  validation {
    condition     = alltrue([for b in var.iam_members : can(regex("^roles/", b.role)) || can(regex("^projects/", b.role))])
    error_message = "Each IAM role must be a predefined role (roles/...) or a full custom-role path (projects/...)."
  }
}

outputs.tf

output "id" {
  description = "Namespace id, format projects/{project}/locations/{location}/namespaces/{namespace_id} — use as the parent for services."
  value       = google_service_directory_namespace.this.id
}

output "name" {
  description = "Full resource name of the namespace (same value as id for this resource)."
  value       = google_service_directory_namespace.this.name
}

output "namespace_id" {
  description = "Short namespace_id as supplied."
  value       = google_service_directory_namespace.this.namespace_id
}

output "service_ids" {
  description = "Map of service_id => full service resource name (projects/*/locations/*/namespaces/*/services/*)."
  value       = { for k, s in google_service_directory_service.this : k => s.id }
}

output "endpoint_ids" {
  description = "Map of \"<service_id>/<endpoint_id>\" => full endpoint resource name."
  value       = { for k, e in google_service_directory_endpoint.this : k => e.id }
}

output "endpoint_addresses" {
  description = "Map of \"<service_id>/<endpoint_id>\" => \"address:port\" for quick wiring of clients."
  value = {
    for k, e in google_service_directory_endpoint.this :
    k => "${e.address}:${e.port}"
  }
}

How to use it

Register a payments-api service with a primary and standby endpoint on the prod VPC, plus reader/editor IAM, then consume an output downstream:

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

  project_id   = "kloudvin-prod-platform"
  location     = "us-central1"
  namespace_id = "prod-services"

  deletion_policy = "PREVENT" # do not let destroy wipe the live registry

  services = {
    payments-api = {
      metadata = {
        team     = "fintech"
        protocol = "grpc"
        version  = "v2"
      }
      endpoints = {
        primary = {
          address  = "10.20.0.14"
          port     = 8443
          network  = "projects/482910335577/locations/global/networks/prod-vpc"
          metadata = { az = "us-central1-a", weight = "100" }
        }
        standby = {
          address  = "10.20.1.14"
          port     = 8443
          network  = "projects/482910335577/locations/global/networks/prod-vpc"
          metadata = { az = "us-central1-b", weight = "0" }
        }
      }
    }

    orders-api = {
      metadata = { team = "commerce", protocol = "http" }
      endpoints = {
        primary = {
          address = "10.20.2.30"
          port    = 8080
          network = "projects/482910335577/locations/global/networks/prod-vpc"
        }
      }
    }
  }

  iam_members = [
    { role = "roles/servicedirectory.viewer", member = "group:platform-readers@kloudvin.com" },
    { role = "roles/servicedirectory.editor", member = "serviceAccount:deployer@kloudvin-prod.iam.gserviceaccount.com" },
  ]

  labels = {
    env  = "prod"
    team = "platform"
  }
}

# Downstream: feed the registered service name into a Cloud DNS
# service-directory zone so clients can resolve payments-api over DNS too.
resource "google_dns_managed_zone" "sd_zone" {
  project    = "kloudvin-prod-platform"
  name       = "prod-services-sd"
  dns_name   = "prod.svc.kloudvin.internal."
  visibility = "private"

  service_directory_config {
    namespace {
      namespace_url = module.service_directory.id
    }
  }

  private_visibility_config {
    networks {
      network_url = "https://www.googleapis.com/compute/v1/projects/kloudvin-prod-platform/global/networks/prod-vpc"
    }
  }
}

# Downstream: surface the primary payments endpoint as an "address:port"
# string for a config-map / app deployment to consume.
output "payments_primary_endpoint" {
  description = "address:port of the primary payments-api endpoint."
  value       = module.service_directory.endpoint_addresses["payments-api/primary"]
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  location = "..."
  namespace_id = "..."
}

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

cd live/prod/service_directory && 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 that owns the namespace.
location string Yes Region for the namespace (Service Directory is regional), e.g. us-central1.
namespace_id string Yes Namespace resource ID; 1-63 chars, lowercase letters/digits/hyphens.
labels map(string) {} No Labels on the namespace (max 64).
deletion_policy string "DELETE" No Destroy behavior for namespace/services/endpoints: DELETE, ABANDON, or PREVENT.
services map(object({metadata, endpoints})) {} No Services to register, keyed by service_id; each holds metadata and a nested map of endpoints (address, port, network, metadata).
iam_members list(object({role, member})) [] No Per-namespace IAM bindings, one role + member each (e.g. roles/servicedirectory.viewer).

Outputs

Name Description
id Namespace id (projects/{project}/locations/{location}/namespaces/{namespace_id}); use as the parent for services.
name Full resource name of the namespace.
namespace_id Short namespace_id as supplied.
service_ids Map of service_id => full service resource name.
endpoint_ids Map of "<service_id>/<endpoint_id>" => full endpoint resource name.
endpoint_addresses Map of "<service_id>/<endpoint_id>" => "address:port" for quick client wiring.

Enterprise scenario

KloudVin runs a hybrid estate where GKE workloads, Compute Engine VMs, and an on-prem datacentre all need to reach the same internal payments-api and orders-api. The platform team instantiates this module once per environment from the same root, with deletion_policy = "PREVENT" in prod, registering each service with its real backend endpoints (primary + standby, tagged with metadata such as az and weight) attached to the prod-vpc network. They grant roles/servicedirectory.editor to the deploy service account so each release can update endpoints, and roles/servicedirectory.viewer to the platform-readers group; a Cloud DNS service-directory zone then mirrors the namespace so legacy clients resolve payments-api.prod.svc.kloudvin.internal over DNS while modern clients query the Service Directory API directly — one source of truth, two access paths, all in code.

Best practices

TerraformGCPService DirectoryModuleIaC
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