IaC Azure

Terraform Module: Azure Private Endpoint — private connectivity to PaaS with zero data-plane exposure to the internet

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_private_endpoint: wires private_service_connection, Private DNS zone groups, and per-subresource IP config so PaaS traffic stays on your VNet. 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_endpoint" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-endpoint?ref=v1.0.0"

  name                           = "..."           # Private endpoint name; also derives the service connect…
  location                       = "..."           # Azure region; must match or peer with the subnet's regi…
  resource_group_name            = "..."           # Resource group for the private endpoint.
  subnet_id                      = "..."           # Subnet ID that supplies the endpoint's private IP.
  private_connection_resource_id = "..."           # Resource ID of the target PaaS resource. Validated as a…
  subresource_names              = ["...", "..."]  # Group ID(s) of the target sub-resource (e.g. `["blob"]`…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure Private Endpoint is a network interface that gets a private IP from one of your VNet subnets and maps it to a specific instance of a PaaS service — a particular Storage account, Key Vault, SQL Server, Cosmos DB account, App Service, and so on. Once it exists, traffic to that resource travels over the Microsoft backbone via the private IP instead of its public endpoint, and you can drop the resource’s public_network_access entirely. The hard part is rarely the azurerm_private_endpoint resource itself — it is the three things that surround it and break silently when you get them wrong:

  1. The private_service_connection — you must target the correct subresource_names (the group ID) for the service. blob vs file vs dfs on Storage, sqlServer on SQL DB, vault on Key Vault. Pick the wrong one and the endpoint provisions cleanly but resolves nothing.
  2. Private DNS — the private IP is useless unless the resource’s public FQDN (e.g. myacct.blob.core.windows.net) resolves to it from inside the VNet. That requires the right Private DNS zone (privatelink.blob.core.windows.net) linked to your network, plus a private_dns_zone_group so the A-record is created and lifecycle-managed automatically.
  3. Lifecycle drift — Azure rewrites the A-record IP when an endpoint is recreated, and manual DNS edits are a classic source of 3am incidents.

Wrapping this in a module means every team consumes a single, reviewed pattern: correct group IDs per service, DNS auto-registration on by default, consistent naming, and a NIC name you can actually find in the portal. It turns “did someone remember the DNS zone group?” into a non-question.

When to use it

Module structure

terraform-module-azure-private-endpoint/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_private_endpoint + service connection + DNS zone group
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id/name + private IP, FQDN, NIC id

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  # Default the NIC name so it is discoverable in the portal, but allow override.
  nic_name = coalesce(var.custom_network_interface_name, "${var.name}-nic")
}

resource "azurerm_private_endpoint" "this" {
  name                          = var.name
  location                      = var.location
  resource_group_name           = var.resource_group_name
  subnet_id                     = var.subnet_id
  custom_network_interface_name = local.nic_name
  tags                          = var.tags

  private_service_connection {
    name                           = "${var.name}-psc"
    private_connection_resource_id = var.private_connection_resource_id
    subresource_names              = var.subresource_names
    is_manual_connection           = var.is_manual_connection
    request_message                = var.is_manual_connection ? var.request_message : null
  }

  # Pin the private IP(s) per member of the group when requested. Without this,
  # Azure assigns a dynamic IP from the subnet, which is fine for most cases but
  # undesirable when you front the endpoint with a fixed NSG/route rule.
  dynamic "ip_configuration" {
    for_each = var.ip_configurations
    content {
      name               = ip_configuration.value.name
      private_ip_address = ip_configuration.value.private_ip_address
      subresource_name   = ip_configuration.value.subresource_name
      member_name        = coalesce(ip_configuration.value.member_name, ip_configuration.value.subresource_name)
    }
  }

  # Auto-register the A-record(s) into the supplied Private DNS zone(s) and let
  # Azure keep the record in sync with the endpoint's IP over its lifecycle.
  dynamic "private_dns_zone_group" {
    for_each = length(var.private_dns_zone_ids) > 0 ? [1] : []
    content {
      name                 = var.private_dns_zone_group_name
      private_dns_zone_ids = var.private_dns_zone_ids
    }
  }
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the private endpoint. Also used to derive the service connection and default NIC name."

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{1,78}[a-zA-Z0-9_]$", var.name))
    error_message = "name must be 3-80 chars, start alphanumeric, and contain only letters, digits, '.', '_' or '-'."
  }
}

