Quick take — Build a reusable hashicorp/azurerm ~> 4.0 module for azurerm_private_link_service: NAT IP configs, an approval-based visibility/auto-approval allowlist, and TCP proxy v2 wired to a Standard internal load balancer. 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 "azurerm" {
features {}
}
module "private_link_service" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-link-service?ref=v1.0.0"
name = "..." # Name of the Private Link Service (2-80 chars, validated…
resource_group_name = "..." # Resource group that holds the Private Link Service.
location = "..." # Azure region; must match the load balancer's region.
load_balancer_frontend_ip_configuration_ids = ["...", "..."] # Frontend IP config IDs of the Standard internal LB to p…
nat_ip_configurations = ["...", "..."] # NAT IP configs (1-8); exactly one `primary = true`; sub…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Private Link Service is the provider side of Private Link. It lets you take a workload sitting behind a Standard SKU internal load balancer and expose it so that other tenants, subscriptions, or VNets can reach it over a private endpoint — without VNet peering, public IPs, NAT gateways, or any traffic ever touching the internet. Consumers connect to a private IP in their own VNet; you stay in control of who is allowed to connect via a connection-approval workflow.
The mechanics are fiddly and easy to get subtly wrong, which is exactly why it belongs in a module. A correct azurerm_private_link_service needs: a load balancer frontend IP configuration ID to front (the LB must be Standard SKU and internal), one or more NAT IP configurations carved from a subnet that has private_link_service_network_policies_enabled = false, an explicit choice between public (anyone can request) vs restricted visibility, an optional auto-approval allowlist of subscription IDs, and TCP Proxy Protocol v2 if your backend needs the real consumer source IP. Wrapping all of this in a terraform-module-azure-private-link-service gives every team one vetted, var-driven way to publish an internal service privately — with the network-policy footgun and the visibility/approval model handled for them.
When to use it
- You are a SaaS / platform team publishing an internal API or appliance to customers in other Azure tenants, and you want them to consume it through a private endpoint they create and govern.
- You run a shared internal service (payments gateway, central logging sink, licensing service, an NVA) that multiple business-unit subscriptions must reach privately, but you do not want to peer dozens of VNets together.
- You need tenant isolation with overlapping address spaces — private endpoints don’t care if consumer and producer reuse the same RFC1918 ranges, unlike peering.
- You must keep the data path off the public internet for compliance, yet still offer the service across subscription/tenant boundaries.
- Skip this module when consumer and producer live in the same VNet (just use the LB directly) or when one-way private consumption of a first-party PaaS resource is all you need — that is a private endpoint, not a private link service.
Module structure
terraform-module-azure-private-link-service/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_private_link_service + NAT IP configs, visibility, auto-approval
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, alias, name, NAT IP map
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_private_link_service" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
# Frontend IP config(s) of the Standard *internal* load balancer to publish.
load_balancer_frontend_ip_configuration_ids = var.load_balancer_frontend_ip_configuration_ids
# Send TCP PROXY protocol v2 so the backend sees the real consumer source IP.
enable_proxy_protocol = var.enable_proxy_protocol
# FQDNs the PLS presents to consumers (lets them use their own DNS/cert names).
fqdns = var.fqdns
# NAT IPs the service uses to talk to your backend pool. The subnet MUST have
# private_link_service_network_policies_enabled = false.
dynamic "nat_ip_configuration" {
for_each = var.nat_ip_configurations
content {
name = nat_ip_configuration.value.name
subnet_id = nat_ip_configuration.value.subnet_id
primary = nat_ip_configuration.value.primary
private_ip_address = nat_ip_configuration.value.private_ip_address
private_ip_address_version = nat_ip_configuration.value.private_ip_address_version
}
}
# Who is even allowed to *see* / request a connection. Empty list ("*") => public.
visibility_subscription_ids = var.visibility_subscription_ids
# Connections from these subscriptions are auto-approved (no manual approval).
auto_approval_subscription_ids = var.auto_approval_subscription_ids
tags = var.tags
}
variables.tf
variable "name" {
description = "Name of the Private Link Service."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 2-80 chars, start alphanumeric, and contain only letters, digits, '.', '-' or '_'."
}
}
variable "resource_group_name" {
description = "Resource group that will hold the Private Link Service."
type = string
}
variable "location" {
description = "Azure region. Must match the region of the load balancer being published."
type = string
}
variable "load_balancer_frontend_ip_configuration_ids" {
description = "Frontend IP configuration IDs of the Standard SKU *internal* load balancer to expose."
type = list(string)
validation {
condition = length(var.load_balancer_frontend_ip_configuration_ids) > 0
error_message = "At least one load balancer frontend IP configuration ID is required."
}
}
variable "nat_ip_configurations" {
description = <<-EOT
NAT IP configurations used by the service to reach the backend pool. The referenced
subnet must have private_link_service_network_policies_enabled = false. Exactly one
entry must be primary = true. Up to 8 entries are supported.
EOT
type = list(object({
name = string
subnet_id = string
primary = bool
private_ip_address = optional(string)
private_ip_address_version = optional(string, "IPv4")
}))
validation {
condition = length([for c in var.nat_ip_configurations : c if c.primary]) == 1
error_message = "Exactly one nat_ip_configurations entry must have primary = true."
}
validation {
condition = length(var.nat_ip_configurations) >= 1 && length(var.nat_ip_configurations) <= 8
error_message = "Provide between 1 and 8 NAT IP configurations."
}
validation {
condition = alltrue([
for c in var.nat_ip_configurations : contains(["IPv4", "IPv6"], c.private_ip_address_version)
])
error_message = "private_ip_address_version must be either 'IPv4' or 'IPv6'."
}
}
variable "enable_proxy_protocol" {
description = "Send TCP PROXY protocol v2 so the backend receives the consumer's real source IP. Backend must be configured to parse it."
type = bool
default = false
}
variable "fqdns" {
description = "FQDNs the Private Link Service advertises to consumers (for their DNS/TLS names). Optional."
type = list(string)
default = []
}
variable "visibility_subscription_ids" {
description = "Subscription IDs allowed to discover and request a connection. Use ['*'] for public visibility (any subscription)."
type = list(string)
default = ["*"]
validation {
condition = alltrue([
for s in var.visibility_subscription_ids :
s == "*" || can(regex("^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$", s))
])
error_message = "visibility_subscription_ids must be '*' or valid subscription GUIDs."
}
}
variable "auto_approval_subscription_ids" {
description = "Subscription IDs whose private endpoint connections are auto-approved (must be a subset of visible subscriptions)."
type = list(string)
default = []
validation {
condition = alltrue([
for s in var.auto_approval_subscription_ids :
can(regex("^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$", s))
])
error_message = "auto_approval_subscription_ids must be valid subscription GUIDs."
}
}
variable "tags" {
description = "Tags to apply to the Private Link Service."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Private Link Service."
value = azurerm_private_link_service.this.id
}
output "name" {
description = "Name of the Private Link Service."
value = azurerm_private_link_service.this.name
}
output "alias" {
description = "Globally unique alias of the Private Link Service. Share this with consumers so they can create a private endpoint by alias (no need to expose the resource ID)."
value = azurerm_private_link_service.this.alias
}
output "nat_ip_configuration" {
description = "Map of NAT IP configuration name => effective private IP address allocated to the service."
value = {
for c in azurerm_private_link_service.this.nat_ip_configuration :
c.name => c.private_ip_address
}
}
How to use it
Publish an internal API that lives behind a Standard internal load balancer, make it visible only to a partner subscription, and auto-approve that partner so their private endpoint comes up without a manual click.
module "private_link_service" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-link-service?ref=v1.0.0"
name = "pls-orders-api-prod"
resource_group_name = azurerm_resource_group.platform.name
location = azurerm_resource_group.platform.location
# Publish the frontend of our existing Standard internal LB.
load_balancer_frontend_ip_configuration_ids = [
azurerm_lb.orders_internal.frontend_ip_configuration[0].id
]
# Backend-facing NAT IPs from a PLS-enabled subnet.
nat_ip_configurations = [
{
name = "primary"
subnet_id = azurerm_subnet.pls.id # network policies disabled below
primary = true
}
]
enable_proxy_protocol = true
fqdns = ["orders.api.kloudvin.internal"]
# Only the partner subscription can see + connect, and is auto-approved.
visibility_subscription_ids = ["7c1f9b2e-3a44-4d11-9f0a-2b6e8d5c1a90"]
auto_approval_subscription_ids = ["7c1f9b2e-3a44-4d11-9f0a-2b6e8d5c1a90"]
tags = {
env = "prod"
team = "platform"
owner = "vinod"
}
}
# The subnet hosting the NAT IPs MUST disable PLS network policies.
resource "azurerm_subnet" "pls" {
name = "snet-pls"
resource_group_name = azurerm_virtual_network.hub.resource_group_name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = ["10.10.4.0/27"]
private_link_service_network_policies_enabled = false
}
# Downstream: hand the alias to a consumer's private endpoint (in their VNet/sub).
resource "azurerm_private_endpoint" "consumer" {
name = "pe-orders-api"
resource_group_name = azurerm_resource_group.consumer.name
location = azurerm_resource_group.consumer.location
subnet_id = azurerm_subnet.consumer_app.id
private_service_connection {
name = "psc-orders-api"
private_connection_resource_alias = module.private_link_service.alias
is_manual_connection = false
}
}
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 = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/private_link_service/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-link-service?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
load_balancer_frontend_ip_configuration_ids = ["...", "..."]
nat_ip_configurations = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/private_link_service && 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 |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the Private Link Service (2-80 chars, validated). |
resource_group_name |
string |
— | Yes | Resource group that holds the Private Link Service. |
location |
string |
— | Yes | Azure region; must match the load balancer’s region. |
load_balancer_frontend_ip_configuration_ids |
list(string) |
— | Yes | Frontend IP config IDs of the Standard internal LB to publish (≥1). |
nat_ip_configurations |
list(object) |
— | Yes | NAT IP configs (1-8); exactly one primary = true; subnet must disable PLS network policies. |
enable_proxy_protocol |
bool |
false |
No | Send TCP PROXY protocol v2 so the backend sees the consumer source IP. |
fqdns |
list(string) |
[] |
No | FQDNs advertised to consumers for their DNS/TLS names. |
visibility_subscription_ids |
list(string) |
["*"] |
No | Subscriptions allowed to discover/request a connection; ["*"] = public. |
auto_approval_subscription_ids |
list(string) |
[] |
No | Subscriptions whose connections are auto-approved (subset of visible). |
tags |
map(string) |
{} |
No | Tags applied to the Private Link Service. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Private Link Service. |
name |
Name of the Private Link Service. |
alias |
Globally unique alias; share with consumers to create a private endpoint by alias. |
nat_ip_configuration |
Map of NAT IP config name => effective private IP allocated to the service. |
Enterprise scenario
A fintech platform team runs a central payments authorization service behind a Standard internal load balancer in their hub subscription. Each of the company’s 30+ product subscriptions — some with overlapping 10.0.0.0/16 ranges from acquisitions — needs to call it privately, and peering them all was a non-starter. They publish the service once with this module, set visibility_subscription_ids to the org’s management-group subscription list and auto_approval_subscription_ids for the trusted production subscriptions, then distribute only the alias output via their internal service catalog. Product teams self-serve a private endpoint by alias, traffic never leaves the Microsoft backbone, and the platform team retains an audit trail of every approved connection.
Best practices
- Disable PLS network policies on the NAT subnet, nowhere else.
private_link_service_network_policies_enabled = falseis mandatory on the NAT IP subnet but weakens NSG/UDR enforcement on private endpoints — keep that subnet dedicated to PLS NAT IPs and small (a/27is plenty for up to 8 NAT IPs). - Default to restricted visibility; treat
["*"]as a deliberate decision. A public PLS is discoverable org-wide. Scopevisibility_subscription_idsto the exact subscriptions that should connect, and keepauto_approval_subscription_idsa strict subset — never auto-approve something that isn’t even visible. - Enable Proxy Protocol v2 only when the backend parses it.
enable_proxy_protocol = trueprepends the TCP PROXY v2 header so you recover the consumer’s real source IP for logging and rate-limiting — but a backend that doesn’t expect it will choke on every connection. Turn it on in lockstep with the listener config. - Standard SKU internal LB is non-negotiable. Private Link Service only fronts a Standard SKU internal load balancer; a Basic or public LB will fail. Bake the SKU/internal assertion into the upstream LB module so consumers can’t wire in an incompatible frontend.
- Share the
alias, not the resource ID. The alias is globally unique and lets cross-tenant consumers connect without you exposing subscription/resource-group structure — publish it through a service catalog and rotate access by editing the visibility list, not by recreating the service. - Name and tag for fleet-wide governance. Use a stable
pls-<service>-<env>convention and tagowner/team/envso you can attribute connection-approval requests and reason about blast radius when a downstream consumer misbehaves.