<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: david</title>
    <description>The latest articles on DEV Community by david (@dwoitzik).</description>
    <link>https://dev.to/dwoitzik</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3933869%2F1fb8aa5b-2239-46a7-bf78-b5352809883c.png</url>
      <title>DEV Community: david</title>
      <link>https://dev.to/dwoitzik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dwoitzik"/>
    <language>en</language>
    <item>
      <title>NIS2 Article 21 in Azure: Implementing Network Security Controls with Terraform</title>
      <dc:creator>david</dc:creator>
      <pubDate>Thu, 21 May 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/dwoitzik/nis2-article-21-in-azure-implementing-network-security-controls-with-terraform-3idl</link>
      <guid>https://dev.to/dwoitzik/nis2-article-21-in-azure-implementing-network-security-controls-with-terraform-3idl</guid>
      <description>&lt;p&gt;NIS2 Article 21 in Azure: Implementing Network Security Controls with Terraform&lt;/p&gt;

&lt;p&gt;Tags: terraform, azure, security, devops&lt;/p&gt;

&lt;p&gt;Canonical URL: &lt;a href="https://woitzik.dev/blog/nis2-article-21-azure-terraform" rel="noopener noreferrer"&gt;https://woitzik.dev/blog/nis2-article-21-azure-terraform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most NIS2 content is written by lawyers for lawyers. This article maps Article 21 network security requirements to concrete Azure resources — with Terraform code, not legal theory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This covers technical network infrastructure controls relevant to NIS2 Article 21. Full compliance requires organizational measures beyond infrastructure code. Nothing here constitutes legal advice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What NIS2 Article 21 Actually Requires (For Engineers)
&lt;/h2&gt;

&lt;p&gt;Four concrete network security areas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Network Segmentation&lt;/strong&gt; — isolate systems by criticality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access Control&lt;/strong&gt; — Default-Deny at network and identity layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimize Attack Surface&lt;/strong&gt; — eliminate unnecessary public endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auditability&lt;/strong&gt; — traceable, version-controlled infrastructure changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's how each maps to Azure Terraform resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 1: Network Segmentation via Hub &amp;amp; Spoke
&lt;/h2&gt;

&lt;p&gt;Spoke-to-Spoke traffic blocked by default — all cross-workload communication routes through the Hub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_virtual_network_peering"&lt;/span&gt; &lt;span class="s2"&gt;"hub_to_spoke1"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"peer-hub-to-spoke1"&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;remote_virtual_network_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spoke1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;allow_forwarded_traffic&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spokes only peer with the Hub — never with each other. Cross-Spoke traffic must be explicitly routed through the Firewall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 2: Default-Deny NSG Baseline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow-VNet-Inbound"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
  &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VirtualNetwork"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VirtualNetwork"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny-Internet-Inbound"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;
  &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
  &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Internet"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority gap (100 → 4096) leaves room for application rules without restructuring the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 3: Egress Control via Forced Tunneling
&lt;/h2&gt;

&lt;p&gt;All outbound Spoke traffic through Azure Firewall — logged, inspectable, FQDN-controlled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"route-to-firewall"&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefix&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
  &lt;span class="nx"&gt;next_hop_type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VirtualAppliance"&lt;/span&gt;
  &lt;span class="nx"&gt;next_hop_in_ip_address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_firewall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip_configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;private_ip_address&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Critical — without these, Windows VMs lose activation&lt;/span&gt;
&lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bypass-azure-kms"&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"23.102.135.246/32"&lt;/span&gt;
  &lt;span class="nx"&gt;next_hop_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Internet"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Firewall log is your audit trail for NIS2 Article 21(2)(b) — monitoring and incident detection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 4: Zero Public PaaS Endpoints
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_key_vault"&lt;/span&gt; &lt;span class="s2"&gt;"kv"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;public_network_access_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="nx"&gt;network_acls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;default_action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
    &lt;span class="nx"&gt;bypass&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AzureServices"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An external scanner gets no TCP connection — the endpoint doesn't resolve to a public IP. Directly satisfies NIS2 Article 21(2)(h).&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 5: No Static Credentials
&lt;/h2&gt;

&lt;p&gt;Managed Identity + scoped RBAC — no connection strings, no access keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"app_kv_secrets"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_key_vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;role_definition_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Key Vault Secrets User"&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_linux_function_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;principal_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the resource is deleted, identity and permissions are automatically destroyed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 6: Auditability via IaC
&lt;/h2&gt;

&lt;p&gt;Every change is a Git commit. &lt;code&gt;terraform plan&lt;/code&gt; is an audit artifact. No ClickOps, no undocumented Portal changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Covers — and What It Doesn't
&lt;/h2&gt;

