DEV Community

Cover image for Surviving Azure Policies: Zero-Trust Hub & Spoke with Terraform
david
david

Posted on • Originally published at woitzik.dev

Surviving Azure Policies: Zero-Trust Hub & Spoke with Terraform

Your Terraform pipeline is green. The deployment completes. You grab a coffee.

Ten minutes later, Azure Policy has silently rewritten three of your resources. You run terraform plan. It detects drift. It tries to revert. Policy blocks the revert with a cryptic permission error. Your pipeline is now permanently broken — and nobody touched the code.

This is Tuesday in an enterprise Azure tenant.

The DINE Death Loop

DeployIfNotExists policies run continuously in the background. They inject tags like CreatedByPolicy=True or hidden-title into your resources for compliance tracking.

Terraform sees these injected tags as drift. It plans to delete them. Azure Policy blocks the deletion. Your pipeline fails. This repeats on every run. Forever.

The fix is surgical — tell Terraform to ignore exactly these tags and nothing else:

resource "azurerm_private_dns_zone" "enterprise_zones" {
  for_each            = toset(var.private_dns_zones)
  name                = each.key
  resource_group_name = azurerm_resource_group.rg.name

  lifecycle {
    ignore_changes = [
      tags["hidden-title"],
      tags["CreatedByPolicy"]
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Terraform now maintains the infrastructure. The compliance scanner gets its metadata. Nobody fights. No pipeline failures.

Zero-Trust NSG Baseline

A default Azure VNet allows unrestricted lateral movement and outbound internet access. For any ISO 27001 or KRITIS audit, this is an immediate finding.

The fix: an NSG bound to Spoke subnets at creation — not as a follow-up ticket:

resource "azurerm_network_security_group" "zero_trust" {
  name                = "nsg-zero-trust-${var.environment}"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  security_rule {
    name                       = "Allow-VNet-Inbound"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "VirtualNetwork"
    source_port_range          = "*"
    destination_port_range     = "*"
  }

  security_rule {
    name                       = "Deny-Internet-Inbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_address_prefix      = "Internet"
    destination_address_prefix = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
  }
}

# Critical: bind it immediately — an unbound NSG enforces nothing
resource "azurerm_subnet_network_security_group_association" "spoke1_nsg" {
  subnet_id                 = azurerm_subnet.spoke1_default.id
  network_security_group_id = azurerm_network_security_group.zero_trust.id
}
Enter fullscreen mode Exit fullscreen mode

The priority gap (100 → 4096) leaves room for hundreds of application-specific rules without renumbering the baseline.

Centralized Private DNS

Deploy DNS zones once in the Hub — Spokes resolve through peering automatically:

variable "private_dns_zones" {
  default = [
    "privatelink.blob.core.windows.net",
    "privatelink.database.windows.net",
    "privatelink.vaultcore.azure.net",
    "privatelink.azurecr.io"
  ]
}

resource "azurerm_private_dns_zone" "enterprise_zones" {
  for_each            = toset(var.private_dns_zones)
  name                = each.key
  resource_group_name = azurerm_resource_group.rg.name

  lifecycle {
    ignore_changes = [tags["hidden-title"], tags["CreatedByPolicy"]]
  }
}
Enter fullscreen mode Exit fullscreen mode

Four zones, one block, DINE-proof. No per-Spoke DNS configuration required.


The free base topology is on GitHub. The full article with complete DINE bypass logic, NSG associations, and VNet link protection is on my blog.

👉 Full article on woitzik.dev
👉 Free GitHub repo

Top comments (0)