SubnetWithNsg: How Azure Policy Deny Forced Me to Use azapi

SubnetWithNsg: How Azure Policy Deny Forced Me to Use azapi

Technical revision — 23 October 2026. The original publication date is unchanged. Historical implementation examples are distinguished below from current, versioned documentation. The original deployment lockfiles are not available with this article, so its exact executed versions are not asserted. These excerpts are not a newly tested Azure deployment. Architecture diagrams, address ranges, resource names and security-control examples are illustrative; they do not describe an identifiable organization’s infrastructure.

The Problem

In this fictional example, assume an assigned policy denies applicable subnet requests without a Network Security Group. The effect depends on policy assignments, parameters, exemptions and service requirements; this is not a rule shared by every landing zone.

The historical two-resource approach attempted to create the subnet first, then associate the NSG. This partial example assumes the resource group, VNet and NSG are defined elsewhere:

resource "azurerm_subnet" "this" {
  name                 = "snet-demo-nodes"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = ["172.20.16.0/24"]
}

resource "azurerm_subnet_network_security_group_association" "this" {
  subnet_id                 = azurerm_subnet.this.id
  network_security_group_id = azurerm_network_security_group.this.id
}

Azure Policy does not wait for Terraform to finish its dependency graph. For a Resource Manager-mode Deny effect, a non-compliant request is rejected before it reaches the resource provider:

1. Request subnet creation without an NSG reference.
2. Applicable Deny policy rejects the request with HTTP 403.
3. The subnet is not created; the subsequent association cannot run.

The later association cannot repair a creation that was denied. A representative error is:

Example policy error

Error: creating Subnet: performing CreateOrUpdate: unexpected status 403 with error: RequestDisallowedByPolicy: Resource was disallowed by policy. Policy: "Subnets should have a Network Security Group"

AzureRM: Historical Limitation and the Version 5 Alternative

The original publication on 5 April 2026 predates AzureRM 5. The old azurerm_subnet.network_security_group_id argument was removed in AzureRM 2.0.0, on 24 February 2020, not an nsg_id argument in v3.

AzureRM 5.0.0, released on 27 July 2026, introduced network_security_group_id_wo and network_security_group_id_wo_version, plus route_table_id_wo and route_table_id_wo_version. The v5.10.0 subnet documentation specifically targets policy requirements during subnet creation or update; it recommends separate association resources outside that need.

This documentation-based excerpt is not an executed deployment. It assumes an existing VNet and NSG and the corresponding input declarations:

# AzureRM 5.10.0 schema; Terraform >= 1.11 for write-only arguments.
# Check provider and consumed-module constraints separately.
resource "azurerm_subnet" "example" {
  name                 = "snet-example"
  resource_group_name  = var.resource_group_name
  virtual_network_name = var.virtual_network_name
  address_prefixes     = ["172.20.16.0/24"]

  network_security_group_id_wo         = var.nsg_id
  network_security_group_id_wo_version = 1
}

Terraform write-only arguments require 1.11 or later. Increment the version counter when changing the write-only value. This mechanism alone does not establish complete drift reconciliation. If a route table is also required at creation, review the corresponding write-only pair. Avoid assigning two concurrent Terraform owners to the same association.

AzAPI remains a valid approach. The public SubnetWithNsg version constraints are Terraform >= 1.12.0, AzureRM ~> 4.0 and AzAPI ~> 2.4. Those constraints are not exact historical lockfile versions. Upgrading that module to AzureRM 5 or migrating existing state is a separate compatibility exercise.

The azapi Solution

The historical AzAPI implementation sends one subnet create/update request containing the existing NSG reference, with any route table and delegation properties. The ARM subnet API 2025-03-01 documents these fields. The following reduced excerpt uses the legacy single-delegation input:

Policy can evaluate the NSG reference in the request before creation. Other applicable policies can still deny it.

resource "azapi_resource" "subnet" {
  for_each = { for s in var.subnets : s.name => s }

  type      = "Microsoft.Network/virtualNetworks/subnets@2025-03-01"
  name      = each.value.name
  parent_id = var.virtual_network_id

  body = {
    properties = {
      addressPrefixes = each.value.address_prefixes
      networkSecurityGroup = each.value.nsg_id != null ? {
        id = each.value.nsg_id
      } : null
      routeTable = each.value.route_table_id != null ? {
        id = each.value.route_table_id
      } : null
      defaultOutboundAccess = each.value.default_outbound_access_enabled
      delegations = each.value.delegation != null ? [
        {
          name = each.value.delegation.name
          properties = {
            serviceName = each.value.delegation.service_name
          }
        }
      ] : []
    }
  }
}

This is one subnet create/update request containing the NSG reference, not an atomic transaction that also creates the NSG. The NSG must already exist; dependency creation, reads and polling may require other API calls.

The Module

The SubnetWithNsg module wraps this pattern for the subnets covered by this implementation. An optional nsg_id in its interface does not override policy: callers must supply it when an applicable policy requires an NSG.