&lt;p&gt;This addresses technical network controls only. Full NIS2 compliance also requires incident response procedures, supply chain risk management, business continuity measures, and organizational governance.&lt;/p&gt;

&lt;p&gt;Infrastructure code is the foundation. Compliance is the full building.&lt;/p&gt;




&lt;p&gt;Full article with complete code for all six controls on my blog.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://woitzik.dev/blog/nis2-article-21-azure-terraform" rel="noopener noreferrer"&gt;Full article on woitzik.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Zero-Trust RAG: Defeating the Shared Private Link Deadlock in Azure Terraform</title>
      <dc:creator>david</dc:creator>
      <pubDate>Wed, 20 May 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/dwoitzik/zero-trust-rag-defeating-the-shared-private-link-deadlock-in-azure-terraform-kdb</link>
      <guid>https://dev.to/dwoitzik/zero-trust-rag-defeating-the-shared-private-link-deadlock-in-azure-terraform-kdb</guid>
      <description>&lt;p&gt;Your Terraform pipeline is green. The deployment completes without errors. You grab a coffee.&lt;/p&gt;

&lt;p&gt;Ten minutes later, you test your new Enterprise RAG application. It throws a &lt;code&gt;403 Forbidden&lt;/code&gt;. You open the Azure Portal, check the OpenAI Networking tab, and there it is: your Shared Private Link from AI Search is sitting in &lt;strong&gt;Pending&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Nobody told Terraform to approve it. Nobody told you it even needed approving.&lt;/p&gt;

&lt;p&gt;This is the CI/CD killer of Azure AI infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens
&lt;/h2&gt;

&lt;p&gt;AI Search must call OpenAI to vectorize data. The &lt;code&gt;azurerm&lt;/code&gt; provider can successfully &lt;em&gt;request&lt;/em&gt; this Shared Private Link — but it cannot &lt;em&gt;approve&lt;/em&gt; its own request. The target resource (OpenAI) must explicitly accept the inbound connection. The standard provider has no method for this. Pipeline deadlocked. Someone has to click "Approve" in the Portal manually. ClickOps in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AzAPI State Machine Fix
&lt;/h2&gt;

&lt;p&gt;We bypass the &lt;code&gt;azurerm&lt;/code&gt; provider entirely and talk directly to the Azure Resource Manager REST API using the &lt;code&gt;azapi&lt;/code&gt; provider.&lt;/p&gt;

&lt;p&gt;Because Azure generates a random GUID for the incoming connection, we can't hardcode the ID — we read it at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"azapi_resource_list"&lt;/span&gt; &lt;span class="s2"&gt;"pe_connections"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.CognitiveServices/accounts/privateEndpointConnections@2023-05-01"&lt;/span&gt;
  &lt;span class="nx"&gt;parent_id&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_cognitive_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;response_export_values&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;azurerm_search_shared_private_link_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openai_link&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;depends_on&lt;/code&gt; is critical — without it, Terraform queries the connection list before the link is even requested, returns empty, and the approval silently fails.&lt;/p&gt;

&lt;p&gt;Then we filter for the Pending connection and approve it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azapi_update_resource"&lt;/span&gt; &lt;span class="s2"&gt;"approve_shared_link"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.CognitiveServices/accounts/privateEndpointConnections@2023-05-01"&lt;/span&gt;

  &lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;try&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;jsondecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azapi_resource_list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pe_connections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
      &lt;span class="nx"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;privateLinkServiceConnectionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="err"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Pending"&lt;/span&gt;
    &lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s2"&gt;""&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;properties&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;privateLinkServiceConnectionState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;status&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Approved"&lt;/span&gt;
        &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Approved via Terraform AzAPI Pipeline"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try()&lt;/code&gt; wrapper is not optional. On &lt;code&gt;terraform destroy&lt;/code&gt;, the Shared Private Link is deleted before this resource is evaluated. Without &lt;code&gt;try()&lt;/code&gt;, indexing &lt;code&gt;[0]&lt;/code&gt; on an empty array crashes the destroy run and leaves orphaned resources in Azure.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;terraform apply&lt;/code&gt;, the link goes from Pending to Approved in under 30 seconds. No Portal access required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity Chaining — Kill the API Keys
&lt;/h2&gt;

&lt;p&gt;Auto-approving the link lets AI Search reach OpenAI. But static API keys will fail your compliance audit. Keys leak. Keys get committed to Git.&lt;/p&gt;

