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:
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
- 🎯 Identifying the NSG Rule Dependency Problem
- 🔗 Can we solve it using Dependencies?
- 🤔 Should we refactor?
- 🔧 Workarounds if refactoring is not a good option
- 🛠️ Refactoring NSG Rules with Dynamic Blocks
- ❓ Frequently Asked Questions
- 📝 Summary
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.
Identifying the NSG Rule Dependency Problem
Let's look at how my infrastructure is defined. I identified the resources involved in the error:
| Resource | Problem |
|---|---|
| Subnet AzureBastionSubnet | Slow delete, NSG Rule dependencies |
| NSG Rules for AzureBastionSubnet | destroy 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.
Related Terraform Config
Here are the Terraform files we are going to troubleshoot:
| File | Explanation |
|---|---|
| main.tf | Root Module passing nsg_ids to ./modules/net |
| nsg.tf | Creating NSGs and NSG Rules |
| ./modules/net/main.tf | Creating 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]
}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-approveIt completed successfully after 20 minutes. Azure Bastion creation is time consuming.
Destroy the Infrastructure
terraform destroy --auto-approveIt 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:
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.
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:
- Run this command for the Bastion Host and AzureBastionSubnet:
terraform destroy -target="module.submodule.resource_type.resource_name"- Destroy the rest of the environment:
terraform destroy --auto-approveRefactoring 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.
- The locals in nsg.tf are staying. We want to keep our inputs readable and maintainable.
- The bastion_nsg Network Security Group need embedded NSG Rules because of AzureBastionSubnet Azure API constraints.
- 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.
- 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:
| Name | Type |
|---|---|
| azurerm_network_security_group.nsgs | map of objects |
| azurerm_network_security_group.bastion_nsg.id | string |
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 Case | Success Criteria |
|---|---|
| Building the Hub Environment | terraform apply - No errors |
| Verify the NSGs in Azure Portal | All NSGs are listed in Azure Portal |
| Building the Spoke | terraform apply- No errors |
| Destroying the Spoke | terraform destroy- No errors |
| Destroying the Hub Network | terraform 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.
Test results: The Hub infrastructure built successfully! ✅
Verify the NSGs in Azure Portal
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:
Test results: The Spoke infrastructure built successfully! ✅
Destroying the Spoke
Test results: The Spoke infrastructure was successfully destroyed! ✅
Destroying the Hub Network
Test results: The Hub Network was successfully destroyed! ✅
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.
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_destroylifecycle 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