variable "location" {
  type        = string
  description = "Azure region for the private endpoint. Must match (or peer with) the subnet's region."
}

variable "resource_group_name" {
  type        = string
  description = "Resource group in which to create the private endpoint."
}

variable "subnet_id" {
  type        = string
  description = "Resource ID of the subnet that provides the endpoint's private IP. The subnet must not be delegated and must allow private endpoint network policies appropriately."
}

variable "private_connection_resource_id" {
  type        = string
  description = "Resource ID of the target PaaS resource (e.g. the Storage account, Key Vault, or SQL Server) to connect to privately."

  validation {
    condition     = can(regex("^/subscriptions/.+/resourceGroups/.+/providers/.+", var.private_connection_resource_id))
    error_message = "private_connection_resource_id must be a fully-qualified Azure resource ID."
  }
}

variable "subresource_names" {
  type        = list(string)
  description = "Group IDs of the target sub-resource(s), e.g. [\"blob\"], [\"vault\"], [\"sqlServer\"], [\"file\"]. Must be empty for manual connections to some first-party resources, otherwise exactly the group(s) the service exposes."

  validation {
    condition     = length(var.subresource_names) <= 1 || alltrue([for s in var.subresource_names : length(trimspace(s)) > 0])
    error_message = "subresource_names entries must be non-empty group IDs."
  }
}

variable "is_manual_connection" {
  type        = bool
  description = "Set true when the endpoint targets a resource in another tenant/subscription that requires owner approval of the connection request."
  default     = false
}

variable "request_message" {
  type        = string
  description = "Approval message sent to the resource owner. Only used when is_manual_connection = true."
  default     = "Private endpoint connection requested via Terraform."

  validation {
    condition     = length(var.request_message) <= 140
    error_message = "request_message must be 140 characters or fewer."
  }
}

variable "private_dns_zone_ids" {
  type        = list(string)
  description = "Resource IDs of the Private DNS zone(s) to auto-register the endpoint's A-record(s) into (e.g. privatelink.blob.core.windows.net). Leave empty to manage DNS yourself."
  default     = []
}

variable "private_dns_zone_group_name" {
  type        = string
  description = "Name of the private_dns_zone_group block. Only used when private_dns_zone_ids is non-empty."
  default     = "default"
}

variable "custom_network_interface_name" {
  type        = string
  description = "Override the auto-generated NIC name (\"<name>-nic\"). Useful to satisfy strict NIC naming standards."
  default     = null
}

variable "ip_configurations" {
  type = list(object({
    name               = string
    private_ip_address = string
    subresource_name   = string
    member_name        = optional(string)
  }))
  description = "Optional static private IP assignments per group member. member_name defaults to subresource_name when omitted. Leave empty for dynamic IP allocation."
  default     = []
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to the private endpoint."
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the private endpoint."
  value       = azurerm_private_endpoint.this.id
}

output "name" {
  description = "Name of the private endpoint."
  value       = azurerm_private_endpoint.this.name
}

output "network_interface_id" {
  description = "Resource ID of the NIC backing the private endpoint."
  value       = azurerm_private_endpoint.this.network_interface[0].id
}

output "private_ip_address" {
  description = "First private IP allocated to the endpoint (from custom_dns_configs)."
  value       = try(azurerm_private_endpoint.this.custom_dns_configs[0].ip_addresses[0], null)
}

output "private_ip_addresses" {
  description = "All private IPs allocated to the endpoint across group members."
  value       = flatten([for c in azurerm_private_endpoint.this.custom_dns_configs : c.ip_addresses])
}

output "fqdn" {
  description = "The target resource FQDN that resolves to the private IP (first custom DNS config)."
  value       = try(azurerm_private_endpoint.this.custom_dns_configs[0].fqdn, null)
}

