Troubleshooting Azure Terraform - Error 400 When Destroying NSG Rules

Fix Terraform Error 400 when destroying Azure NSG rules on AzureBastionSubnet. Step-by-step guide to solve race condition issues with embedded dynamic blocks.


Introduction

I decided to resume working on my Azure Hub & Spoke Terraform lab. Last time I tested it, the environment built and destroyed successfully. However, this time I encountered Terraform Error 400 while destroying NSG rules attached to AzureBastionSubnet in my Azure infrastructure:

Terraform Error 400 destroying Azure NSG rules on AzureBastionSubnet

TL;DR - Quick Solution

Problem: Terraform Error 400 when running terraform destroy - NSG rules fail to delete on AzureBastionSubnet due to race conditions.

Solution: Embed Bastion NSG rules using dynamic blocks instead of separate azurerm_network_security_rule resources. This ensures atomic deletion and prevents dependency conflicts.

Time to implement: 15-20 minutes | Difficulty: Intermediate

📋 Table of Contents

Understanding Terraform Error 400: Root Cause Analysis

I cannot know for sure if Azure API recently added constraints disallowing NSG rule deletions when they are required by AzureBastionSubnet (see Azure Bastion NSG requirements), or I got lucky with Race Conditions last time. The command terraform destroy deletes resources in parallel, which can cause Race Condition errors if pending deletions are not allowing related resources to be deleted.

↑ Back to top

Identifying the NSG Rule Dependency Problem

Let's look at how my infrastructure is defined. I identified the resources involved in the error:

ResourceProblem
Subnet AzureBastionSubnetSlow delete, NSG Rule dependencies
NSG Rules for AzureBastionSubnetdestroy failed, dependencies

The error is narrowed down, and I can start looking at my Terraform IaC files and figure out how to solve the problem.

Here are the Terraform files we are going to troubleshoot:

FileExplanation
main.tfRoot Module passing nsg_ids to ./modules/net
nsg.tfCreating NSGs and NSG Rules
./modules/net/main.tfCreating Network, Subnets and attaching NSGs

nsg.tf

locals {
  # Calculate subnet prefixes locally to avoid circular dependency
  # This mirrors the calculation done in the network module
  hub_subnet_prefixes = {
    for key, config in local.hub_vnet.subnets : key => cidrsubnet(
      local.hub_vnet.address_space[0],
      config.cidr_newbits,
      config.cidr_netnum
    )
  }
 
  # NSG rule definitions
  nsg_rules = {
    # Rules for Azure Bastion Subnet (required by Azure)
    bastion_subnet = [
      {
        name                       = "AllowHttpsInbound"
        priority                   = 120
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "Internet"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowGatewayManagerInbound"
        priority                   = 130
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "GatewayManager"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowAzureLoadBalancerInbound"
        priority                   = 140
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "AzureLoadBalancer"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowBastionHostCommunication"
        priority                   = 150
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "VirtualNetwork"
        source_port_range          = "*"
        destination_address_prefix = "VirtualNetwork"
        destination_port_ranges    = ["8080", "5701"]
      },
      {
        name                       = "AllowSshRdpOutbound"
        priority                   = 100
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "VirtualNetwork"
        destination_port_ranges    = ["22", "3389"]
      },
      {
        name                       = "AllowAzureCloudOutbound"
        priority                   = 110
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "AzureCloud"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowBastionCommunication"
        priority                   = 120
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "VirtualNetwork"
        source_port_range          = "*"
        destination_address_prefix = "VirtualNetwork"
        destination_port_ranges    = ["8080", "5701"]
      },
      {
        name                       = "AllowGetSessionInformation"
        priority                   = 130
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "Internet"
        destination_port_range     = "80"
      }
    ]
 
    # Rules for Hub default subnet
    hub_default = [
      {
        name                       = "AllowBastionInbound"
        priority                   = 100
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = local.hub_subnet_prefixes["bastion_subnet"]
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_ranges    = ["22", "3389"]
      },
      {
        name                       = "AllowVnetInbound"
        priority                   = 200
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "VirtualNetwork"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "*"
      },
      {
        name                       = "AllowInternetOutbound"
        priority                   = 100
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "Internet"
        destination_port_range     = "*"
      }
    ]
  }
}
 
 
# Creating a resource group for the NSGs
resource "azurerm_resource_group" "nsg" {
  name     = "${var.prefix}-${var.project_name}-rg-nsg-${var.environment}"
  location = azurerm_resource_group.tflab_linux.location // For implicit dependency to main RG
 
  tags = local.common_tags
}
 
