IaC Azure

Terraform Module: Azure Subnet — Consistent address-space carving with service delegation and NSG/route-table binding

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

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

TerraformAzureSubnetModuleIaC
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