SubnetWithNsg: How Azure Policy Deny Forced Me to Use azapi
How Azure Landing Zone Deny policies block standard Terraform subnet creation, and why azapi_resource is the only reliable fix for atomic subnet + NSG deployment.
The Problem
Every production Azure Landing Zone deploys a Deny policy: “Subnets must have a Network Security Group.” It is a security best practice — no subnet should exist without an NSG controlling its traffic. Microsoft recommends Deny (not Audit) for production landing zones, and for good reason.
But this policy creates a real problem for Terraform. The standard azurerm approach creates the subnet first, then associates the NSG in a separate step:
resource "azurerm_subnet" "this" {
name = "snet-api-prod-gwc-nodes"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = ["10.238.1.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
}
This looks correct. It is correct, logically. But Azure Policy does not evaluate the final desired state — it evaluates each ARM API call individually. Here is what actually happens at the API level:
# Step 1: PUT Microsoft.Network/virtualNetworks/subnets
# -> Subnet created WITHOUT NSG
# -> Azure Policy evaluates: "Does this subnet have an NSG?" -> NO
# -> DENIED
# Step 2: Never reached
# PUT subnet/networkSecurityGroupAssociation
# -> Would have attached the NSG, but step 1 already failed
The subnet is created without an NSG. The policy evaluates. The policy denies. Terraform fails with a clear error message: RequestDisallowedByPolicy. The fix, however, is not obvious.
The Error You’ll See
Error: creating Subnet: performing CreateOrUpdate: unexpected status 403 with error: RequestDisallowedByPolicy: Resource was disallowed by policy. Policy: "Subnets should have a Network Security Group"
Why azurerm Can’t Fix This
Your first instinct might be to look for an nsg_id argument on azurerm_subnet. It does not exist. It was removed in azurerm v3 and never came back.
This is not an oversight. The azurerm provider separates the NSG association into its own resource (azurerm_subnet_network_security_group_association) by design. The provider makes two separate PUT calls to the ARM API — one for the subnet, one for the association. There is no way to make it send a single atomic request.
- No inline NSG support —
azurerm_subnethas nonetwork_security_group_idargument - Separate resource by design — the association is a distinct lifecycle object in azurerm
- Two API calls, always — the provider cannot combine them into one PUT
- No plan to change this — the HashiCorp team has confirmed this is intentional architecture
If your landing zone uses Audit instead of Deny, you will never hit this problem. The subnet gets created, the policy logs a warning, the NSG gets associated, and the warning clears. But Audit is not enough for production — it only reports non-compliance, it does not prevent it.
The azapi Solution
The azapi provider lets you send raw ARM API calls directly. Instead of two resources, you send a single PUT to Microsoft.Network/virtualNetworks/subnets with the NSG, route table, delegations, and all properties included in one request body.
The policy evaluates after the full resource is created — and it sees a subnet with an NSG attached. No denial.
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 = {
addressPrefix = each.value.address_prefix
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
}
}
] : []
}
}
}
One resource. One API call. NSG, route table, default outbound access, and delegations — all set atomically. The policy never sees a bare subnet.
Why This Works
ARM evaluates policy on the final state of the PUT request, not on intermediate steps. When the entire subnet definition — including
networkSecurityGroup.id— is in the request body, the policy sees a compliant resource and allows it through.
The Module
The SubnetWithNsg module wraps this pattern for easy consumption across the entire landing zone. Every subnet — in every spoke, in every environment — goes through this module.
The interface is a simple list of subnet objects:
variable "subnets" {
type = list(object({
name = string
address_prefix = 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 :
can(regex("^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$", s.address_prefix))
])
error_message = "Each subnet address_prefix must be a valid CIDR block (e.g. 10.0.1.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
In the landing zone, each spoke has a subnet-{workload} directory that calls the module. Here is a real example from the ApiManager workload:
# Terragrunt caller example (subnet-api/terragrunt.hcl)
terraform {
source = "${get_repo_root()}/modules/SubnetWithNsg"
extra_arguments "sequential_subnets" {
commands = ["apply", "plan", "destroy"]
arguments = ["-parallelism=1"]
}
}
dependency "network_api" {
config_path = "../network-api"
mock_outputs = {
id = "/subscriptions/00000000/resourceGroups/mock/providers/Microsoft.Network/virtualNetworks/mock-vnet"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
dependency "nsg_api" {
config_path = "../nsg-api"
mock_outputs = {
ids = {
"nodes" = "/subscriptions/.../networkSecurityGroups/mock-nsg-nodes"
"pods" = "/subscriptions/.../networkSecurityGroups/mock-nsg-pods"
"apiserver" = "/subscriptions/.../networkSecurityGroups/mock-nsg-apiserver"
}
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
inputs = {
virtual_network_id = dependency.network_api.outputs.id
subnets = [
{
name = "snet-api-prod-gwc-nodes"
address_prefix = "10.238.1.0/24"
nsg_id = dependency.nsg_api.outputs.ids["nodes"]
route_table_id = dependency.rt_api.outputs.id
},
{
name = "snet-api-prod-gwc-pods"
address_prefix = "10.238.2.0/22"
nsg_id = dependency.nsg_api.outputs.ids["pods"]
route_table_id = dependency.rt_api.outputs.id
},
{
name = "snet-api-prod-gwc-apiserver"
address_prefix = "10.238.6.0/28"
nsg_id = dependency.nsg_api.outputs.ids["apiserver"]
route_table_id = dependency.rt_api.outputs.id
delegation = {
name = "aks-apiserver"
service_name = "Microsoft.ContainerService/managedClusters"
}
},
]
}
Note the -parallelism=1 flag. Azure does not allow parallel subnet operations on the same VNet — the ARM API returns a conflict error if two subnet PUTs overlap. Terragrunt’s extra_arguments block forces sequential execution.
Validations
The module includes three validation rules that catch common mistakes before Terraform even reaches the plan stage:
- CIDR format validation — Every
address_prefixis validated with a regex to ensure it matches thex.x.x.x/ypattern. Catches typos like missing the prefix length or using colons instead of dots. - Unique subnet names — The
for_eachkey is the subnet name. Duplicate names would silently drop subnets. 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.
These validations run during terraform validate and terragrunt validate, which means CI/CD pipelines catch errors before any API call is made.
Trade-offs
Using azapi_resource instead of azurerm_subnet comes with trade-offs. They are worth it, but you should know what you are signing up for:
- 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. - No automatic drift detection on sub-properties —
azapitracks the full body, but if someone manually changes only the NSG association outside of Terraform, drift detection depends on howazapihandles partial responses. - 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.
But the fundamental trade-off is simple: it works with Azure Policy Deny. The standard approach does not. Everything else is manageable.
When You Need This
You need this module — or something like it — in exactly one scenario:
- Your landing zone enforces “Subnets must have NSG” with Deny effect. This is the Microsoft-recommended configuration for production landing zones. If your ALZ uses Deny, standard
azurerm_subnetwill fail on every subnet creation.
You do not need this if:
- Your policy uses Audit instead of Deny — the standard two-step approach works fine, and you get a non-compliance warning that clears once the NSG is attached.
- You create subnets outside of Terraform (ClickOps, ARM templates, Bicep) — ARM templates and Bicep already send a single PUT with all properties.
- You create subnets before the policy is assigned — possible during initial bootstrapping, but not sustainable.
If you are building an Azure Landing Zone with Terraform and your security team insists on Deny policies (they should), you will hit this problem. The azapi provider is the clean solution.