IaC Azure

Terraform Module: Azure Private Link Service — publish your service behind a private endpoint your consumers control

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

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

TerraformAzurePrivate Link ServiceModuleIaC
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