5. Deployment
Getting Started proved a VNet exists. This page makes it repeatable, reviewable, and reversible — the version that survives a pull request, three environments, and someone rolling it back at 2 a.m.
Networking deserves more deployment rigour than most services, for one reason: network changes have blast radius that isn't local. A wrong subnet CIDR blocks a peering forever. A wrong UDR black-holes a whole subnet, including your own access to it. A deleted NSG association silently opens a database tier. Nothing here has a "just redeploy it" recovery path the way a stateless app does.
Tool order
| Rank | Tool | What it does here | When it's the wrong choice |
|---|---|---|---|
| 1. Primary | Terraform (azurerm, azapi for preview features) |
The whole topology — VNets, subnets, NSGs, route tables, peerings, NAT. Terraform's for_each over a subnet map is genuinely better than the alternatives for this shape of problem |
State is yours to protect, and portal clicks cause drift. azurerm lags brand-new networking features by weeks — that's what azapi is for |
| 2. Secondary | Ansible (azure.azcollection) |
Day-2 operations: adding an NSG rule across many NSGs, bulk peering onboarding, and the in-guest half of anything involving NVAs, DNS forwarders, or jump boxes | Won't reconcile drift. Don't own long-lived network topology with it |
| 3. Third | Bicep / ARM | The Azure-native path. First-class support for new networking features on day one, and what Microsoft's landing-zone reference implementations and AZ-104/AZ-305 exams assume | Azure-only, and what-if is noisier than terraform plan on network resources specifically |
All three apply cleanly to VNets. Nothing is dropped on this page.