This is a reduced interface excerpt. The public revision additionally supports IPAM, NAT and multiple delegations; its legacy delegation field is retained but deprecated in favor of delegations. Review the linked module before copying this example:

variable "subnets" {
  type = list(object({
    name                            = string
    address_prefixes                = list(string)
    nsg_id                          = optional(string)
    route_table_id                  = optional(string)
    default_outbound_access_enabled = optional(bool, false)
    delegation = optional(object({
      name         = string
      service_name = string
    }))
  }))
  nullable = false

  validation {
    condition = alltrue([
      for s in var.subnets : length(s.address_prefixes) >= 1
    ])
    error_message = "Each subnet must have at least one address prefix (a CIDR block, e.g. 172.20.16.0/24)."
  }

  validation {
    condition     = length(var.subnets) == length(distinct([for s in var.subnets : s.name]))
    error_message = "Each subnet name must be unique."
  }
}

variable "virtual_network_id" {
  type     = string
  nullable = false

  validation {
    condition = can(regex(
      "^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\\.Network/virtualNetworks/[^/]+$",
      var.virtual_network_id
    ))
    error_message = "virtual_network_id must be a valid Azure Virtual Network resource ID."
  }
}

The output is a map of subnet IDs keyed by name, making it easy for downstream modules to reference specific subnets:

output "subnet_ids" {
  description = "Map of subnet name => subnet ID"
  value       = { for k, s in azapi_resource.subnet : k => s.id }
}

Terragrunt Caller

This fictional caller illustrates a sample AKS workload with a demonstration sibling-directory layout (network-demo, nsg-demo, rt-demo). It is not a snapshot of an internal deployment. Its three subnet CIDRs are disjoint and contained in the fictional Demo A VNet (172.20.0.0/16) used in the landing zone article. The earlier one-subnet examples are alternatives using the same nodes subnet, not additional subnets to deploy alongside it. Dependency outputs must already exist; complete inputs and provider configuration remain the caller’s responsibility.

# Terragrunt caller example (subnet-demo/terragrunt.hcl)
terraform {
  source = "git::https://github.com/Kumo-Craft/terraform-azurerm-landing-zone.git//modules/SubnetWithNsg?ref=658d78cfc98de1b685ae902fbf3ef02bc309255b"

  extra_arguments "sequential_subnets" {
    commands  = ["apply", "plan", "destroy"]
    arguments = ["-parallelism=1"]
  }
}

dependency "network_demo" {
  config_path = "../network-demo"
}

dependency "nsg_demo" {
  config_path = "../nsg-demo"
}

dependency "rt_demo" {
  config_path = "../rt-demo"
}

inputs = {
  virtual_network_id = dependency.network_demo.outputs.id

  subnets = [
    {
      name           = "snet-demo-nodes"
      address_prefixes = ["172.20.16.0/24"]
      nsg_id         = dependency.nsg_demo.outputs.ids["nodes"]
      route_table_id = dependency.rt_demo.outputs.id
    },
    {
      name           = "snet-demo-pods"
      address_prefixes = ["172.20.20.0/22"]
      nsg_id         = dependency.nsg_demo.outputs.ids["pods"]
      route_table_id = dependency.rt_demo.outputs.id
    },
    {
      name           = "snet-demo-apiserver"
      address_prefixes = ["172.20.24.0/28"]
      nsg_id         = dependency.nsg_demo.outputs.ids["apiserver"]
      route_table_id = dependency.rt_demo.outputs.id
      delegation = {
        name         = "aks-apiserver"
        service_name = "Microsoft.ContainerService/managedClusters"
      }
    },
  ]
}

The repository package before // preserves the module’s sibling dependencies, as documented in Terragrunt v1.2.0. The -parallelism=1 flag records a mitigation for conflicting subnet operations encountered in this context. It serializes Terraform resource operations within that invocation, not separate Terragrunt units. Measure the actual conflict before adopting it as a general constraint.

Validations

These validation rules improve diagnostics for known inputs:

  • Address prefix presence — Every subnet must declare at least one address_prefixes entry (a CIDR block). In the full module this is an OR with an IPAM pool allocation. Catches subnets defined with no address range at all.
  • Unique subnet names — The for_each key is the subnet name. Duplicate object keys normally produce an error; the explicit validation provides a clearer diagnostic. The validation compares length(var.subnets) against length(distinct(names)) to catch duplicates early.
  • VNet resource ID format — The virtual_network_id is validated against the full ARM resource ID pattern. Prevents accidentally passing a VNet name or a subnet ID instead of the parent VNet ID.

Terraform validate, documented in v1.11.0, checks configuration consistency. Use plan to check the actual input context as well. Unknown values can defer validation, and neither command guarantees that Azure will accept the deployment.

Trade-offs

