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"]
]
}
}
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
}
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"]]
}
}
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.
Top comments (0)