The Terraform module
The shape that works for networking is a map of subnets in a variable, not a resource per
subnet. Adding a subnet becomes a three-line change to .tfvars reviewed in a pull request, and
for_each keys the resources by name so reordering the map never destroys anything (which is
exactly what count would do).
variables.tf
variable "name_prefix" {
description = "Short workload identifier, e.g. 'plat'."
type = string
}
variable "environment" {
description = "dev | staging | prod"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "location" {
type = string
default = "uksouth"
}
variable "address_space" {
description = "The VNet's address space. Must not overlap any network this may ever peer with."
type = list(string)
}
variable "subnets" {
description = "Subnet definitions, keyed by subnet name."
type = map(object({
address_prefixes = list(string)
service_endpoints = optional(list(string), [])
delegation = optional(string) # e.g. "Microsoft.Web/serverFarms"
nat_gateway = optional(bool, false)
route_table = optional(bool, false)
# Private endpoints need this false; most other subnets want the default.
private_endpoint_network_policies = optional(string, "Enabled")
}))
}
variable "nsg_rules" {
description = "NSG rules keyed by 'subnet_name/rule_name'."
type = map(object({
priority = number
direction = string
access = string
protocol = string
source_port_range = optional(string, "*")
destination_port_ranges = list(string)
source_address_prefix = optional(string)
source_application_security_group_ids = optional(list(string))
destination_address_prefix = optional(string, "*")
}))
default = {}
}
variable "firewall_private_ip" {
description = "Hub firewall IP for the default route. Null disables forced tunnelling."
type = string
default = null
}
variable "tags" {
type = map(string)
default = {}
}
main.tf
terraform {
required_version = ">= 1.9"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
# The features{} block matters less for networking than for Key Vault or VMs —
# there is no soft-delete story here. It is still required, and omitting it is a
# provider error rather than a silent default.
}
locals {
suffix = "${var.name_prefix}-${var.environment}-${var.location}"
tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
Workload = var.name_prefix
})
}
resource "azurerm_resource_group" "net" {
name = "rg-net-${local.suffix}"
location = var.location
tags = local.tags
}
resource "azurerm_virtual_network" "this" {
name = "vnet-${local.suffix}"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
address_space = var.address_space
tags = local.tags
# NOTE: no inline `subnet {}` blocks. Subnets are standalone resources below.
# Mixing the two makes every apply propose deleting every subnet.
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = each.value.address_prefixes
service_endpoints = each.value.service_endpoints
private_endpoint_network_policies = each.value.private_endpoint_network_policies
dynamic "delegation" {
for_each = each.value.delegation == null ? [] : [each.value.delegation]
content {
name = "delegation"
service_delegation {
name = delegation.value
actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
}
}
}
}
# --- NSGs: one per subnet, associated explicitly ---------------------------
resource "azurerm_network_security_group" "this" {
for_each = var.subnets
name = "nsg-${each.key}-${var.environment}"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
tags = local.tags
# No inline security_rule blocks — same mixing hazard as subnets.
}
resource "azurerm_network_security_rule" "this" {
for_each = var.nsg_rules
name = split("/", each.key)[1]
resource_group_name = azurerm_resource_group.net.name
network_security_group_name = azurerm_network_security_group.this[split("/", each.key)[0]].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_ranges = each.value.destination_port_ranges
source_address_prefix = each.value.source_address_prefix
source_application_security_group_ids = each.value.source_application_security_group_ids
destination_address_prefix = each.value.destination_address_prefix
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = var.subnets
subnet_id = azurerm_subnet.this[each.key].id
network_security_group_id = azurerm_network_security_group.this[each.key].id
}
# --- Forced tunnelling through the hub firewall ----------------------------
resource "azurerm_route_table" "egress" {
count = var.firewall_private_ip == null ? 0 : 1
name = "rt-egress-${local.suffix}"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
bgp_route_propagation_enabled = false
tags = local.tags
}
resource "azurerm_route" "default_via_firewall" {
count = var.firewall_private_ip == null ? 0 : 1
name = "default-to-firewall"
resource_group_name = azurerm_resource_group.net.name
route_table_name = azurerm_route_table.egress[0].name
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = var.firewall_private_ip
}
resource "azurerm_subnet_route_table_association" "this" {
for_each = { for k, v in var.subnets : k => v if v.route_table && var.firewall_private_ip != null }
subnet_id = azurerm_subnet.this[each.key].id
route_table_id = azurerm_route_table.egress[0].id
}
# --- Explicit outbound egress ---------------------------------------------
resource "azurerm_public_ip" "nat" {
count = anytrue([for s in var.subnets : s.nat_gateway]) ? 1 : 0
name = "pip-natgw-${local.suffix}"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
allocation_method = "Static"
sku = "Standard"
zones = var.environment == "prod" ? ["1"] : null
tags = local.tags
}
resource "azurerm_nat_gateway" "this" {
count = anytrue([for s in var.subnets : s.nat_gateway]) ? 1 : 0
name = "natgw-${local.suffix}"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
sku_name = "Standard"
idle_timeout_in_minutes = 10
zones = var.environment == "prod" ? ["1"] : null
tags = local.tags
}
resource "azurerm_nat_gateway_public_ip_association" "this" {
count = anytrue([for s in var.subnets : s.nat_gateway]) ? 1 : 0
nat_gateway_id = azurerm_nat_gateway.this[0].id
public_ip_address_id = azurerm_public_ip.nat[0].id
}
resource "azurerm_subnet_nat_gateway_association" "this" {
for_each = { for k, v in var.subnets : k => v if v.nat_gateway }
subnet_id = azurerm_subnet.this[each.key].id
nat_gateway_id = azurerm_nat_gateway.this[0].id
}
outputs.tf
output "vnet_id" {
value = azurerm_virtual_network.this.id
}
output "vnet_name" {
value = azurerm_virtual_network.this.name
}
output "subnet_ids" {
description = "Map of subnet name to resource ID — what every consuming module needs."
value = { for k, s in azurerm_subnet.this : k => s.id }
}
output "nat_gateway_public_ip" {
description = "The egress IP to give partners for their allowlists."
value = try(azurerm_public_ip.nat[0].ip_address, null)
}
prod.tfvars
name_prefix = "plat"
environment = "prod"
location = "uksouth"
address_space = ["10.20.0.0/20"] # from the central address plan, room to grow after it
subnets = {
snet-app = {
address_prefixes = ["10.20.1.0/24"]
nat_gateway = true
route_table = true
}
snet-db = {
address_prefixes = ["10.20.2.0/24"]
service_endpoints = ["Microsoft.Sql"]
}
snet-pe = {
address_prefixes = ["10.20.3.0/24"]
private_endpoint_network_policies = "Disabled"
}
snet-integration = {
address_prefixes = ["10.20.4.0/26"]
delegation = "Microsoft.Web/serverFarms"
}
}
nsg_rules = {
"snet-db/allow-sql-from-app" = {
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
destination_port_ranges = ["1433"]
source_address_prefix = "10.20.1.0/24"
}
"snet-db/deny-all-inbound" = {
priority = 4000
direction = "Inbound"
access = "Deny"
protocol = "*"
destination_port_ranges = ["*"]
source_address_prefix = "*"
}
}
firewall_private_ip = "10.20.0.4"
The loop:
terraform init -backend-config=backend-prod.hcl
terraform plan -var-file=prod.tfvars -out=tfplan
terraform apply tfplan
Three networking-specific Terraform hazards
1. Never mix inline and standalone. Covered in Getting Started and
repeated here because it's the one that destroys production: inline subnet {} on the VNet plus
azurerm_subnet resources, or inline security_rule {} plus azurerm_network_security_rule, makes
every apply propose deleting the other's objects. Pick standalone and never look back.
2. Some services write back into your subnets. AKS, App Service VNet integration, and Container
Apps create service association links and modify delegations on subnets Terraform owns. The
resulting drift shows up as a plan wanting to remove a delegation you didn't add. Manage delegated
subnets in the same state as the service consuming them, or add a targeted lifecycle { ignore_changes }
— and write a comment explaining why, because an unexplained ignore_changes on a network resource
is how the next person loses an afternoon.
3. for_each, never count, over subnets. With count, removing the second of four subnets
renumbers indices 2 and 3, and Terraform destroys and recreates them — which means destroying every
NIC and private endpoint inside them. With for_each keyed by name, removing one subnet touches
exactly one subnet.
Remote state and locking
Local state on a shared network module is a footgun: two engineers applying concurrently will
produce a topology neither intended, and a lost terraform.tfstate on a VNet with live workloads is
a recovery project.
The azurerm backend stores state in an Azure Storage container and locks it with a native blob
lease — no separate lock table, unlike AWS's DynamoDB requirement.
az group create -n rg-tfstate -l uksouth
az storage account create -n sttfstateplat0001 -g rg-tfstate -l uksouth \
--sku Standard_GRS --min-tls-version TLS1_2 --allow-blob-public-access false
az storage container create -n tfstate --account-name sttfstateplat0001 --auth-mode login
# protect the state account from the very pipelines that use it
az storage account blob-service-properties update \
--account-name sttfstateplat0001 -g rg-tfstate \
--enable-versioning true --enable-delete-retention true --delete-retention-days 30
az lock create --name no-delete --lock-type CanNotDelete \
--resource-group rg-tfstate
# backend-prod.hcl
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstateplat0001"
container_name = "tfstate"
key = "network/prod.tfstate"
use_azuread_auth = true # OIDC/managed identity, not an account key
use_azuread_auth = true matters: it means the pipeline authenticates to the state account with its
federated identity and the Storage Blob Data Contributor data-plane role, rather than a
storage account key in a variable. This is the control plane vs. data plane
split showing up in your own tooling — being Owner on the storage account does not let you read the
state blob.
Workspaces vs. directory-per-environment. For networking specifically, use
directory-per-environment with separate state files, not workspaces. Networking state is the
thing you least want an accidental terraform workspace select to point at the wrong environment,
and separate directories let dev and prod diverge legitimately (prod has a firewall route, dev
doesn't) without conditionals sprawling through the module.
Ansible — day-2 operations
Ansible's honest place in networking is not owning the topology. It's for the operational tasks Terraform is awkward at: pushing a rule across many NSGs during an incident, onboarding a batch of spoke peerings, and configuring the inside of NVAs, DNS forwarders, and jump boxes — which Terraform cannot reach at all.
# playbook-network-day2.yml
- name: Virtual network day-2 operations
hosts: localhost
connection: local
gather_facts: false
vars:
resource_group: rg-net-plat-prod-uksouth
vnet_name: vnet-plat-prod-uksouth
hub_vnet_id: >-
/subscriptions/{{ hub_sub }}/resourceGroups/rg-net-hub/providers/Microsoft.Network/virtualNetworks/vnet-hub-prod
tasks:
# Authentication: AZURE_CLIENT_ID / AZURE_TENANT_ID with federated credentials,
# or a managed identity on the runner. Never a client secret in vars.
- name: Ensure the emergency-block rule exists on every application NSG
azure.azcollection.azure_rm_securitygroup:
resource_group: "{{ resource_group }}"
name: "{{ item }}"
rules:
- name: emergency-block-egress-to-badnet
protocol: "*"
destination_address_prefix: "203.0.113.0/24"
access: Deny
priority: 150
direction: Outbound
state: present
loop:
- nsg-snet-app-prod
- nsg-snet-db-prod
- name: Peer this spoke to the hub
azure.azcollection.azure_rm_virtualnetworkpeering:
resource_group: "{{ resource_group }}"
virtual_network: "{{ vnet_name }}"
name: peer-spoke-to-hub
remote_virtual_network: "{{ hub_vnet_id }}"
allow_virtual_network_access: true
allow_forwarded_traffic: true # required: the hub firewall forwards on behalf of others
use_remote_gateways: true # the spoke borrows the hub's ExpressRoute/VPN gateway
state: present
- name: Confirm the subnet inventory matches expectations
azure.azcollection.azure_rm_subnet_info:
resource_group: "{{ resource_group }}"
virtual_network_name: "{{ vnet_name }}"
register: subnets
- name: Fail loudly if an unmanaged subnet has appeared
ansible.builtin.assert:
that: subnets.subnets | map(attribute='name') | sort ==
['snet-app', 'snet-db', 'snet-integration', 'snet-pe']
fail_msg: "Unexpected subnet found — someone created one outside IaC."
Idempotency, demonstrated: the first run reports changed=3. The second reports changed=0
with identical output. The azure_rm_securitygroup module reconciles the rules you name and leaves
others alone, which is what makes it safe to run repeatedly during an incident.
The peering caveat worth writing down: peering has two sides. This playbook creates the spoke
side. If nobody creates the hub side, az network vnet peering show reports Initiated rather
than Connected and no traffic flows. In a real hub-and-spoke that second half is usually a
separate pipeline in the platform team's subscription, and the handoff between the two is the part
that goes wrong.
Bicep / ARM equivalent
Bicep module and what-if — click to expand
Bicep earns its place in networking when you're inside a Microsoft landing-zone reference implementation (they're all Bicep), when you need a networking feature the day it ships, or when deployment stacks are your governance mechanism.
// network.bicep
@description('Short workload identifier')
param namePrefix string
@allowed(['dev', 'staging', 'prod'])
param environment string
param location string = resourceGroup().location
param addressSpace array
param subnets array
param tags object = {}
var suffix = '${namePrefix}-${environment}-${location}'
resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = {
name: 'vnet-${suffix}'
location: location
tags: tags
properties: {
addressSpace: { addressPrefixes: addressSpace }
subnets: [for s in subnets: {
name: s.name
properties: {
addressPrefix: s.addressPrefix
networkSecurityGroup: { id: nsg[s.name].id }
privateEndpointNetworkPolicies: s.?privateEndpointNetworkPolicies ?? 'Enabled'
}
}]
}
}
resource nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = [for s in subnets: {
name: 'nsg-${s.name}-${environment}'
location: location
tags: tags
properties: {
securityRules: s.?rules ?? []
}
}]
output vnetId string = vnet.id
output subnetIds object = reduce(subnets, {}, (acc, s) => union(acc, {
'${s.name}': '${vnet.id}/subnets/${s.name}'
}))
Preview and preflight:
az deployment group what-if \
-g rg-net-plat-prod-uksouth \
-f network.bicep -p @prod.bicepparam
az deployment group create \
-g rg-net-plat-prod-uksouth \
-f network.bicep -p @prod.bicepparam \
--mode Incremental
⚠️ Deployment modes — the footgun that matters most here
Incremental(the default) leaves resources not mentioned in the template alone.Completemode deletes every resource in the resource group that is not in the template.In a networking resource group that is catastrophic in a specific way: it will delete the NICs, private endpoints, and gateways that other teams' deployments placed in your subnets, because your template doesn't declare them. Complete mode on a shared networking resource group is one of the fastest ways to cause a multi-team outage on Azure. Always pass
--mode Incrementalexplicitly rather than relying on the default, and always runwhat-iffirst — it does show the deletions Complete mode would perform.
Bicep's real weakness for networking: the ARM child-resource model means subnets can be declared
either inline on the VNet or as separate Microsoft.Network/virtualNetworks/subnets resources, and
mixing them causes the same destroy-everything behaviour as in Terraform. It's the same trap in a
different syntax. Declare subnets inline in Bicep and never separately.
CI/CD — GitHub Actions with workload identity federation
No client secrets, no service principal passwords, no publish profiles. The pipeline authenticates with a short-lived OIDC token exchanged against a federated credential on an Entra app registration.
One-time setup:
APP_ID=$(az ad app create --display-name "gh-network-prod" --query appId -o tsv)
az ad sp create --id $APP_ID
OBJ_ID=$(az ad app show --id $APP_ID --query id -o tsv)
az ad app federated-credential create --id $OBJ_ID --parameters '{
"name": "gh-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/platform-network:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# Least privilege: Network Contributor on the networking RG, not Contributor on the subscription.
az role assignment create --assignee $APP_ID \
--role "Network Contributor" \
--scope /subscriptions/$SUB/resourceGroups/rg-net-plat-prod-uksouth
# Data-plane role for the state blob — separate from the control-plane role above.
az role assignment create --assignee $APP_ID \
--role "Storage Blob Data Contributor" \
--scope /subscriptions/$SUB/resourceGroups/rg-tfstate/providers/Microsoft.Storage/storageAccounts/sttfstateplat0001
# .github/workflows/network.yml
name: network
on:
pull_request:
paths: ['envs/**', 'modules/network/**']
push:
branches: [main]
paths: ['envs/**', 'modules/network/**']
permissions:
id-token: write # required for OIDC
contents: read
pull-requests: write
env:
ARM_USE_OIDC: true
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
jobs:
plan:
runs-on: ubuntu-latest
strategy:
matrix:
env: [dev, staging, prod]
defaults:
run:
working-directory: envs/${{ matrix.env }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init -backend-config=backend.hcl
- run: terraform fmt -check && terraform validate
- run: terraform plan -var-file=terraform.tfvars -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.env }}
path: envs/${{ matrix.env }}/tfplan
apply-prod:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: prod # ← the manual approval gate lives here
defaults:
run:
working-directory: envs/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: tfplan-prod
path: envs/prod
- run: terraform init -backend-config=backend.hcl
- run: terraform apply -auto-approve tfplan
Applying the saved plan file, not re-planning, is worth insisting on for networking. It guarantees the change that was reviewed on the pull request is exactly the change that runs — no window in which someone else's portal click alters what apply decides to do.
One networking-specific gate to add to the plan job: grep the plan output for destructive network actions and fail the PR check unless a label overrides it.
if terraform show -json tfplan | jq -e '
.resource_changes[]
| select(.change.actions | index("delete"))
| select(.type | test("azurerm_subnet|azurerm_virtual_network|azurerm_virtual_network_peering"))
' > /dev/null; then
echo "::error::Plan destroys a subnet, VNet or peering. Requires the 'network-breaking' label."
exit 1
fi
Environments
The subscription is Azure's natural blast-radius and quota boundary — most networking limits are counted per subscription per region (Architecture) — so for networking specifically, one subscription per environment is the right answer more often than it is in AWS.
The reference shape:
| Dev | Staging | Prod | |
|---|---|---|---|
| Subscription | sub-plat-dev |
sub-plat-staging |
sub-plat-prod |
| VNet address space | 10.10.0.0/20 |
10.15.0.0/20 |
10.20.0.0/20 — non-overlapping, from the central plan |
| Peered to | dev hub | staging hub | prod hub |
| Egress | NAT Gateway | NAT Gateway | Azure Firewall via UDR |
| DDoS | off | off | Network Protection on the hub VNet |
| Flow logs | off or 7-day retention | 30 days | 90 days + Traffic Analytics |
| Locks | none | CanNotDelete on the VNet |
CanNotDelete on the VNet, hub, and gateways |
Where Azure Policy enforces it rather than convention. Assign at the management group above the production subscriptions so it applies to resources nobody remembered to put in Terraform:
- Deny NSG rules allowing inbound
*orInterneton 22, 3389, 1433, 3306, 5432 — the single highest-value network policy there is. - Deny creation of a subnet without an associated NSG (there is a built-in policy for this).
- DeployIfNotExists to add a diagnostic setting sending VNet flow logs to the central Log Analytics workspace.
- Deny public IP creation entirely in spoke subscriptions, forcing egress through the hub.
- Audit/Deny VNet peering to VNets outside the tenant.
Policy is doing something Terraform structurally cannot: covering the resources created outside your state file. Both are needed.
Rollback and blast radius
"Undo" for a VNet is not one thing, and the difference between the cases is the whole skill.
In-place and safely reversible — re-apply the previous commit and it's genuinely undone:
- NSG rules (add, remove, reprioritise)
- Routes in a route table
- Adding a subnet
- Adding an address range to the VNet
- Peering flags (
allowForwardedTraffic,allowGatewayTransit) - NAT Gateway attach/detach, tags, DNS server list
Reversible but with a real outage window — the resource survives, traffic doesn't:
- Removing an NSG association — instantly reopens the subnet to everything the default rules allow. Reversible in seconds; the exposure already happened.
- Adding a
0.0.0.0/0 → VirtualApplianceUDR — if the appliance isn't ready or return routing is asymmetric, every flow in the subnet dies at once, including your own SSH session. Have an out-of-band path (Bastion in a different subnet, or serial console) before you apply this. - Changing the VNet's DNS servers — applies at DHCP lease renewal, so nothing breaks immediately and then everything breaks a few hours later, one VM at a time, in a way that looks unrelated to the change.
Destructive or forcing replacement — these are the ones to catch in review:
- Changing a subnet's
address_prefixesforces replacement. Terraform will try to delete the subnet — and will fail if anything is in it, leaving state and reality diverged mid-apply. - Deleting a subnet takes its NICs, private endpoints, and service association links with it.
- Deleting a VNet is the whole topology.
- Changing a VPN Gateway's SKU family recreates the gateway, which means a new public IP and every on-premises peer reconfigured.
- Changing a subnet's delegation while the delegated service is running.
Azure-specific traps that make rollback fail in confusing ways:
- Resource locks. A
CanNotDeletelock on the VNet — exactly the lock you should have in prod — makesterraform applyfail with an authorization-shaped error on any change that requires replacement. It looks like an RBAC bug and is not. Locks are inherited from the resource group and subscription, so the lock blocking you may not be on the resource you're touching. - Peerings you don't own. Deleting a VNet with peerings requires removing both sides, and the far side may be in a subscription you can't see.
- No soft delete. Unlike Key Vault or storage, a deleted VNet is gone. No purge protection,
no recovery window, no support ticket. Your only recovery is
terraform applyfrom the previous commit — which is precisely why the state file and the git history are your backup, and why the state storage account gets versioning and a lock.
The one thing worth rehearsing: deleting a VNet is not recoverable, and terraform apply from
the last good commit will recreate the topology but not the resources other teams put inside it.
Their NICs, private endpoints, and gateway connections are gone, and every consuming team has to
redeploy. Blast radius for a VNet is measured in teams, not resources.
Drift detection
Network drift is uniquely likely, because networking is where people click during incidents. At 3 a.m. someone adds an NSG rule to restore service, and nobody tells the repository.
Four layers, and you want all of them:
1. Scheduled terraform plan in CI.
on:
schedule:
- cron: '0 6 * * 1-5'
jobs:
drift:
runs-on: ubuntu-latest
steps:
- run: terraform plan -detailed-exitcode -var-file=prod.tfvars
# exit 0 = no changes, 2 = drift detected, 1 = error
2. Azure Policy compliance state. Catches resources that were never in Terraform — the NSG someone created by hand, the public IP in a spoke that shouldn't have one.
3. Activity log alerts on the operations that matter. These fire in minutes rather than overnight:
AzureActivity
| where TimeGenerated > ago(1d)
| where OperationNameValue has_any (
"MICROSOFT.NETWORK/NETWORKSECURITYGROUPS/SECURITYRULES/WRITE",
"MICROSOFT.NETWORK/ROUTETABLES/ROUTES/WRITE",
"MICROSOFT.NETWORK/VIRTUALNETWORKS/SUBNETS/WRITE",
"MICROSOFT.NETWORK/VIRTUALNETWORKS/VIRTUALNETWORKPEERINGS/WRITE")
| where ActivityStatusValue == "Success"
| where Caller !endswith "terraform-prod" // exclude the pipeline identity
| project TimeGenerated, Caller, OperationNameValue, _ResourceId
| order by TimeGenerated desc
Filtering out the pipeline's own identity is what turns this from noise into a signal: every row that remains is a human change that bypassed the pipeline.
4. az deployment group what-if for anything managed in Bicep, on the same schedule.
Reconciling drift. When the scheduled plan finds an unexpected NSG rule, resist reverting it blindly — it may be the only thing keeping production up. The workflow is: find who made it in the activity log, ask why, then either codify it in the module or remove it deliberately. A drift process that silently reverts emergency fixes teaches people to disable the pipeline, which is worse than the drift.
Teardown
terraform destroy -var-file=prod.tfvars
# or, for the throwaway case
az group delete -n rg-net-plat-dev-uksouth --yes --no-wait
Cleanup note — what
destroywill not remove.
- The far side of any peering. Terraform removes the peering it owns; the remote VNet keeps a dangling peering in
Disconnectedstate that someone must clean up in the other subscription.- Anything under a resource lock.
CanNotDeleteon the VNet or its resource group makes destroy fail partway, leaving state inconsistent with reality. Remove locks first, deliberately.- Resources other teams put in your subnets. Destroy will fail — often with a generic "subnet is in use" — until every NIC, private endpoint, and service association link is gone. In a shared VNet you frequently cannot see who owns them.
- Private DNS zone records auto-registered by VMs or created by private endpoints, if the zone is managed in a different state file. They linger and then resolve to nothing.
- Role assignments made outside the state file — including the
join/actiongrants you gave application teams on the subnet.- Diagnostic settings and flow log configurations created by Azure Policy's
DeployIfNotExists. Policy created them, so Policy — not Terraform — owns them.- Deallocated resources still holding IPs. A stopped VM's NIC still occupies its subnet address and still blocks subnet deletion.
There is no soft delete for VNets. Once destroy succeeds, the topology is gone and your only copy is the git history.
Next: Integrations →
← Back to the Virtual Network overview · ← Previous: Getting Started