Using azapi_resource instead of azurerm_subnet comes with trade-offs. Evaluate these against the provider versions and policies in your environment:

  • State representation — Terraform state shows azapi_resource.subnet instead of azurerm_subnet.this. Other modules referencing the subnet ID are unaffected (it is the same ARM resource ID), but state inspection is less intuitive.
  • Drift verification — Test an out-of-band NSG association change with refresh enabled, the selected AzAPI version and the properties actually managed. No Azure drift test was run for this editorial revision; there is no general claim that AzAPI cannot detect it.
  • Import syntax — If you need to import existing subnets, you use azapi import syntax (terraform import 'azapi_resource.subnet["subnet-name"]' /subscriptions/.../subnets/subnet-name) instead of the azurerm equivalent.
  • Provider dependency — You need the Azure/azapi provider in addition to hashicorp/azurerm. One more provider to version-pin and update.

Both AzAPI and the AzureRM 5 write-only arguments can address the subnet creation requirement. Their state representation and lifecycle responsibilities differ.

When You Need This

First inspect the effective policy assignments and exemptions. If an applicable Deny rule requires an NSG at creation, choose an implementation that includes its reference in the subnet request. The historical AzAPI approach remains useful with the linked AzureRM 4-constrained module; AzureRM 5 offers the documented alternative above.

Without that creation-time requirement, the AzureRM documentation recommends the separate association resource. Audit and Deny serve different governance goals; this article does not prescribe one effect for every production environment. Do not remove policy controls merely to accommodate an example.

The related public SubnetWithNsg implementation in Kumo-Craft follows the same subnet-with-NSG pattern. Its base inputs match this example, but the public module has additional features and sibling-module dependencies. The linked revision is not a snapshot of an internal deployment; review its inputs and version requirements before reuse.

Related Posts

Azure Landing Zone with Palo Alto NVA: Lessons Learned

Azure Landing Zone with Palo Alto NVA: Lessons Learned

Deploying Palo Alto VM-Series as a Network Virtual Appliance in an Azure Landing Zone sounds straightforward — until you actually do it. This article covers the hard-won lessons from building the fire

read more
Azure Virtual Desktop: from POC to prod, the hardening checklist

Azure Virtual Desktop: from POC to prod, the hardening checklist

TL;DR. A fully private AVD POC that works is not a prod. Between the two: harden the FSLogix storage (NTFS ACLs, backup), automate secret rotation, wire up observability (AVD Insights + dedicate

read more
Deploying Azure Virtual Desktop End-to-End in a Landing Zone

Deploying Azure Virtual Desktop End-to-End in a Landing Zone

TL;DR. This is the deployment walkthrough for a fully private, Entra-only Azure Virtual Desktop environment inside a regulated Azure Landing Zone. Pooled Windows 11 multi-session on a **cust

read more
Azure Virtual Desktop in a regulated Landing Zone: the traps no doc tells you about

Azure Virtual Desktop in a regulated Landing Zone: the traps no doc tells you about

TL;DR. Deploying Azure Virtual Desktop fully private in a Landing Zone (Private Endpoints everywhere, Palo Alto NVA for egress, centralised DNS) works very well — once you know about a dozen

read more
The AVM ALZ accelerator is solid. Here's what we add on top (and what we'd do differently)

The AVM ALZ accelerator is solid. Here's what we add on top (and what we'd do differently)

TL;DR. The official Azure/avm-ptn-alz/azurerm module covers the essentials of an Azure Landing Zone correctly. Three things aren't in the box and cost us time: explicit pinning of the alz/`a

read more
Building 60+ Terraform Modules for an Azure Landing Zone

Building 60+ Terraform Modules for an Azure Landing Zone

Technical revision — 23 October 2026. The original publication date is unchanged. Historical implementation examples are distinguished below from current, versioned documentation. The original d

read more
The First Apply Is Local (and That's Fine)

The First Apply Is Local (and That's Fine)

TL;DR. The deployment chain that applies your IaC is itself infrastructure — and something has to create it first. Instead of hand-building the pipeline chain before the code could run, we appli

read more
The Second Landing Zone Is the Real Test of the First

The Second Landing Zone Is the Real Test of the First

TL;DR. We stamped out a second application landing zone from the first one. The diff between the two repos is six values: subscription, name acronym, address space, project, service connecti

read more
Running a 79-Module Terraform Library Like a Product

Running a 79-Module Terraform Library Like a Product

TL;DR. A shared Terraform module library has two possible futures: product or landfill. Ours holds 79 modules consumed live from main by four landing zones — no pinning. The only reason th

read more
When terraform plan says 'no changes' and the feature is broken anyway

When terraform plan says 'no changes' and the feature is broken anyway

Everything was green. Workspace deployed, host pool up, users assigned, RBAC clean. Then a user opens Windows App and hits: "No devices or apps found." The feed never loads. Nothing in the Azure

read more
Why I Don't Use AVM Modules As-Is (And What I Do Instead)

Why I Don't Use AVM Modules As-Is (And What I Do Instead)

Technical revision — 23 October 2026. The original publication date is unchanged. Historical implementation examples are distinguished below from current, versioned documentation. The original d

read more