&lt;p&gt;Disable local auth and use a System Managed Identity instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_search_service"&lt;/span&gt; &lt;span class="s2"&gt;"search"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search_service_name&lt;/span&gt;
  &lt;span class="nx"&gt;sku&lt;/span&gt;                          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"standard"&lt;/span&gt;
  &lt;span class="nx"&gt;public_network_access_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;local_authentication_enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SystemAssigned"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"search_to_openai"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_cognitive_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;role_definition_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Cognitive Services OpenAI User"&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_search_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;principal_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero credential management. When the AI Search instance is deleted, its identity and permissions are destroyed automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't Forget Private DNS
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;your-instance.openai.azure.com&lt;/code&gt; still resolves to a public IP, the Azure Firewall drops the traffic and you get another opaque &lt;code&gt;403&lt;/code&gt;. Both services need their DNS zones linked to the VNet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone"&lt;/span&gt; &lt;span class="s2"&gt;"openai_dns"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.openai.azure.com"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone_virtual_network_link"&lt;/span&gt; &lt;span class="s2"&gt;"openai_vnet_link"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"link-openai-vnet"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zone_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openai_dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;registration_enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;registration_enabled = false&lt;/code&gt; — always. Automatic registration conflicts with centralized Hub &amp;amp; Spoke DNS management.&lt;/p&gt;




&lt;p&gt;The free baseline (AzAPI auto-approval + basic networking) is on GitHub. The full article with complete VNet injection, Private DNS automation, and RBAC Identity Chaining is on my blog.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://woitzik.dev/blog/azure-rag-shared-private-link-automation" rel="noopener noreferrer"&gt;Full article on woitzik.dev&lt;/a&gt;&lt;br&gt;
👉 &lt;a href="https://github.com/dwoitzik/azure-openai-rag-network" rel="noopener noreferrer"&gt;Free GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Surviving Azure Policies: Zero-Trust Hub &amp; Spoke with Terraform</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 18 May 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/dwoitzik/surviving-azure-policies-zero-trust-hub-spoke-with-terraform-2b6l</link>
      <guid>https://dev.to/dwoitzik/surviving-azure-policies-zero-trust-hub-spoke-with-terraform-2b6l</guid>
      <description>&lt;p&gt;Your Terraform pipeline is green. The deployment completes. You grab a coffee.&lt;/p&gt;

&lt;p&gt;Ten minutes later, Azure Policy has silently rewritten three of your resources. You run &lt;code&gt;terraform plan&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;This is Tuesday in an enterprise Azure tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DINE Death Loop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DeployIfNotExists&lt;/code&gt; policies run continuously in the background. They inject tags like &lt;code&gt;CreatedByPolicy=True&lt;/code&gt; or &lt;code&gt;hidden-title&lt;/code&gt; into your resources for compliance tracking.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix is surgical — tell Terraform to ignore exactly these tags and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone"&lt;/span&gt; &lt;span class="s2"&gt;"enterprise_zones"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_dns_zones&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ignore_changes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hidden-title"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CreatedByPolicy"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform now maintains the infrastructure. The compliance scanner gets its metadata. Nobody fights. No pipeline failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-Trust NSG Baseline
&lt;/h2&gt;

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

&lt;p&gt;The fix: an NSG bound to Spoke subnets at creation — not as a follow-up ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"zero_trust"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nsg-zero-trust-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow-VNet-Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VirtualNetwork"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VirtualNetwork"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;security_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny-Internet-Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;priority&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
    &lt;span class="nx"&gt;access&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Internet"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;source_port_range&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
    &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Critical: bind it immediately — an unbound NSG enforces nothing&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet_network_security_group_association"&lt;/span&gt; &lt;span class="s2"&gt;"spoke1_nsg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spoke1_default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;network_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zero_trust&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The priority gap (100 → 4096) leaves room for hundreds of application-specific rules without renumbering the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Centralized Private DNS
&lt;/h2&gt;