# Create Network Security Groups
resource "azurerm_network_security_group" "nsgs" {
  for_each = local.nsg_rules
 
  name                = "${var.prefix}-${var.project_name}-nsg-${each.key}-${var.environment}"
  location            = azurerm_resource_group.nsg.location
  resource_group_name = azurerm_resource_group.nsg.name
 
  tags = local.common_tags
 
  # Prevent deletion while Bastion depends on it
  lifecycle {
    create_before_destroy = true
  }
}
 
# Create NSG rules
resource "azurerm_network_security_rule" "nsg_rules" {
  for_each = {
    for rule in flatten([
      for nsg_key, rules in local.nsg_rules : [
        for rule in rules : {
          key                        = "${nsg_key}-${rule.name}"
          nsg_name                   = azurerm_network_security_group.nsgs[nsg_key].name
          resource_group_name        = azurerm_resource_group.nsg.name
          name                       = rule.name
          priority                   = rule.priority
          direction                  = rule.direction
          access                     = rule.access
          protocol                   = rule.protocol
          source_port_range          = lookup(rule, "source_port_range", "*")
          destination_port_range     = lookup(rule, "destination_port_range", null)
          destination_port_ranges    = lookup(rule, "destination_port_ranges", null)
          source_address_prefix      = lookup(rule, "source_address_prefix", null)
          destination_address_prefix = lookup(rule, "destination_address_prefix", "*")
        }
      ]
    ]) : rule.key => rule
  }
 
  network_security_group_name = each.value.nsg_name
  resource_group_name         = each.value.resource_group_name
  name                        = each.value.name
  priority                    = each.value.priority
  direction                   = each.value.direction
  access                      = each.value.access
  protocol                    = each.value.protocol
  source_port_range           = each.value.source_port_range
  destination_port_range      = each.value.destination_port_range
  destination_port_ranges     = each.value.destination_port_ranges
  source_address_prefix       = each.value.source_address_prefix
  destination_address_prefix  = each.value.destination_address_prefix
 
  # Prevent deletion of Bastion NSG rules while Bastion exists
  lifecycle {
    create_before_destroy = true
  }
}

main.tf - Relevant Code

Irrelevant code is omitted for conciseness and readability

# Building the network
module "hub_network" {
  source              = "./modules/net/"
  resource_group_name = azurerm_resource_group.tflab_linux.name
  location            = azurerm_resource_group.tflab_linux.location
  vnet_config         = local.hub_vnet
  nsg_ids             = { for k, v in azurerm_network_security_group.nsgs : k => v.id }
 
  tags = local.common_tags
 
  depends_on = [
    azurerm_resource_group_policy_assignment.allowed_locations,
    azurerm_resource_group_policy_assignment.require_environment_tag,
  ]
}

./modules/net/main.tf

# Description: This Terraform configuration creates an Azure Virtual Network
# with subnets and associates NSGs based on configuration
 
locals {
 
  # Define subnets with dynamic address prefixes based on VNet address space
  subnet_config = var.vnet_config.subnets
 
  # Generate subnet configurations
  subnets = {
    for key, config in local.subnet_config : key => {
      name   = config.name == "bastion" ? "AzureBastionSubnet" : "${var.vnet_config.name}-subnet-${config.name}"
      prefix = cidrsubnet(var.vnet_config.address_space[0], config.cidr_newbits, config.cidr_netnum)
    }
  }
 
  # Helper map for NSG associations - keys are static (subnet keys), values reference NSG IDs
  # Only include subnets where nsg_key is explicitly set (not null)
  subnets_with_nsg = {
    for key, config in local.subnet_config : key => config.nsg_key
    if config.nsg_key != null
  }
}
 
#Build the VNet and subnets
resource "azurerm_virtual_network" "this" {
  name                = var.vnet_config.name
  resource_group_name = var.resource_group_name
  location            = var.location
  address_space       = var.vnet_config.address_space
 
  tags = var.tags
}
 
resource "azurerm_subnet" "this" {
  for_each = local.subnets
 
  name                 = each.value.name
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [each.value.prefix]
}
 
