Quick take — Build a reusable Terraform module for GCP VPC Service Controls with google_access_context_manager_service_perimeter — perimeters, access levels, ingress/egress policies and dry-run enforcement. 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 "vpc_sc" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-sc?ref=v1.0.0"
access_policy_id = "..." # Numeric ID of the org-level Access Context Manager acce…
perimeter_name = "..." # Short, unique perimeter resource name (lowercase, numbe…
perimeter_title = "..." # Human-readable title shown in the console.
protected_project_numbers = ["...", "..."] # GCP project **numbers** to enclose in the perimeter.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
VPC Service Controls (VPC-SC) is GCP’s data-exfiltration defense for managed APIs. It draws a logical service perimeter around a set of projects so that requests to protected services — BigQuery, Cloud Storage, Pub/Sub, Vertex AI and dozens more — can only originate from inside that perimeter, or from explicitly allow-listed identities and networks. Unlike IAM (which answers “are you allowed?”) VPC-SC answers “are you calling from an approved boundary?”. The result is that even a leaked service-account key cannot pull data from a protected GCS bucket if the call comes from outside the perimeter.
The trouble is that a perimeter is rarely a single resource in isolation. A production perimeter pairs with an access policy, one or more access levels (the conditions under which external callers are trusted — corporate IP ranges, specific device posture, named principals), and granular ingress/egress rules that punch surgical holes for legitimate cross-perimeter traffic. Hand-rolling all of that per environment is error-prone, and a misconfigured perimeter either blocks production traffic or silently leaves a hole open. This module wraps google_access_context_manager_service_perimeter together with its access levels and directional rules behind a small, validated variable surface, and ships with dry-run mode so you can observe what would be blocked before you enforce it.
When to use it
- You handle regulated or sensitive data (PII, PHI, payment data) in BigQuery, GCS or Pub/Sub and need a hard boundary against exfiltration, not just IAM.
- You are meeting a compliance control (PCI-DSS, HIPAA, FedRAMP, ISO 27001) that requires demonstrable network-level isolation of data services.
- You run a multi-project landing zone and want to standardize perimeter creation so every data-bearing project lands inside a governed boundary by default.
- You need to allow narrow, audited exceptions — a partner pulling from one bucket, a CI runner publishing to one topic — without opening the whole perimeter.
- You are rolling VPC-SC out incrementally and want to start in dry-run, collect violation logs, then flip to enforced with no resource churn.
Reach for something else if you only need identity-based controls (use IAM and Organization Policy) or you are not using GCP-managed APIs at all — VPC-SC protects the API surface, not raw VM-to-VM traffic.
Module structure
terraform-module-gcp-vpc-sc/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# A regular perimeter holds projects directly; "enforced" config is set only
# when use_explicit_dry_run_spec = false. When true we keep the enforced
# status minimal and put real rules under spec{} so nothing is blocked yet.
resource_names = [for n in var.protected_project_numbers : "projects/${n}"]
}
# Access levels describe WHO from outside may be trusted (IP ranges, regions,
# named principals, device posture). They live on the access policy and are
# referenced by name from the perimeter's ingress rules / status.
resource "google_access_context_manager_access_level" "this" {
for_each = var.access_levels
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/accessLevels/${each.key}"
title = each.value.title
basic {
combining_function = each.value.combining_function
conditions {
ip_subnetworks = each.value.ip_subnetworks
required_access_levels = each.value.required_access_levels
members = each.value.members
regions = each.value.regions
negate = each.value.negate
}
}
}
resource "google_access_context_manager_service_perimeter" "this" {
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/servicePerimeters/${var.perimeter_name}"
title = var.perimeter_title
perimeter_type = var.perimeter_type
# Let the explicit dry-run spec drive enforcement transitions without churn.
use_explicit_dry_run_spec = var.use_explicit_dry_run_spec
# ENFORCED configuration. When use_explicit_dry_run_spec = true this is the
# "live" config and we deliberately keep it permissive (no restricted
# services) so production is not impacted while you validate in dry-run.
status {
resources = local.resource_names
restricted_services = var.use_explicit_dry_run_spec ? [] : var.restricted_services
access_levels = var.use_explicit_dry_run_spec ? [] : [
for k in var.perimeter_access_level_keys :
google_access_context_manager_access_level.this[k].name
]
dynamic "vpc_accessible_services" {
for_each = var.use_explicit_dry_run_spec ? [] : (var.vpc_allowed_services == null ? [] : [1])
content {
enable_restriction = true
allowed_services = var.vpc_allowed_services
}
}
dynamic "ingress_policies" {
for_each = var.use_explicit_dry_run_spec ? [] : var.ingress_policies
content {
ingress_from {
identity_type = ingress_policies.value.identity_type
identities = ingress_policies.value.identities
dynamic "sources" {
for_each = ingress_policies.value.source_access_levels
content {
access_level = "accessPolicies/${var.access_policy_id}/accessLevels/${sources.value}"
}
}
dynamic "sources" {
for_each = ingress_policies.value.source_resources
content {
resource = sources.value
}
}
}
ingress_to {
resources = ingress_policies.value.to_resources
dynamic "operations" {
for_each = ingress_policies.value.operations
content {
service_name = operations.value.service_name
dynamic "method_selectors" {
for_each = operations.value.methods
content {
method = method_selectors.value
}
}
}
}
}
}
}
dynamic "egress_policies" {
for_each = var.use_explicit_dry_run_spec ? [] : var.egress_policies
content {
egress_from {
identity_type = egress_policies.value.identity_type
identities = egress_policies.value.identities
}
egress_to {
resources = egress_policies.value.to_resources
dynamic "operations" {
for_each = egress_policies.value.operations
content {
service_name = operations.value.service_name
dynamic "method_selectors" {
for_each = operations.value.methods
content {
method = method_selectors.value
}
}
}
}
}
}
}
}
# DRY-RUN spec. Populated only when use_explicit_dry_run_spec = true. GCP
# evaluates this and emits "would have been denied" audit logs without
# actually blocking, so you can validate restricted_services + rules safely.
dynamic "spec" {
for_each = var.use_explicit_dry_run_spec ? [1] : []
content {
resources = local.resource_names
restricted_services = var.restricted_services
access_levels = [
for k in var.perimeter_access_level_keys :
google_access_context_manager_access_level.this[k].name
]
dynamic "vpc_accessible_services" {
for_each = var.vpc_allowed_services == null ? [] : [1]
content {
enable_restriction = true
allowed_services = var.vpc_allowed_services
}
}
dynamic "ingress_policies" {
for_each = var.ingress_policies
content {
ingress_from {
identity_type = ingress_policies.value.identity_type
identities = ingress_policies.value.identities
dynamic "sources" {
for_each = ingress_policies.value.source_access_levels
content {
access_level = "accessPolicies/${var.access_policy_id}/accessLevels/${sources.value}"
}
}
dynamic "sources" {
for_each = ingress_policies.value.source_resources
content {
resource = sources.value
}
}
}
ingress_to {
resources = ingress_policies.value.to_resources
dynamic "operations" {
for_each = ingress_policies.value.operations
content {
service_name = operations.value.service_name
dynamic "method_selectors" {
for_each = operations.value.methods
content {
method = method_selectors.value
}
}
}
}
}
}
}
dynamic "egress_policies" {
for_each = var.egress_policies
content {
egress_from {
identity_type = egress_policies.value.identity_type
identities = egress_policies.value.identities
}
egress_to {
resources = egress_policies.value.to_resources
dynamic "operations" {
for_each = egress_policies.value.operations
content {
service_name = operations.value.service_name
dynamic "method_selectors" {
for_each = operations.value.methods
content {
method = method_selectors.value
}
}
}
}
}
}
}
}
}
lifecycle {
# Perimeters are frequently mutated out-of-band by the
# google_access_context_manager_service_perimeter_resource fan-out
# resource. Guard against accidental destroy of a production boundary.
create_before_destroy = false
}
}
# variables.tf
variable "access_policy_id" {
description = "Numeric ID of the org-level Access Context Manager access policy (e.g. \"123456789\"). Find it with: gcloud access-context-manager policies list --organization ORG_ID."
type = string
validation {
condition = can(regex("^[0-9]+$", var.access_policy_id))
error_message = "access_policy_id must be the numeric policy ID, not the full resource path."
}
}
variable "perimeter_name" {
description = "Short, unique perimeter resource name (the last path segment). Lowercase letters, numbers and underscores only."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9_]{1,49}$", var.perimeter_name))
error_message = "perimeter_name must start with a letter and contain only lowercase letters, numbers and underscores (max 50 chars)."
}
}
variable "perimeter_title" {
description = "Human-readable title shown in the console for the perimeter."
type = string
}
variable "perimeter_type" {
description = "PERIMETER_TYPE_REGULAR for a standalone boundary, or PERIMETER_TYPE_BRIDGE to allow projects in two regular perimeters to share data."
type = string
default = "PERIMETER_TYPE_REGULAR"
validation {
condition = contains(["PERIMETER_TYPE_REGULAR", "PERIMETER_TYPE_BRIDGE"], var.perimeter_type)
error_message = "perimeter_type must be PERIMETER_TYPE_REGULAR or PERIMETER_TYPE_BRIDGE."
}
}
variable "protected_project_numbers" {
description = "List of GCP project NUMBERS (not IDs) to enclose in the perimeter."
type = list(string)
validation {
condition = length(var.protected_project_numbers) > 0
error_message = "At least one project number must be supplied."
}
validation {
condition = alltrue([for n in var.protected_project_numbers : can(regex("^[0-9]+$", n))])
error_message = "protected_project_numbers must contain project numbers (digits only), not project IDs."
}
}
variable "restricted_services" {
description = "Fully-qualified service endpoints to bring inside the perimeter, e.g. [\"storage.googleapis.com\", \"bigquery.googleapis.com\"]."
type = list(string)
default = ["storage.googleapis.com", "bigquery.googleapis.com"]
}
variable "vpc_allowed_services" {
description = "Optional allow-list of services reachable from within the VPC (VPC accessible services). Set to null to leave VPC accessibility unrestricted."
type = list(string)
default = null
}
variable "use_explicit_dry_run_spec" {
description = "When true, restricted_services and all rules are applied in DRY-RUN (spec) only — violations are logged but not blocked. Flip to false to enforce."
type = bool
default = true
}
variable "access_levels" {
description = "Map of access levels to create on the access policy. Key = access level name."
type = map(object({
title = string
combining_function = optional(string, "AND")
ip_subnetworks = optional(list(string), [])
required_access_levels = optional(list(string), [])
members = optional(list(string), [])
regions = optional(list(string), [])
negate = optional(bool, false)
}))
default = {}
}
variable "perimeter_access_level_keys" {
description = "Keys from var.access_levels to attach to the perimeter status/spec (callers satisfying any attached level may reach restricted services from outside)."
type = list(string)
default = []
}
variable "ingress_policies" {
description = "Directional rules allowing external identities/sources to reach resources inside the perimeter."
type = list(object({
identity_type = optional(string) # "ANY_IDENTITY", "ANY_USER_ACCOUNT", "ANY_SERVICE_ACCOUNT" or null when using identities
identities = optional(list(string), [])
source_access_levels = optional(list(string), [])
source_resources = optional(list(string), []) # e.g. ["projects/123456789"] or ["//cloudresourcemanager..."]
to_resources = optional(list(string), ["*"])
operations = optional(list(object({
service_name = string
methods = optional(list(string), ["*"])
})), [])
}))
default = []
}
variable "egress_policies" {
description = "Directional rules allowing in-perimeter identities to reach resources OUTSIDE the perimeter."
type = list(object({
identity_type = optional(string)
identities = optional(list(string), [])
to_resources = optional(list(string), ["*"])
operations = optional(list(object({
service_name = string
methods = optional(list(string), ["*"])
})), [])
}))
default = []
}
# outputs.tf
output "perimeter_id" {
description = "Fully-qualified resource ID of the service perimeter."
value = google_access_context_manager_service_perimeter.this.id
}
output "perimeter_name" {
description = "Resource name (accessPolicies/.../servicePerimeters/...) of the perimeter."
value = google_access_context_manager_service_perimeter.this.name
}
output "perimeter_title" {
description = "Human-readable title of the perimeter."
value = google_access_context_manager_service_perimeter.this.title
}
output "enforcement_mode" {
description = "\"dry-run\" if violations are only logged, \"enforced\" if blocked."
value = var.use_explicit_dry_run_spec ? "dry-run" : "enforced"
}
output "protected_resources" {
description = "List of projects enclosed by the perimeter (projects/NUMBER)."
value = [for n in var.protected_project_numbers : "projects/${n}"]
}
output "access_level_names" {
description = "Map of access level key to its fully-qualified resource name."
value = { for k, al in google_access_context_manager_access_level.this : k => al.name }
}
How to use it
# Enclose the regulated-data project. Start in dry-run, allow only the
# corporate egress IP range and the CI service account through, and let
# the data-platform project's storage reach an external partner bucket.
module "vpc_service_controls" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-sc?ref=v1.0.0"
access_policy_id = "987654321098"
perimeter_name = "data_platform_prod"
perimeter_title = "Data Platform Production Perimeter"
protected_project_numbers = ["111122223333", "444455556666"]
restricted_services = [
"storage.googleapis.com",
"bigquery.googleapis.com",
"pubsub.googleapis.com",
"aiplatform.googleapis.com",
]
# Trust requests from the corporate egress range for break-glass console use.
access_levels = {
corp_network = {
title = "Corporate Egress Network"
ip_subnetworks = ["203.0.113.0/24", "198.51.100.0/24"]
}
}
perimeter_access_level_keys = ["corp_network"]
# Let the cross-project CI deployer publish to Pub/Sub from outside.
ingress_policies = [
{
identity_type = "ANY_SERVICE_ACCOUNT"
identities = ["serviceAccount:ci-deployer@tooling-proj.iam.gserviceaccount.com"]
to_resources = ["*"]
operations = [
{
service_name = "pubsub.googleapis.com"
methods = ["google.pubsub.v1.Publisher.Publish"]
}
]
}
]
# Allow in-perimeter workloads to read one external partner bucket only.
egress_policies = [
{
identity_type = "ANY_IDENTITY"
to_resources = ["projects/777788889999"]
operations = [
{
service_name = "storage.googleapis.com"
methods = ["google.storage.objects.get", "google.storage.objects.list"]
}
]
}
]
# Keep enforcing OFF until violation logs are clean.
use_explicit_dry_run_spec = true
}
# Downstream: wire the perimeter into a monitoring alert policy that fires on
# VPC-SC violation audit logs scoped to this exact perimeter.
resource "google_monitoring_alert_policy" "vpcsc_violations" {
display_name = "VPC-SC violations: ${module.vpc_service_controls.perimeter_title}"
combiner = "OR"
conditions {
display_name = "RESOURCES_EXCEEDED denied requests"
condition_matched_log {
filter = <<-EOT
logName=~"logs/cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.violationReason!=""
protoPayload.metadata.securityPolicyInfo.servicePerimeterName="${module.vpc_service_controls.perimeter_name}"
EOT
}
}
alert_strategy {
notification_rate_limit { period = "300s" }
}
}
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/vpc_sc/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-sc?ref=v1.0.0"
}
inputs = {
access_policy_id = "..."
perimeter_name = "..."
perimeter_title = "..."
protected_project_numbers = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpc_sc && 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 |
|---|---|---|---|---|
| access_policy_id | string |
— | Yes | Numeric ID of the org-level Access Context Manager access policy. |
| perimeter_name | string |
— | Yes | Short, unique perimeter resource name (lowercase, numbers, underscores). |
| perimeter_title | string |
— | Yes | Human-readable title shown in the console. |
| perimeter_type | string |
"PERIMETER_TYPE_REGULAR" |
No | PERIMETER_TYPE_REGULAR or PERIMETER_TYPE_BRIDGE. |
| protected_project_numbers | list(string) |
— | Yes | GCP project numbers to enclose in the perimeter. |
| restricted_services | list(string) |
["storage.googleapis.com", "bigquery.googleapis.com"] |
No | Service endpoints brought inside the perimeter. |
| vpc_allowed_services | list(string) |
null |
No | Allow-list of services reachable from within the VPC; null leaves it unrestricted. |
| use_explicit_dry_run_spec | bool |
true |
No | true applies rules in dry-run (logged, not blocked); false enforces. |
| access_levels | map(object) |
{} |
No | Access levels to create (IP ranges, regions, members, device posture). |
| perimeter_access_level_keys | list(string) |
[] |
No | Keys from access_levels to attach to the perimeter. |
| ingress_policies | list(object) |
[] |
No | Rules allowing external sources to reach in-perimeter resources. |
| egress_policies | list(object) |
[] |
No | Rules allowing in-perimeter identities to reach external resources. |
Outputs
| Name | Description |
|---|---|
| perimeter_id | Fully-qualified resource ID of the service perimeter. |
| perimeter_name | Resource name (accessPolicies/.../servicePerimeters/...). |
| perimeter_title | Human-readable title of the perimeter. |
| enforcement_mode | "dry-run" if violations are only logged, "enforced" if blocked. |
| protected_resources | List of enclosed projects (projects/NUMBER). |
| access_level_names | Map of access level key to its fully-qualified resource name. |
Enterprise scenario
A healthcare analytics provider stores de-identified claims data in BigQuery and raw ingestion files in Cloud Storage across two GCP projects, and must satisfy a HIPAA control requiring network-level isolation of PHI-adjacent services. The platform team consumes this module once per region from their landing-zone repo, enclosing both projects and restricting storage, bigquery and pubsub. They run for two sprints with use_explicit_dry_run_spec = true, mine the VPC-SC violation logs through the bundled alert policy to find a forgotten Looker connection and a batch job calling from an on-prem range, add precise ingress rules for both, then flip the flag to false in a single PR — enforcing the boundary with zero resource recreation and a clean audit trail.
Best practices
- Always start in dry-run. Set
use_explicit_dry_run_spec = true, let it run for at least a week, and triage theRESOURCES_EXCEEDED/VPC_SERVICE_CONTROLSviolation audit logs before enforcing. Flipping to enforced too early is the #1 way to break production data pipelines. - Use project numbers, not project IDs. Perimeter membership is keyed on the immutable numeric project number; passing the human-readable ID will fail apply. The module validates this for you, but plan your tfvars accordingly.
- Keep ingress/egress rules surgical. Scope every directional rule to a specific
service_nameandmethodsrather than["*"], and to namedidentitiesrather thanANY_IDENTITY. A broad egress rule re-opens the exfiltration path the perimeter exists to close. - Manage perimeter membership in one place. Do not mix this resource-style management with the separate
google_access_context_manager_service_perimeter_resourcefan-out for the same perimeter — Terraform will fight itself on every plan. Pick one model and stay consistent. - Name perimeters by data domain and environment, e.g.
data_platform_prod,payments_nonprod. Perimeter names are immutable and surface in every violation log, so a clear convention pays off during incident triage. - Pair restrictions with
vpc_allowed_servicesfor defense in depth. Restricting services at the perimeter stops external exfiltration; constraining VPC accessible services additionally limits which APIs in-perimeter VMs can even reach, shrinking the blast radius of a compromised workload.