&lt;p&gt;Deploy DNS zones once in the Hub — Spokes resolve through peering automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"private_dns_zones"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"privatelink.blob.core.windows.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"privatelink.database.windows.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"privatelink.vaultcore.azure.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"privatelink.azurecr.io"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone"&lt;/span&gt; &lt;span class="s2"&gt;"enterprise_zones"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_dns_zones&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ignore_changes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hidden-title"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CreatedByPolicy"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four zones, one block, DINE-proof. No per-Spoke DNS configuration required.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://woitzik.dev/blog/azure-terraform-hub-spoke-zero-trust" rel="noopener noreferrer"&gt;Full article on woitzik.dev&lt;/a&gt;&lt;br&gt;
👉 &lt;a href="https://github.com/dwoitzik/azure-network-hub-spoke" rel="noopener noreferrer"&gt;Free GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Hardening Azure Acmebot for ISO 27001 &amp; NIS2 Compliance with Terraform</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sat, 16 May 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/dwoitzik/hardening-azure-acmebot-for-iso-27001-nis2-compliance-with-terraform-438e</link>
      <guid>https://dev.to/dwoitzik/hardening-azure-acmebot-for-iso-27001-nis2-compliance-with-terraform-438e</guid>
      <description>&lt;p&gt;Automating SSL/TLS certificates with Let's Encrypt and Azure Key Vault is a solved problem. Tools like &lt;a href="https://github.com/shibayan/keyvault-acmebot" rel="noopener noreferrer"&gt;Azure Acmebot&lt;/a&gt; make deployment incredibly simple.&lt;/p&gt;

&lt;p&gt;In corporate environments targeting ISO 27001, KRITIS, or NIS2 compliance, "simple" is rarely sufficient. The standard Acmebot deployment relies on public endpoints — and a Storage Account or Key Vault reachable from the public internet will immediately trigger findings during a security audit.&lt;/p&gt;

&lt;p&gt;Here's how to transition from a standard deployment to a fully network-isolated, Zero-Trust architecture — entirely managed with Terraform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compliance Gap
&lt;/h2&gt;

&lt;p&gt;Three components expose a public attack surface out of the box:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Storage Account&lt;/strong&gt; — accepts traffic from all networks by default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key Vault&lt;/strong&gt; — operates with a public endpoint unless explicitly restricted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function App&lt;/strong&gt; — the Acmebot dashboard is publicly reachable with no network-layer restriction&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Target principle: &lt;strong&gt;Default-Deny at the network layer, not just the identity layer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: VNet Integration
&lt;/h2&gt;

&lt;p&gt;The Function App needs a dedicated subnet with a delegation to &lt;code&gt;Microsoft.Web/serverFarms&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"acmebot_integration"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"snet-acmebot-integration"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;existing_vnet_rg&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;existing_vnet_name&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefixes&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.1.0/27"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;delegation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"delegation-acmebot"&lt;/span&gt;
    &lt;span class="nx"&gt;service_delegation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.Web/serverFarms"&lt;/span&gt;
      &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Microsoft.Network/virtualNetworks/subnets/join/action"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings that will cost you hours if you miss them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;site_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vnet_route_all_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# Forces ALL outbound through VNet&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app_settings&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"WEBSITE_DNS_SERVER"&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"168.63.129.16"&lt;/span&gt;  &lt;span class="c1"&gt;# Azure internal DNS — required&lt;/span&gt;
  &lt;span class="s2"&gt;"WEBSITE_CONTENTOVERVNET"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;vnet_route_all_enabled = true&lt;/code&gt;, the Function still tries to reach Storage over the public internet and fails silently once you lock it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Private Endpoints &amp;amp; DNS
&lt;/h2&gt;

&lt;p&gt;The Storage Account requires &lt;strong&gt;four&lt;/strong&gt; separate Private Endpoints — one each for &lt;code&gt;blob&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt;, and &lt;code&gt;file&lt;/code&gt;. Azure Functions uses all four internally. Skip any one and the Function App deploys successfully but fails at runtime with cryptic storage errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"storage_pe"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"blob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"queue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pe-acmebot-st-${each.key}"&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;acmebot_endpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;private_service_connection&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"psc-acmebot-st-${each.key}"&lt;/span&gt;
    &lt;span class="nx"&gt;private_connection_resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;acmebot_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="nx"&gt;subresource_names&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;is_manual_connection&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;private_dns_zone_group&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;
    &lt;span class="nx"&gt;private_dns_zone_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_dns_zone_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Default-Deny Firewall Rules
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_storage_account"&lt;/span&gt; &lt;span class="s2"&gt;"acmebot_storage"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;public_network_access_enabled&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;allow_nested_items_to_be_public&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="nx"&gt;network_rules&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;default_action&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
    &lt;span class="nx"&gt;bypass&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"AzureServices"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;virtual_network_subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;acmebot_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bypass = "AzureServices"&lt;/code&gt; is required — it allows Azure's internal control plane operations to continue working. Without it, diagnostic log shipping and other platform features silently break.&lt;/p&gt;

&lt;p&gt;After applying this configuration, your Acmebot deployment meets the network isolation requirements of ISO 27001 Annex A.8, NIS2 Article 21, and KRITIS baseline controls.&lt;/p&gt;




