
SubnetWithNsg: How Azure Policy Deny Forced Me to Use azapi
- Jonathan Aerts
- Networking
- 05 Apr, 2026
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_prefixesentry (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_eachkey is the subnet name. Duplicate object keys normally produce an error; the explicit validation provides a clearer diagnostic. The validation compareslength(var.subnets)againstlength(distinct(names))to catch duplicates early. - VNet resource ID format — The
virtual_network_idis 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.subnetinstead ofazurerm_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
azapiimport syntax (terraform import 'azapi_resource.subnet["subnet-name"]' /subscriptions/.../subnets/subnet-name) instead of theazurermequivalent. - Provider dependency — You need the
Azure/azapiprovider in addition tohashicorp/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.










