IaC GCP

Terraform Module: GCP Private Service Connect — publish a private producer endpoint in one block

Quick take — A reusable Terraform module for GCP Private Service Connect that wraps google_compute_service_attachment to publish an internal load balancer as a PSC service, with NAT subnets, consumer accept-lists, and connection limits. 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 "private_service_connect" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-private-service-connect?ref=v1.0.0"

  project_id       = "..."           # Producer project that owns the service attachment and N…
  name             = "..."           # Base name for the attachment and derived PSC NAT subnet…
  region           = "..."           # Region for the attachment; must match the producer inte…
  network          = "..."           # Self-link or name of the VPC hosting the PSC NAT subnet…
  target_service   = "..."           # Self-link of the producer forwarding rule (internal LB)…
  nat_subnet_cidrs = ["...", "..."]  # CIDRs for the dedicated `PRIVATE_SERVICE_CONNECT` NAT s…
}

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

What this module is

Private Service Connect (PSC) lets a producer expose a service privately so consumers in other VPCs or projects reach it over Google’s internal network — no VPC peering, no public IPs, no overlapping-CIDR headaches. The producer side of that contract is a service attachment: it points at the forwarding rule of an internal load balancer, carves out one or more NAT subnets that PSC uses to source-NAT consumer traffic, and decides who is allowed to connect and how many connections each project may open.

In Terraform that producer side is google_compute_service_attachment, and getting it right by hand is fiddly: you must pre-create dedicated PRIVATE_SERVICE_CONNECT-purpose subnets, wire them into nat_subnets, choose between ACCEPT_AUTOMATIC and ACCEPT_MANUAL connection preferences, and — if manual — maintain a per-project accept-list with connection limits. This module wraps all of that behind a handful of variables. You hand it a forwarding rule self-link and a list of NAT subnet CIDRs; it creates the PSC subnets, the service attachment, optional DNS auto-registration via domain_names, and emits the psc_service_attachment_id that consumers feed into their google_compute_forwarding_rule (target = service attachment) to connect.

The result is a service-publishing primitive you can drop into any producer project and version like the rest of your platform.

When to use it

Module structure

terraform-module-gcp-private-service-connect/
├── versions.tf      # provider + version pins
├── main.tf          # PSC NAT subnets + service attachment
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # attachment id/name + connection details

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # PSC requires dedicated subnets with purpose = PRIVATE_SERVICE_CONNECT.
  # Index them so names stay stable across plans even if order is preserved.
  nat_subnets = {
    for idx, cidr in var.nat_subnet_cidrs :
    tostring(idx) => cidr
  }
}

# Dedicated NAT subnets PSC uses to source-NAT incoming consumer connections.
# These must NOT be used for any other workload.
resource "google_compute_subnetwork" "psc_nat" {
  for_each = local.nat_subnets

  project       = var.project_id
  name          = "${var.name}-psc-nat-${each.key}"
  region        = var.region
  network       = var.network
  ip_cidr_range = each.value
  purpose       = "PRIVATE_SERVICE_CONNECT"
  # role/secondary ranges are intentionally omitted: invalid for PSC subnets.
}

# The producer-side Private Service Connect publication.
resource "google_compute_service_attachment" "this" {
  project     = var.project_id
  name        = var.name
  region      = var.region
  description = var.description

  # Self-link of the producer internal LB forwarding rule being published.
  target_service = var.target_service

  enable_proxy_protocol = var.enable_proxy_protocol

  connection_preference = var.connection_preference

  nat_subnets = [for s in google_compute_subnetwork.psc_nat : s.id]

  # Optional: auto-register consumer DNS under this domain (must end with a dot).
  domain_names = var.domain_names

  # When connection_preference = ACCEPT_MANUAL, declare the allow-list.
  dynamic "consumer_accept_lists" {
    for_each = var.connection_preference == "ACCEPT_MANUAL" ? var.consumer_accept_lists : []
    content {
      project_id_or_num = consumer_accept_lists.value.project_id_or_num
      connection_limit  = consumer_accept_lists.value.connection_limit
    }
  }

  # Projects explicitly denied, regardless of accept-list (manual mode only).
  reconcile_connections = var.reconcile_connections
}

variables.tf

variable "project_id" {
  description = "Producer project ID that owns the service attachment and NAT subnets."
  type        = string
}