&lt;p&gt;The complete tested architecture including Entra ID automation and full Private Link configuration is on my blog and GitHub.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://woitzik.dev/blog/hardening-azure-acmebot-iso27001" rel="noopener noreferrer"&gt;Full article on woitzik.dev&lt;/a&gt;&lt;br&gt;
👉 &lt;a href="https://github.com/dwoitzik/azure-acme-cert-automation" rel="noopener noreferrer"&gt;Free GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Breaking the Loop: Solving Circular Dependencies in Azure Firewall Routing with Terraform</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 15 May 2026 20:39:45 +0000</pubDate>
      <link>https://dev.to/dwoitzik/breaking-the-loop-solving-circular-dependencies-in-azure-firewall-routing-with-terraform-2dbe</link>
      <guid>https://dev.to/dwoitzik/breaking-the-loop-solving-circular-dependencies-in-azure-firewall-routing-with-terraform-2dbe</guid>
      <description>&lt;p&gt;You add a Route Table to force all internet-bound traffic (&lt;code&gt;0.0.0.0/0&lt;/code&gt;) from your Spoke VNets into an Azure Firewall. You run &lt;code&gt;terraform plan&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Cycle: azurerm_subnet_route_table_association.spoke_binding,
azurerm_route_table.spoke_udr, azurerm_firewall.fw ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform has deadlocked. And even if you fix the cycle — a plain &lt;code&gt;0.0.0.0/0&lt;/code&gt; route will silently break Windows VM activation and Managed Identity authentication three days later.&lt;/p&gt;

&lt;p&gt;Here's why both happen and how to fix them cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cycle Error
&lt;/h2&gt;

&lt;p&gt;Terraform can't resolve the dependency graph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Route Table needs the Firewall's &lt;code&gt;private_ip_address&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The Firewall needs &lt;code&gt;AzureFirewallSubnet&lt;/code&gt; to exist first&lt;/li&gt;
&lt;li&gt;The Subnet Association tries to bind everything simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; directly reference &lt;code&gt;azurerm_firewall.fw.ip_configuration[0].private_ip_address&lt;/code&gt; in the Route Table. Terraform can now unambiguously resolve the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Firewall → (has IP) → Route Table → Subnet Association
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No workarounds. Just correct resource ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PaaS Trap
&lt;/h2&gt;

&lt;p&gt;Once the cycle is fixed, most engineers celebrate and move on. Three days later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows VMs lose activation&lt;/strong&gt; — Azure KMS traffic is trapped by &lt;code&gt;0.0.0.0/0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed Identities stop authenticating&lt;/strong&gt; — Azure AD traffic hits the unconfigured firewall&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is two explicit bypass routes that must exist &lt;em&gt;before&lt;/em&gt; the Route Table is attached to any subnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# KMS Bypass — required for Windows VM activation&lt;/span&gt;
&lt;span class="c1"&gt;# 23.102.135.246/32 is Azure's global KMS endpoint (officially documented)&lt;/span&gt;
&lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bypass-azure-kms"&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"23.102.135.246/32"&lt;/span&gt;
  &lt;span class="nx"&gt;next_hop_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Internet"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Azure AD Bypass — prevents Managed Identity auth lockouts&lt;/span&gt;
&lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bypass-azure-ad"&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AzureActiveDirectory"&lt;/span&gt;
  &lt;span class="nx"&gt;next_hop_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Internet"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;next_hop_type = "Internet"&lt;/code&gt; for Azure-owned IP ranges routes traffic over Azure's internal backbone — it never leaves Microsoft's network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling to Multiple Spokes
&lt;/h2&gt;

&lt;p&gt;Instead of duplicating the association resource for every Spoke, use &lt;code&gt;for_each&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet_route_table_association"&lt;/span&gt; &lt;span class="s2"&gt;"spoke_routing"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spoke_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="nx"&gt;route_table_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_route_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spoke_udr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a new Spoke to the variable map, run &lt;code&gt;terraform apply&lt;/code&gt; — done.&lt;/p&gt;




&lt;p&gt;The free baseline (cycle fix + route table structure) is on GitHub. The full article with complete code, IP Group scaling, and FQDN baseline policies is on my blog.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://woitzik.dev/blog/azure-firewall-cycle-error" rel="noopener noreferrer"&gt;Full article on woitzik.dev&lt;/a&gt;&lt;br&gt;&lt;br&gt;
👉 &lt;a href="https://github.com/dwoitzik/azure-firewall-forced-tunneling" rel="noopener noreferrer"&gt;Free GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>devops</category>
      <category>security</category>
    </item>
  </channel>
</rss>