# Associate NSGs with subnets based on nsg_key mapping
resource "azurerm_subnet_network_security_group_association" "nsg_associations" {
  for_each = local.subnets_with_nsg
 
  subnet_id                 = azurerm_subnet.this[each.key].id
  network_security_group_id = var.nsg_ids[each.value]
}

↑ Back to top

Can we solve it using Dependencies?

During creation using terraform apply, dependencies are really important to make sure resources are created in the right order. Most of the time, implicit dependencies using variables referring to other resources get the job done. Sometimes we need to define explicit dependencies if we have to make sure modules we cannot use variable from are created before the resources, for example Azure Polices.

The same applies to terraform destroy. Let's try to review and modify our dependencies.

Let's try adding NSGs and NSG Rules dependencies to the hub_network module:

Adding Dependencies

main.tf

# Building the network
module "hub_network" {
  source              = "./modules/net/"
  resource_group_name = azurerm_resource_group.tflab_linux.name
  location            = azurerm_resource_group.tflab_linux.location
  vnet_config         = local.hub_vnet
  nsg_ids             = { for k, v in azurerm_network_security_group.nsgs : k => v.id }
 
  tags = local.common_tags
 
  depends_on = [
    azurerm_resource_group_policy_assignment.allowed_locations,
    azurerm_resource_group_policy_assignment.require_environment_tag,
    azurerm_network_security_group.nsgs,
    azurerm_network_security_rule.nsg_rules,
  ]
}

Testing the changes

Build the Infrastructure

terraform apply --auto-approve

It completed successfully after 20 minutes. Azure Bastion creation is time consuming.

Destroy the Infrastructure

terraform destroy --auto-approve

It completed successfully after 15 minutes. However, the verbose output from the command looks a bit like race conditions between the bastion subnet and the NSG rules:

Terraform race condition output for Azure Bastion NSG rule deletion

↑ Back to top

Should we refactor?

After 2 successful test runs, creating and destroying worked fine, but I am not fully comfortable with what I saw in the image above.

A possible refactor is to split the NSG creation so creating the AzureBastionSubnet related NSG and NSG rules are separated from the rest. Creating the rules embedded in the NSG for AzureBastionSubnet is going to make the dependency chain a lot easier to manage.

Should we do it? It depends!

In most cases, going against Best Practice should be avoided. For short lived test environments, refactoring would be beneficial. We want as much automation as possible.

↑ Back to top

Workarounds if refactoring is not a good option

We can split the destruction in multiple parts to make sure all Bastion related infrastructure is gone before the rest is taken down. It can be done this way:

  1. Run this command for the Bastion Host and AzureBastionSubnet:
terraform destroy -target="module.submodule.resource_type.resource_name"
  1. Destroy the rest of the environment:
terraform destroy --auto-approve

↑ Back to top

Refactoring NSG Rules with Dynamic Blocks

We are going to make some changes to the Terraform code using Terraform dynamic blocks to make sure creation and destruction stay automated. Let's break down the algorithm before we start.

  1. The locals in nsg.tf are staying. We want to keep our inputs readable and maintainable.
  2. The bastion_nsg Network Security Group need embedded NSG Rules because of AzureBastionSubnet Azure API constraints.
  3. To accomplish this while keeping the Best Practice setup for the rest of the NSGs, nsg.tf has to return one string and one map of objects. These are bastion_nsg and nsgs.
  4. Merge bastion_nsg and nsgs into a map. Pass the merged nsg_ids variable into the net module.

Splitting the NSGs

We handle step 1 to 3 by refactoring nsg.tf. The refactored parts are highlighted:

locals {
  # Calculate subnet prefixes locally to avoid circular dependency
  # This mirrors the calculation done in the network module
  hub_subnet_prefixes = {
    for key, config in local.hub_vnet.subnets : key => cidrsubnet(
      local.hub_vnet.address_space[0],
      config.cidr_newbits,
      config.cidr_netnum
    )
  }
 
  # NSG rule definitions
  nsg_rules = {
    # Rules for Azure Bastion Subnet (required by Azure)
    bastion_subnet = [
      {
        name                       = "AllowHttpsInbound"
        priority                   = 120
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "Internet"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowGatewayManagerInbound"
        priority                   = 130
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "GatewayManager"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowAzureLoadBalancerInbound"
        priority                   = 140
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "AzureLoadBalancer"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowBastionHostCommunication"
        priority                   = 150
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "VirtualNetwork"
        source_port_range          = "*"
        destination_address_prefix = "VirtualNetwork"
        destination_port_ranges    = ["8080", "5701"]
      },
      {
        name                       = "AllowSshRdpOutbound"
        priority                   = 100
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "VirtualNetwork"
        destination_port_ranges    = ["22", "3389"]
      },
      {
        name                       = "AllowAzureCloudOutbound"
        priority                   = 110
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "AzureCloud"
        destination_port_range     = "443"
      },
      {
        name                       = "AllowBastionCommunication"
        priority                   = 120
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "VirtualNetwork"
        source_port_range          = "*"
        destination_address_prefix = "VirtualNetwork"
        destination_port_ranges    = ["8080", "5701"]
      },
      {
        name                       = "AllowGetSessionInformation"
        priority                   = 130
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "Internet"
        destination_port_range     = "80"
      }
    ]
 
    # Rules for Hub default subnet
    hub_default = [
      {
        name                       = "AllowBastionInbound"
        priority                   = 100
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_address_prefix      = local.hub_subnet_prefixes["bastion_subnet"]
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_ranges    = ["22", "3389"]
      },
      {
        name                       = "AllowVnetInbound"
        priority                   = 200
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "VirtualNetwork"
        source_port_range          = "*"
        destination_address_prefix = "*"
        destination_port_range     = "*"
      },
      {
        name                       = "AllowInternetOutbound"
        priority                   = 100
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "*"
        source_address_prefix      = "*"
        source_port_range          = "*"
        destination_address_prefix = "Internet"
        destination_port_range     = "*"
      }
    ]
  }
}
 
 
# Creating a resource group for the NSGs
resource "azurerm_resource_group" "nsg" {
  name     = "${var.prefix}-${var.project_name}-rg-nsg-${var.environment}"
  location = azurerm_resource_group.tflab_linux.location // For implicit dependency to main RG
 
  tags = local.common_tags
}
 
# Create Network Security Groups
# For Bastion subnet, rules are embedded to prevent individual rule deletion issues
resource "azurerm_network_security_group" "bastion_nsg" {
  name                = "${var.prefix}-${var.project_name}-nsg-bastion_subnet-${var.environment}"
  location            = azurerm_resource_group.nsg.location
  resource_group_name = azurerm_resource_group.nsg.name
 
  # Embed all Bastion rules directly in the NSG resource
  # This prevents Azure API errors when trying to delete individual rules
  dynamic "security_rule" {
    for_each = local.nsg_rules["bastion_subnet"]
    content {
      name                       = security_rule.value.name
      priority                   = security_rule.value.priority
      direction                  = security_rule.value.direction
      access                     = security_rule.value.access
      protocol                   = security_rule.value.protocol
      source_port_range          = lookup(security_rule.value, "source_port_range", "*")
      destination_port_range     = lookup(security_rule.value, "destination_port_range", null)
      destination_port_ranges    = lookup(security_rule.value, "destination_port_ranges", null)
      source_address_prefix      = lookup(security_rule.value, "source_address_prefix", null)
      destination_address_prefix = lookup(security_rule.value, "destination_address_prefix", "*")
    }
  }
 
  tags = local.common_tags
 
  lifecycle {
    create_before_destroy = true
  }
}
 
# Create other NSGs (excluding bastion_subnet)
resource "azurerm_network_security_group" "nsgs" {
  for_each = { for k, v in local.nsg_rules : k => v if k != "bastion_subnet" }
 
  name                = "${var.prefix}-${var.project_name}-nsg-${each.key}-${var.environment}"
  location            = azurerm_resource_group.nsg.location
  resource_group_name = azurerm_resource_group.nsg.name
 
  tags = local.common_tags
 
  lifecycle {
    create_before_destroy = true
  }
}
 
