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:
- The
private_service_connection— you must target the correctsubresource_names(the group ID) for the service.blobvsfilevsdfson Storage,sqlServeron SQL DB,vaulton Key Vault. Pick the wrong one and the endpoint provisions cleanly but resolves nothing. - 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 aprivate_dns_zone_groupso the A-record is created and lifecycle-managed automatically. - 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
- You are enforcing a no-public-PaaS baseline (Azure Policy
denyon public network access for Storage/Key Vault/SQL) and need every account reachable privately. - A workload in a VNet (AKS, App Service with VNet integration, a VM tier) must reach a PaaS resource without traversing the public internet or a NAT gateway.
- You operate a hub-and-spoke topology with centralized Private DNS zones in the hub and want spokes to register endpoints into them.
- You need auditable, repeatable private connectivity — the same module for dev/test/prod so the group ID and DNS wiring can’t drift between environments.
- Skip it for a one-off endpoint you’ll click together in the portal, or when the target service is only reachable via service endpoints (a different, subnet-scoped feature) rather than private endpoints.
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 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_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
- Match the group ID to the service exactly. Storage exposes
blob,file,queue,table,dfs,webas separate endpoints — one per group ID you actually use; Key Vault isvault, Azure SQL issqlServer, Cosmos DB SQL API isSql. A wrong or omittedsubresource_namesprovisions an endpoint that resolves nothing. Audit it before merge. - Always pair the endpoint with DNS. Pass
private_dns_zone_ids(or have a centralized hub zone linked to the VNet); never leave name resolution to chance. Theprivate_dns_zone_groupkeeps the A-record’s IP correct across recreation — manualazurerm_private_dns_a_recordedits drift and page someone at 3am. - Disable the public endpoint on the target. A private endpoint does not close the public door by itself. Set
public_network_access_enabled = falseon the Storage/Key Vault/SQL resource so traffic must use the private path; otherwise you pay for the endpoint and still leak. - Mind the cost model. Private endpoints bill per endpoint-hour plus per-GB processed, and each group ID is a billable endpoint. Consolidate to the sub-resources you genuinely use rather than provisioning all of them “just in case.”
- Prefer dynamic IPs; pin only when a rule needs it. Use
ip_configurationsfor static allocation only where a fixed NSG/UDR target demands it — pinned IPs constrain subnet sizing and complicate scale-out. Either way, size the private-endpoint subnet generously (a/27or larger) since every endpoint consumes an address. - Name for discoverability. Standardize on
pe-<resource>-<group>and keep the<name>-nicdefault so operators can find the backing NIC in the portal in seconds; carry anownerandworkloadtag for blast-radius and cost attribution.