Quick take — A production-ready Terraform module for azurerm_subnet on azurerm ~> 4.0: var-driven prefixes, service delegation, NSG and route-table association, service endpoints, and private-endpoint policy controls. 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 "subnet" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-subnet?ref=v1.0.0"
name = "..." # Name of the subnet (1–80 chars).
resource_group_name = "..." # Resource group containing the parent VNet.
virtual_network_name = "..." # Name of the parent virtual network.
address_prefixes = ["...", "..."] # CIDR prefixes for the subnet; each is CIDR-validated.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure subnet is a logical partition of a virtual network’s address space (azurerm_subnet). It is where the real networking decisions live: which /24 (or /27) range a workload gets, whether a Network Security Group filters its traffic, whether a route table forces egress through a firewall, which Azure services it can reach over a service endpoint, and whether it is delegated to a managed service like App Service, Container Apps, or Azure Database for PostgreSQL Flexible Server.
On azurerm 4.x the subnet resource carries a surprising amount of behavioural surface for such a small object. Getting it wrong is expensive: a subnet that is too small can never be resized without recreating every resource attached to it, a missing private_endpoint_network_policies setting silently blocks private endpoints, and an inline delegation block that doesn’t match the consumer service makes the dependent resource fail to deploy. Wrapping azurerm_subnet in a reusable module gives every team the same vetted defaults — correctly-sized prefixes, the right network-policy flags, and a separate association resource for NSGs and route tables — so the platform team controls how address space is consumed without hand-writing the same forty lines in every workload repo.
A critical azurerm 4.x detail this module respects: NSG and route-table links are modelled as their own resources (azurerm_subnet_network_security_group_association and azurerm_subnet_route_table_association) rather than inline arguments on the subnet. Mixing inline links with the association resources causes Terraform to fight itself on every plan, so the module exposes association via dedicated, optional resources only.
When to use it
- You run a hub-and-spoke or Azure Landing Zone topology and every spoke VNet needs the same subnet shapes (workload, private-endpoints, integration/delegation) carved consistently.
- You need subnets delegated to managed services — App Service Plan VNet integration, Container Apps environments, API Management, or PostgreSQL/MySQL Flexible Server — and want the delegation block correct every time.
- You want NSG and route-table associations applied uniformly so no subnet accidentally ships without egress control or default-route forcing to a firewall.
- You are standing up private-endpoint subnets and must control
private_endpoint_network_policies/private_link_service_network_policiesper workload. - You are enabling service endpoints (Storage, SQL, Key Vault) to keep PaaS traffic on the Microsoft backbone instead of the public internet.
Skip the module for a one-off lab VNet where a single inline subnet on azurerm_virtual_network is simpler — the abstraction earns its keep at fleet scale, not for a throwaway.
Module structure
terraform-module-azure-subnet/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_subnet + NSG/route-table associations
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, name, address prefixes, association ids
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_subnet" "this" {
name = var.name
resource_group_name = var.resource_group_name
virtual_network_name = var.virtual_network_name
address_prefixes = var.address_prefixes
# Service endpoints keep PaaS traffic (Storage, SQL, KeyVault) on the
# Microsoft backbone instead of routing over the public internet.
service_endpoints = var.service_endpoints
service_endpoint_policy_ids = length(var.service_endpoint_policy_ids) > 0 ? var.service_endpoint_policy_ids : null
# In azurerm 4.x these default to "Disabled". Private endpoints require
# the *_endpoint_network_policies to be "Disabled" (or RouteTableEnabled).
private_endpoint_network_policies = var.private_endpoint_network_policies
private_link_service_network_policies_enabled = var.private_link_service_network_policies_enabled
# Optional delegation to a managed service (App Service, Container Apps,
# PostgreSQL Flexible Server, API Management, etc.). At most one allowed.
dynamic "delegation" {
for_each = var.delegation != null ? [var.delegation] : []
content {
name = delegation.value.name
service_delegation {
name = delegation.value.service_name
actions = delegation.value.actions
}
}
}
}
# NSG association is a separate resource in azurerm 4.x — never inline on the
# subnet, or every plan will show perpetual drift.
resource "azurerm_subnet_network_security_group_association" "this" {
count = var.network_security_group_id != null ? 1 : 0
subnet_id = azurerm_subnet.this.id
network_security_group_id = var.network_security_group_id
}
# Route-table association — forces egress through a firewall / NVA default route.
resource "azurerm_subnet_route_table_association" "this" {
count = var.route_table_id != null ? 1 : 0
subnet_id = azurerm_subnet.this.id
route_table_id = var.route_table_id
}
variables.tf
variable "name" {
description = "Name of the subnet."
type = string
validation {
condition = length(var.name) >= 1 && length(var.name) <= 80
error_message = "Subnet name must be between 1 and 80 characters."
}
}
variable "resource_group_name" {
description = "Resource group containing the parent virtual network."
type = string
}
variable "virtual_network_name" {
description = "Name of the parent virtual network."
type = string
}
variable "address_prefixes" {
description = "List of CIDR address prefixes for the subnet (e.g. [\"10.10.1.0/24\"])."
type = list(string)
validation {
condition = length(var.address_prefixes) > 0
error_message = "At least one address prefix must be supplied."
}
validation {
condition = alltrue([for p in var.address_prefixes : can(cidrhost(p, 0))])
error_message = "Every address_prefixes entry must be a valid CIDR block."
}
}
variable "service_endpoints" {
description = "Service endpoints to enable (e.g. [\"Microsoft.Storage\", \"Microsoft.Sql\", \"Microsoft.KeyVault\"])."
type = list(string)
default = []
validation {
condition = alltrue([
for e in var.service_endpoints :
can(regex("^Microsoft\\.[A-Za-z]+$", e))
])
error_message = "Service endpoints must look like 'Microsoft.<Service>' (e.g. Microsoft.Storage)."
}
}
variable "service_endpoint_policy_ids" {
description = "Service Endpoint Policy resource IDs to associate with the subnet."
type = list(string)
default = []
}
variable "private_endpoint_network_policies" {
description = "Private endpoint network policies. Use 'Disabled' on private-endpoint subnets."
type = string
default = "Disabled"
validation {
condition = contains(["Disabled", "Enabled", "NetworkSecurityGroupEnabled", "RouteTableEnabled"], var.private_endpoint_network_policies)
error_message = "Must be one of: Disabled, Enabled, NetworkSecurityGroupEnabled, RouteTableEnabled."
}
}
variable "private_link_service_network_policies_enabled" {
description = "Whether private link service network policies are enabled. Set false on subnets hosting a Private Link Service."
type = bool
default = true
}
variable "network_security_group_id" {
description = "Resource ID of an NSG to associate with the subnet. Null to skip."
type = string
default = null
}
variable "route_table_id" {
description = "Resource ID of a route table to associate with the subnet. Null to skip."
type = string
default = null
}
variable "delegation" {
description = <<-EOT
Optional subnet delegation to a managed service. Example:
{
name = "appservice"
service_name = "Microsoft.Web/serverFarms"
actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
}
EOT
type = object({
name = string
service_name = string
actions = list(string)
})
default = null
}
outputs.tf
output "id" {
description = "Resource ID of the subnet."
value = azurerm_subnet.this.id
}
output "name" {
description = "Name of the subnet."
value = azurerm_subnet.this.name
}
output "address_prefixes" {
description = "Address prefixes assigned to the subnet."
value = azurerm_subnet.this.address_prefixes
}
output "network_security_group_association_id" {
description = "ID of the NSG association, or null if none was created."
value = try(azurerm_subnet_network_security_group_association.this[0].id, null)
}
output "route_table_association_id" {
description = "ID of the route-table association, or null if none was created."
value = try(azurerm_subnet_route_table_association.this[0].id, null)
}
How to use it
# A private-endpoint subnet with an NSG and a forced default route to the hub firewall.
module "pe_subnet" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-subnet?ref=v1.0.0"
name = "snet-prod-privatelink"
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.spoke.name
address_prefixes = ["10.10.4.0/24"]
service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"]
private_endpoint_network_policies = "Disabled" # required for private endpoints
network_security_group_id = azurerm_network_security_group.workload.id
route_table_id = azurerm_route_table.to_firewall.id
}
# A second subnet delegated to App Service for regional VNet integration.
module "integration_subnet" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-subnet?ref=v1.0.0"
name = "snet-prod-appsvc-integration"
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.spoke.name
address_prefixes = ["10.10.5.0/27"]
delegation = {
name = "appservice"
service_name = "Microsoft.Web/serverFarms"
actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
}
}
# Downstream: drop a private endpoint into the subnet using the module's id output.
resource "azurerm_private_endpoint" "blob" {
name = "pe-prodstorage-blob"
location = azurerm_resource_group.network.location
resource_group_name = azurerm_resource_group.network.name
subnet_id = module.pe_subnet.id
private_service_connection {
name = "psc-blob"
private_connection_resource_id = azurerm_storage_account.prod.id
subresource_names = ["blob"]
is_manual_connection = false
}
}
# Downstream: wire the delegated subnet into App Service VNet integration.
resource "azurerm_app_service_virtual_network_swift_connection" "this" {
app_service_id = azurerm_linux_web_app.prod.id
subnet_id = module.integration_subnet.id
}
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/subnet/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-subnet?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
virtual_network_name = "..."
address_prefixes = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/subnet && 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 subnet (1–80 chars). |
resource_group_name |
string |
— | Yes | Resource group containing the parent VNet. |
virtual_network_name |
string |
— | Yes | Name of the parent virtual network. |
address_prefixes |
list(string) |
— | Yes | CIDR prefixes for the subnet; each is CIDR-validated. |
service_endpoints |
list(string) |
[] |
No | Service endpoints to enable (e.g. Microsoft.Storage). |
service_endpoint_policy_ids |
list(string) |
[] |
No | Service Endpoint Policy IDs to associate. |
private_endpoint_network_policies |
string |
"Disabled" |
No | One of Disabled, Enabled, NetworkSecurityGroupEnabled, RouteTableEnabled. |
private_link_service_network_policies_enabled |
bool |
true |
No | Set false on subnets hosting a Private Link Service. |
network_security_group_id |
string |
null |
No | NSG resource ID to associate; null skips association. |
route_table_id |
string |
null |
No | Route table resource ID to associate; null skips association. |
delegation |
object |
null |
No | Optional service delegation (name, service_name, actions). |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the subnet (use for private endpoints, NICs, VNet integration). |
name |
Name of the subnet. |
address_prefixes |
Address prefixes assigned to the subnet. |
network_security_group_association_id |
ID of the NSG association, or null if none created. |
route_table_association_id |
ID of the route-table association, or null if none created. |
Enterprise scenario
A retail platform team runs an Azure Landing Zone with one spoke VNet per environment. Each spoke needs three fixed subnet shapes — a /24 workload subnet (NSG + forced default route to the hub Azure Firewall), a /24 private-endpoint subnet (private_endpoint_network_policies = "Disabled", Storage and Key Vault service endpoints), and a /27 integration subnet delegated to Microsoft.Web/serverFarms for App Service regional VNet integration. By calling this module three times per spoke from a stack module, the platform team guarantees every environment carves identical address space, every workload subnet ships with egress control, and no private-endpoint subnet is ever deployed with network policies left enabled — a mistake that previously broke private DNS resolution in two production incidents.
Best practices
- Right-size prefixes up front. A subnet’s
address_prefixescannot be changed once resources are attached without recreating them. Reserve a/24for general workloads and a/27–/26for delegated/integration subnets (App Service VNet integration consumes addresses fast); leave headroom rather than packing ranges tightly. - Always set
private_endpoint_network_policies = "Disabled"on private-endpoint subnets. Leaving itEnabledsilently blocks private endpoints and is a top cause of “private DNS works but connectivity fails” tickets. Conversely, setprivate_link_service_network_policies_enabled = falseonly on subnets that host a Private Link Service. - Associate NSGs and route tables through the dedicated resources, never inline. This module uses
azurerm_subnet_network_security_group_associationandazurerm_subnet_route_table_associationso plans stay stable; mixing inline links with these resources causes perpetual drift onazurerm4.x. - One delegation per subnet, and match the service exactly. A delegated subnet can host only its delegated service — e.g. a
Microsoft.Web/serverFarmssubnet can’t also run a Container Apps environment. Keep delegated subnets single-purpose and name them after the service (snet-prod-appsvc-integration). - Prefer service endpoints (or private endpoints) over public PaaS access. Enabling
Microsoft.Storage/Microsoft.Sql/Microsoft.KeyVaultendpoints keeps traffic on the Microsoft backbone and lets you scope PaaS firewalls to the subnet — cheaper and tighter than opening public IP ranges. - Adopt a consistent naming convention (
snet-<env>-<purpose>, e.g.snet-prod-privatelink) and keep it in thenameinput so subnets are self-describing across every spoke; this pays off enormously when reviewing NSG flow logs and route tables across a fleet.