# Create NSG rules for non-Bastion NSGs only
resource "azurerm_network_security_rule" "nsg_rules" {
  for_each = {
    for rule in flatten([
      for nsg_key, rules in local.nsg_rules : [
        for rule in rules : {
          key                        = "${nsg_key}-${rule.name}"
          nsg_key                    = nsg_key
          nsg_name                   = azurerm_network_security_group.nsgs[nsg_key].name
          resource_group_name        = azurerm_resource_group.nsg.name
          name                       = rule.name
          priority                   = rule.priority
          direction                  = rule.direction
          access                     = rule.access
          protocol                   = rule.protocol
          source_port_range          = lookup(rule, "source_port_range", "*")
          destination_port_range     = lookup(rule, "destination_port_range", null)
          destination_port_ranges    = lookup(rule, "destination_port_ranges", null)
          source_address_prefix      = lookup(rule, "source_address_prefix", null)
          destination_address_prefix = lookup(rule, "destination_address_prefix", "*")
        } if nsg_key != "bastion_subnet" # Exclude bastion rules
      ]
    ]) : rule.key => rule
  }
 
  network_security_group_name = each.value.nsg_name
  resource_group_name         = each.value.resource_group_name
  name                        = each.value.name
  priority                    = each.value.priority
  direction                   = each.value.direction
  access                      = each.value.access
  protocol                    = each.value.protocol
  source_port_range           = each.value.source_port_range
  destination_port_range      = each.value.destination_port_range
  destination_port_ranges     = each.value.destination_port_ranges
  source_address_prefix       = each.value.source_address_prefix
  destination_address_prefix  = each.value.destination_address_prefix
 
  lifecycle {
    create_before_destroy = true
  }
}

Merging and passing to the Network Module

The refactored nsg.tf file is now providing:

NameType
azurerm_network_security_group.nsgsmap of objects
azurerm_network_security_group.bastion_nsg.idstring

We are merging their NSG ID's into a map for passing into the module call **hub_network in the highlighted area in **main.tf**:

# Terraform configuration file for deploying a Linux VM in Azure with networking, key vault and monitoring
 
# Local variables
locals {
 
  #Reusable tags for all resources
  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    Version     = var.env_version
  }
 
  # Hub VNet configuration
  hub_vnet = {
    name          = "${var.prefix}-${var.project_name}-vnet-hub-${var.environment}"
    address_space = ["10.0.0.0/16"]
 
    subnets = {
      default = {
        name         = "default"
        cidr_newbits = 8
        cidr_netnum  = 1
        nsg_key      = "hub_default" # Reference to NSG in nsg.tf
      }
      appgw_subnet = {
        name         = "appgw"
        cidr_newbits = 8
        cidr_netnum  = 2
        nsg_key      = null # No NSG for App Gateway subnet
      }
      bastion_subnet = {
        name         = "bastion"
        cidr_newbits = 10
        cidr_netnum  = 12
        nsg_key      = "bastion_subnet" # Reference to NSG in nsg.tf
      }
    }
  }
}
 
 
# Create a single Azure resource group for simplicity
resource "azurerm_resource_group" "tflab_linux" {
  name     = "${var.prefix}-${var.project_name}-rg-${var.environment}"
  location = var.location
 
  tags = local.common_tags
}
 
 
# Building the network
module "hub_network" {
  source              = "./modules/net/"
  resource_group_name = azurerm_resource_group.tflab_linux.name
  location            = azurerm_resource_group.tflab_linux.location
  vnet_config         = local.hub_vnet
  nsg_ids = merge(
    { for k, v in azurerm_network_security_group.nsgs : k => v.id },
    { "bastion_subnet" = azurerm_network_security_group.bastion_nsg.id }
  )
 
  tags = local.common_tags
 
  depends_on = [
    azurerm_resource_group_policy_assignment.allowed_locations,
    azurerm_resource_group_policy_assignment.require_environment_tag,
    azurerm_network_security_group.nsgs, //Make sure NSGs are created before networks
    azurerm_network_security_group.bastion_nsg,
    azurerm_network_security_rule.nsg_rules,
  ]
}
 
# Hub Services (Bastion, NAT Gateway, App Gateway)
module "hub_services" {
  source = "./modules/hubserv/"
 
  resource_group_name = azurerm_resource_group.tflab_linux.name
  location            = azurerm_resource_group.tflab_linux.location
  prefix              = var.prefix
  project_name        = var.project_name
  environment         = var.environment
 
  # Subnet references from hub network module
  bastion_subnet_id = module.hub_network.subnet_ids["bastion_subnet"]
  appgw_subnet_id   = module.hub_network.subnet_ids["appgw_subnet"]
 
  # Subnets that should use NAT Gateway
  nat_gateway_subnets = {
    default = module.hub_network.subnet_ids["default"]
  }
 