variable "name" {
  description = "Base name for the service attachment and derived PSC NAT subnets."
  type        = string

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.name))
    error_message = "name must be a valid GCP resource name: lowercase letters, digits, hyphens; start with a letter."
  }
}

variable "region" {
  description = "Region for the service attachment. Must match the producer internal LB region."
  type        = string
}

variable "network" {
  description = "Self-link or name of the VPC network that hosts the PSC NAT subnets."
  type        = string
}

variable "target_service" {
  description = "Self-link of the producer forwarding rule (internal LB) to publish via PSC."
  type        = string

  validation {
    condition     = can(regex("forwardingRules/", var.target_service))
    error_message = "target_service must be a forwarding rule self-link (contains 'forwardingRules/')."
  }
}

variable "nat_subnet_cidrs" {
  description = "CIDR ranges for the dedicated PURPOSE=PRIVATE_SERVICE_CONNECT NAT subnets. Size for peak concurrent consumer connections."
  type        = list(string)

  validation {
    condition     = length(var.nat_subnet_cidrs) > 0
    error_message = "At least one NAT subnet CIDR is required for a service attachment."
  }

  validation {
    condition     = alltrue([for c in var.nat_subnet_cidrs : can(cidrhost(c, 0))])
    error_message = "Every entry in nat_subnet_cidrs must be a valid CIDR (e.g. 10.10.0.0/24)."
  }
}

variable "connection_preference" {
  description = "How consumers connect: ACCEPT_AUTOMATIC (open) or ACCEPT_MANUAL (allow-listed)."
  type        = string
  default     = "ACCEPT_MANUAL"

  validation {
    condition     = contains(["ACCEPT_AUTOMATIC", "ACCEPT_MANUAL"], var.connection_preference)
    error_message = "connection_preference must be ACCEPT_AUTOMATIC or ACCEPT_MANUAL."
  }
}

variable "consumer_accept_lists" {
  description = "Allow-listed consumer projects (used only when connection_preference = ACCEPT_MANUAL)."
  type = list(object({
    project_id_or_num = string
    connection_limit  = number
  }))
  default = []

  validation {
    condition     = alltrue([for a in var.consumer_accept_lists : a.connection_limit >= 1])
    error_message = "Each consumer accept-list entry must allow at least one connection."
  }
}

variable "domain_names" {
  description = "DNS domains for PSC auto-DNS registration. Each must be fully qualified and end with a trailing dot."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for d in var.domain_names : endswith(d, ".")])
    error_message = "Every domain_names entry must end with a trailing dot (e.g. payments.internal.example.com.)."
  }
}

variable "enable_proxy_protocol" {
  description = "Prepend PROXY protocol header so the producer LB sees the consumer PSC source. Requires LB support."
  type        = bool
  default     = false
}

variable "reconcile_connections" {
  description = "Whether PSC reconciles existing consumer connections against accept/reject lists when they change."
  type        = bool
  default     = true
}

variable "description" {
  description = "Free-text description applied to the service attachment."
  type        = string
  default     = "Managed by Terraform — Private Service Connect producer publication."
}

outputs.tf

output "psc_service_attachment_id" {
  description = "Self-link/ID of the service attachment. Consumers target this from their PSC forwarding rule."
  value       = google_compute_service_attachment.this.id
}

output "psc_service_attachment_name" {
  description = "Name of the service attachment."
  value       = google_compute_service_attachment.this.name
}

output "connection_preference" {
  description = "Effective connection preference (ACCEPT_AUTOMATIC or ACCEPT_MANUAL)."
  value       = google_compute_service_attachment.this.connection_preference
}

output "nat_subnet_ids" {
  description = "Self-links of the dedicated PSC NAT subnets created for this attachment."
  value       = [for s in google_compute_subnetwork.psc_nat : s.id]
}

output "connected_endpoints" {
  description = "Live consumer connections (consumer PSC IP, forwarding rule, and status) as reported by GCP."
  value       = google_compute_service_attachment.this.connected_endpoints
}

output "domain_names" {
  description = "DNS domains registered for PSC auto-DNS, if any."
  value       = google_compute_service_attachment.this.domain_names
}

