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
- You run an internal TCP/UDP load balancer or internal HTTP(S) load balancer in a producer VPC and need to expose it to other teams, projects, or even external organizations without peering networks together.
- You are building a SaaS or shared-platform offering on GCP (a managed Kafka, a payments gateway, an internal API plane) and want consumers to attach with their own PSC endpoint IPs.
- You need explicit, auditable control over which consumer projects may connect, with per-project connection caps — i.e.
ACCEPT_MANUAL. - You want consumer-side
/etc/hosts-free DNS to resolve automatically through PSCdomain_names. - Do not reach for this for the consumer side (use a forwarding rule targeting the attachment) or for connecting to Google-managed APIs like
storage.googleapis.com— those use PSC endpoints /google_compute_global_address+ agoogle-managed-servicesforwarding rule, not a service attachment.
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
- Dedicate the NAT range and size it deliberately. PSC source-NATs every consumer connection through the
PRIVATE_SERVICE_CONNECTsubnets, and that range can serve nothing else. A/24gives ~250 usable addresses; under-provisioning silently caps concurrent connections, so size against peak fan-out, not average load. - Default to
ACCEPT_MANUALwith explicit connection limits.ACCEPT_AUTOMATIClets any project in the org connect — fine for an internal free-for-all, dangerous for anything regulated. Manual accept-lists plus per-projectconnection_limitgive you an auditable, rate-bounded contract and make partner offboarding a code review. - Keep
target_service, the attachment, and the NAT subnets in the same region. A service attachment is regional and must point at a forwarding rule in its own region; cross-region publication is not supported, so co-locate producer LB and module instance. - Use
domain_namesfor stable consumer DNS, and always include the trailing dot. Auto-DNS registration removes brittle host overrides on the consumer side; the validation in this module rejects un-dotted domains so a typo fails atplan, not at runtime. - Watch
connected_endpointsand reconciliation. Wire the output into monitoring to catchPENDING/REJECTEDstates early, and leavereconcile_connections = trueso tightening an accept-list actively severs connections that no longer qualify instead of leaving stale grants. - Name for ownership and blast radius. Prefix the attachment with the owning service (
payments-api-psc) so the derived NAT subnets (payments-api-psc-psc-nat-0) are self-documenting in shared-VPC host projects where many teams’ PSC ranges coexist.