  # Monitoring configuration
  admin_email = var.admin_email
 
  tags = local.common_tags
 
  depends_on = [
    azurerm_resource_group_policy_assignment.allowed_locations,
    azurerm_resource_group_policy_assignment.require_environment_tag,
  ]
}

Testing

We need to test the IaC properly after refactoring. Here are the test cases:

Test CaseSuccess Criteria
Building the Hub Environmentterraform apply - No errors
Verify the NSGs in Azure PortalAll NSGs are listed in Azure Portal
Building the Spoketerraform apply- No errors
Destroying the Spoketerraform destroy- No errors
Destroying the Hub Networkterraform destroy- No errors

Building the Hub Environment

Running terraform apply --auto-approve succeeded in building the Hub Network including my core Hub Services like Application Gateway.

Successful Terraform apply building Azure Hub network with embedded Bastion NSG rules

Test results: The Hub infrastructure built successfully! ✅

Verify the NSGs in Azure Portal

Azure Portal showing Network Security Groups for Bastion subnet after Terraform deployment

Test results: The NSG resources are verified in Azure Portal! ✅

Building the Spoke

Initialize and apply the Spoke network and resources, which are in a separate terraform state depending on data from the Hub network state:

Terraform apply output for Azure Spoke network deployment Terraform apply successfully completing Azure Spoke infrastructure build

Test results: The Spoke infrastructure built successfully! ✅

Destroying the Spoke

Terraform destroy successfully removing Azure Spoke infrastructure

Test results: The Spoke infrastructure was successfully destroyed! ✅

Destroying the Hub Network

Terraform destroy output for Azure Hub network with AzureBastionSubnet Successful Terraform destroy completion for Azure infrastructure

Test results: The Hub Network was successfully destroyed! ✅

↑ Back to top

Frequently Asked Questions

Why does Terraform Error 400 occur when destroying Azure Bastion NSG rules?

Azure requires AzureBastionSubnet to maintain specific NSG rules while the subnet exists. When Terraform deletes NSG rules in parallel before the subnet is removed, Azure API returns Error 400 blocking the deletion.

What is the difference between embedded NSG rules and separate resources?

Embedded NSG rules are defined within the azurerm_network_security_group resource using dynamic blocks. Separate rules use individual azurerm_network_security_rule resources. Embedded rules ensure atomic deletion, preventing race conditions.

Can I use depends_on to fix Terraform NSG deletion errors?

While depends_on helps establish explicit dependencies, it doesn't fully prevent race conditions during parallel deletion. Embedding rules is the more reliable solution for AzureBastionSubnet.

How long does it take to refactor NSG rules to use dynamic blocks?

Refactoring typically takes 15-20 minutes for a standard Hub & Spoke architecture. The solution requires modifying your nsg.tf and main.tf files.

Will this solution work for other Azure resources besides Bastion?

Yes. Any Azure resource with strict dependency requirements can benefit from embedded configuration instead of separate resources. However, this should be used selectively as separate resources are generally considered best practice.

↑ Back to top

Summary

Azure Bastion's strict NSG requirements can cause race conditions during terraform destroy operations. When individual NSG rules are deleted before the AzureBastionSubnet, Azure API returns Error 400, blocking automated infrastructure teardown.

The root cause is Terraform's parallel deletion combined with Azure's requirement that AzureBastionSubnet must maintain specific NSG rules while the subnet exists. Adding explicit dependencies helped but didn't fully eliminate race conditions.

The solution is to embed Bastion NSG rules directly within the NSG resource using dynamic blocks, while keeping other NSGs following best practices with separate rule resources. This approach ensures proper dependency ordering during both creation and destruction.

Key learnings:

  • Azure Bastion has strict NSG requirements that can conflict with Terraform's parallel deletion
  • Embedded NSG rules prevent individual rule deletion issues
  • Terraform's create_before_destroy lifecycle helps but doesn't solve the root cause
  • Different resource types may need different patterns based on cloud provider constraints
  • Comprehensive testing across the full infrastructure lifecycle is essential

The refactored infrastructure now successfully automates complete lifecycle operations without manual intervention or targeted destroy commands. All test cases passed: building Hub and Spoke environments, verifying resources in Azure Portal, and destroying infrastructure cleanly.

Did you find this helpful, please support me on Ko-fi.com - jihillestad