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 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/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