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:
- Creates a regional namespace with labels and a safe
deletion_policy. - Registers an arbitrary map of services, each with its own metadata.
- Registers the endpoints under each service —
address,port, optional VPCnetwork, and metadata — from a single nested map. - Optionally grants per-namespace IAM (
google_service_directory_namespace_iam_member) so reader vs. editor access is codified, not hand-bound in the console.
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:
- You run service discovery without (or alongside) DNS and want a single managed registry that VMs, GKE workloads, and on-prem clients can all query through one API.
- You operate hybrid or multi-environment topologies and need the same logical service (
orders-api,payments-api) registered consistently indev/stg/prod, each pointing at different endpoints. - You want endpoint registration to be part of the deploy — a new backend IP is a map entry in a pull request, not a console click that drifts from reality.
- You need to lock down who can read versus register into a namespace with IAM that lives next to the resource.
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 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/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
- Set
deletion_policy = "PREVENT"on production namespaces. A registry that other services resolve against is critical infrastructure; preventing destroy stops an errantterraform destroyfrom deleting the namespace (and orphaning every dependent caller) while you keepDELETEfor throwaway ephemeral environments. - Split read from register with the two predefined roles. Grant
roles/servicedirectory.viewerbroadly to anything that needs to resolve services, and reserveroles/servicedirectory.editorfor the deploy identity that registers endpoints. Binding these at the namespace level viaiam_memberskeeps the blast radius of editor access to a single namespace, not the whole project. - Let the owning deploy manage its endpoints, and tag them with metadata. Endpoints move every release, so treat
address/portas deploy-time inputs and usemetadata(az,weight,version,protocol) to carry the routing hints clients need — Service Directory stores the catalogue, your clients do the selection. - Always set the
networkfield for VPC-internal endpoints. Pass the fullprojects/PROJECT_NUMBER/locations/global/networks/NETWORK_NAMEform so the endpoint is scoped to the correct VPC; omitting it leaves the endpoint network-unqualified and breaks the DNS-integration view that filters by network. - Name namespaces and services for the discovery they serve, label for ops. Use stable, environment-prefixed
namespace_ids (prod-services,stg-services) and protocol-agnosticservice_ids (payments-api, notpayments-grpc-8443), then drive cost and ownership reporting fromlabels(env,team,cost-center). - Mind the metadata limits. Endpoint metadata is capped at 512 characters total and service metadata around 2000 — keep values to routing-relevant keys rather than dumping config, so registrations stay within bounds and the registry stays a directory, not a database.