output "private_service_connection" {
  description = "Status and details of the private service connection (name, request response, status)."
  value       = azurerm_private_endpoint.this.private_service_connection
}

How to use it

Below, a Storage account is made reachable only over a private endpoint for its blob sub-resource, registered into the hub’s privatelink.blob.core.windows.net zone. A downstream resource then consumes the endpoint’s private IP — here, to seed an NSG rule that only permits the app subnet to reach that exact address.

data "azurerm_private_dns_zone" "blob" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = "rg-hub-dns"
}

module "private_endpoint" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-endpoint?ref=v1.0.0"

  name                = "pe-stkloudvindata-blob"
  location            = "centralindia"
  resource_group_name = "rg-app-prod"
  subnet_id           = azurerm_subnet.private_endpoints.id

  private_connection_resource_id = azurerm_storage_account.data.id
  subresource_names              = ["blob"]

  private_dns_zone_ids = [data.azurerm_private_dns_zone.blob.id]

  tags = {
    environment = "prod"
    workload    = "kloudvin-data"
    owner       = "platform-team"
  }
}

# Downstream: lock an NSG rule to the endpoint's private IP so only the app
# subnet can reach the blob endpoint, even from inside the VNet.
resource "azurerm_network_security_rule" "allow_app_to_blob_pe" {
  name                        = "Allow-App-To-BlobPE"
  priority                    = 200
  direction                   = "Outbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "443"
  source_address_prefix       = "10.20.1.0/24"
  destination_address_prefix  = module.private_endpoint.private_ip_address
  resource_group_name         = "rg-app-prod"
  network_security_group_name = azurerm_network_security_group.app.name
}

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_endpoint/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-endpoint?ref=v1.0.0"
}

inputs = {
  name = "..."
  location = "..."
  resource_group_name = "..."
  subnet_id = "..."
  private_connection_resource_id = "..."
  subresource_names = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/private_endpoint && 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 Private endpoint name; also derives the service connection name and default NIC name.
location string Yes Azure region; must match or peer with the subnet’s region.
resource_group_name string Yes Resource group for the private endpoint.
subnet_id string Yes Subnet ID that supplies the endpoint’s private IP.
private_connection_resource_id string Yes Resource ID of the target PaaS resource. Validated as a fully-qualified Azure resource ID.
subresource_names list(string) Yes Group ID(s) of the target sub-resource (e.g. ["blob"], ["vault"], ["sqlServer"]).
is_manual_connection bool false No True when targeting a resource that requires owner approval (cross-tenant).
request_message string "Private endpoint connection requested via Terraform." No Approval message (<= 140 chars); used only for manual connections.
private_dns_zone_ids list(string) [] No Private DNS zone ID(s) for auto-registering the A-record(s). Empty = self-managed DNS.
private_dns_zone_group_name string "default" No Name of the DNS zone group block.
custom_network_interface_name string null No Override the <name>-nic NIC name.
ip_configurations list(object) [] No Static private IP assignments per group member; empty = dynamic allocation.
tags map(string) {} No Tags applied to the private endpoint.

Outputs

Name Description
id Resource ID of the private endpoint.
name Name of the private endpoint.
network_interface_id Resource ID of the NIC backing the endpoint.
private_ip_address First private IP allocated to the endpoint.
private_ip_addresses All private IPs across group members.
fqdn Target resource FQDN that resolves to the private IP.
private_service_connection Service connection details and status (name, request response, status).

Enterprise scenario

A regulated fintech runs an Azure Policy that denies public network access on every Storage account, Key Vault, and SQL Server across 40+ spoke subscriptions. Each spoke’s landing-zone pipeline calls this module once per PaaS dependency — ["vault"] for Key Vault, ["sqlServer"] for the SQL logical server, ["blob"] and ["dfs"] for the data-lake account — all registering into the centrally-managed privatelink.* zones that live in the connectivity hub and are linked to every spoke VNet via DNS zone virtual-network links. Because DNS auto-registration is baked into the module default, a new spoke is private-by-construction the moment its pipeline runs, with no DNS ticket and no chance of a forgotten zone group.

Best practices

TerraformAzurePrivate EndpointModuleIaC
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