How to use it

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

  project_id = "kv-payments-prod"
  name       = "payments-api-psc"
  region     = "asia-south1"
  network    = "projects/kv-payments-prod/global/networks/payments-vpc"

  # Self-link of the producer internal LB forwarding rule (created elsewhere).
  target_service = google_compute_forwarding_rule.payments_ilb.self_link

  # Dedicated PSC NAT range — sized for ~250 concurrent consumer connections.
  nat_subnet_cidrs = ["10.40.250.0/24"]

  connection_preference = "ACCEPT_MANUAL"
  consumer_accept_lists = [
    {
      project_id_or_num = "kv-checkout-prod"
      connection_limit  = 10
    },
    {
      project_id_or_num = "kv-partner-acme"
      connection_limit  = 2
    },
  ]

  domain_names = ["payments.internal.kloudvin.com."]
}

# Downstream: a monitoring/alerting stack consumes the attachment id to watch
# producer-side PSC connection health.
resource "google_monitoring_alert_policy" "psc_attachment" {
  project      = "kv-payments-prod"
  display_name = "PSC attachment ${module.private_service_connect.psc_service_attachment_name} unhealthy"
  combiner     = "OR"

  conditions {
    display_name = "Service attachment has rejected/pending connections"
    condition_matched_log {
      filter = <<-EOT
        resource.type="gce_psc_service_attachment"
        resource.labels.service_attachment_id="${module.private_service_connect.psc_service_attachment_id}"
        severity>=WARNING
      EOT
    }
  }

  notification_channels = [var.ops_pagerduty_channel]
}

A consumer in a different project then attaches by pointing a forwarding rule at the same psc_service_attachment_id:

resource "google_compute_forwarding_rule" "consume_payments" {
  project               = "kv-checkout-prod"
  name                  = "payments-psc-endpoint"
  region                = "asia-south1"
  load_balancing_scheme = "" # PSC consumer endpoints use an empty scheme
  network               = "projects/kv-checkout-prod/global/networks/checkout-vpc"
  ip_address            = google_compute_address.payments_psc_ip.id
  target                = module.private_service_connect.psc_service_attachment_id
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  region = "..."
  network = "..."
  target_service = "..."
  nat_subnet_cidrs = ["...", "..."]
}

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

cd live/prod/private_service_connect && 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 Producer project that owns the service attachment and NAT subnets.
name string — Yes Base name for the attachment and derived PSC NAT subnets.
region string — Yes Region for the attachment; must match the producer internal LB region.
network string — Yes Self-link or name of the VPC hosting the PSC NAT subnets.
target_service string — Yes Self-link of the producer forwarding rule (internal LB) to publish.
nat_subnet_cidrs list(string) — Yes CIDRs for the dedicated PRIVATE_SERVICE_CONNECT NAT subnets; size for peak concurrent connections.
connection_preference string "ACCEPT_MANUAL" No ACCEPT_AUTOMATIC (open) or ACCEPT_MANUAL (allow-listed).
consumer_accept_lists list(object({project_id_or_num=string, connection_limit=number})) [] No Allow-listed consumer projects with per-project connection caps (manual mode only).
domain_names list(string) [] No PSC auto-DNS domains; each must end with a trailing dot.
enable_proxy_protocol bool false No Prepend PROXY protocol header so the LB sees the consumer source.
reconcile_connections bool true No Reconcile existing consumer connections when accept/reject lists change.
description string "Managed by Terraform — ..." No Free-text description on the attachment.

Outputs

Name Description
psc_service_attachment_id Self-link/ID of the attachment; consumers target this from their PSC forwarding rule.
psc_service_attachment_name Name of the service attachment.
connection_preference Effective connection preference (ACCEPT_AUTOMATIC / ACCEPT_MANUAL).
nat_subnet_ids Self-links of the dedicated PSC NAT subnets created for the attachment.
connected_endpoints Live consumer connections (PSC IP, forwarding rule, status) as reported by GCP.
domain_names DNS domains registered for PSC auto-DNS, if any.

Enterprise scenario

A fintech runs its card-authorization service behind an internal TCP load balancer in kv-payments-prod and must expose it to an internal checkout team and two external acquiring-bank partners — none of whom can be granted VPC peering for compliance reasons. The platform team instantiates this module once with connection_preference = ACCEPT_MANUAL, allow-lists the three consumer projects with tight connection_limit values (10 for checkout, 2 per partner), and publishes payments.internal.kloudvin.com. via domain_names so consumers resolve the endpoint without static host entries. Each new partner onboarding becomes a reviewed one-line addition to consumer_accept_lists, and the emitted connected_endpoints output feeds a Cloud Monitoring alert that fires the moment a connection lands in PENDING or REJECTED.

Best practices

TerraformGCPPrivate Service ConnectModuleIaC
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