A connection between two peered VNets times out, even though the portal may show the peering as healthy. Connected only means both links exist. It doesn't prove that the access flags, routes, NSGs or name resolution are right.
Symptom, cause and fix
| Symptom | Likely cause | Fix |
|---|---|---|
Peering state is Initiated | Only one link has been created | Create the link from the other VNet |
Peering state is Disconnected | One link was deleted, often after a move or address space change | Delete the remaining links, then recreate both |
New address range is unreachable; peeringSyncLevel isn't FullyInSync | Address space changed and the peering wasn't synced | az network vnet peering sync |
State is Connected, all traffic fails | allowVirtualNetworkAccess is false on a side, or an NSG denies it | Enable access; fix the NSG rule |
| Traffic goes to a firewall or is dropped | A UDR overrides the peering route | Fix the route or the firewall rule |
| Spoke can't reach another spoke | Peering isn't transitive | Route through an NVA or Azure Firewall in the hub |
| Spoke can't reach on-premises | Gateway transit flags missing, or on-premises has no route back | Set the flags; check the advertised routes |
| IP works, hostname fails | Name resolution doesn't cross the peering by itself | Link the private DNS zone or fix custom DNS |
Check 1: state and sync on both sides
Every peering has two links, so look at both VNets. They may be in different subscriptions.
az network vnet peering list -g rg-hub --vnet-name vnet-hub \
--query "[].{name:name, state:peeringState, sync:peeringSyncLevel, access:allowVirtualNetworkAccess, forwarded:allowForwardedTraffic, gwTransit:allowGatewayTransit, remoteGw:useRemoteGateways}" \
-o table
Repeat on the other VNet. Healthy means Connected, FullyInSync and access True on both sides.
Next, compare the ranges the peering knows about with the remote VNet's real ranges:
az network vnet peering show -g rg-hub --vnet-name vnet-hub -n hub-to-spoke1 \
--query "remoteVirtualNetworkAddressSpace.addressPrefixes"
az network vnet show -g rg-spoke1 -n vnet-spoke1 \
--query "addressSpace.addressPrefixes"
Check 2: effective routes on the source NIC
The VM must be running for this to work.
az network nic show-effective-route-table -g rg-spoke1 -n vm-app1-nic -o table
Find the destination prefix. You should see an active route with a next hop type such as VNetPeering or VNetGlobalPeering (names vary by version). Then confirm what the platform does with one flow:
az network watcher show-next-hop -g rg-spoke1 --vm vm-app1 \
--source-ip 10.1.0.4 --dest-ip 10.2.0.4
VirtualAppliancemeans a UDR sends the flow to a firewall or NVA. Continue the investigation there.Nonemeans Azure drops the packet.- No route to the prefix on a peered VNet points back to Check 1: sync, state or the wrong address range.
Check 3: NSGs on both ends
Test the flow from each VM's point of view:
# Outbound from the source
az network watcher test-ip-flow -g rg-spoke1 --vm vm-app1 \
--direction Outbound --protocol TCP \
--local 10.1.0.4:49152 --remote 10.2.0.4:443
# Inbound at the destination
az network watcher test-ip-flow -g rg-spoke2 --vm vm-db1 \
--direction Inbound --protocol TCP \
--local 10.2.0.4:443 --remote 10.1.0.4:49152
The output names the rule that allowed or denied the flow.
Peered ranges are part of the VirtualNetwork service tag, so the default rules allow this traffic. Failures usually come from a custom deny rule, or an allow rule written for a specific CIDR that doesn't include a new range.
If routes and NSGs are clean, check the destination guest: OS firewall, and whether the service is listening on that port.
Fixes
Peering is Initiated or Disconnected
Initiated needs the missing link. --remote-vnet needs the full resource ID when the VNets are in different subscriptions.
az network vnet peering create -g rg-spoke1 --vnet-name vnet-spoke1 -n spoke1-to-hub \
--remote-vnet /subscriptions/<sub-id>/resourceGroups/rg-hub/providers/Microsoft.Network/virtualNetworks/vnet-hub \
--allow-vnet-access true --allow-forwarded-traffic true
Disconnected can't be repaired in place. Microsoft's guidance is to delete the links on both VNets and create them again.
Caution
Deleting a peering stops all traffic across it, including any gateway transit the spoke relies on. Save each side's flags from Check 1 first, recreate them exactly and do the work in a change window if any traffic still flows.
az network vnet peering delete -g rg-hub --vnet-name vnet-hub -n hub-to-spoke1
az network vnet peering delete -g rg-spoke1 --vnet-name vnet-spoke1 -n spoke1-to-hub
# Then run az network vnet peering create from each VNet.
Peering is out of sync
az network vnet peering sync -g rg-hub --vnet-name vnet-hub -n hub-to-spoke1
Run it for each link that isn't FullyInSync, then re-run Check 1 on both sides. The account needs rights on both VNets. Microsoft recommends a sync after every address space resize, rather than batching resizes.
Access flag is off
az network vnet peering update -g rg-spoke1 --vnet-name vnet-spoke1 -n spoke1-to-hub \
--set allowVirtualNetworkAccess=true
Spoke to spoke
Peering isn't transitive. If spoke1 and spoke2 both peer with the hub, they still can't reach each other through it without a forwarding device or a direct peering. To route through the hub:
- Add a UDR on each spoke subnet that sends the other spoke's range to the firewall or NVA private IP.
- Enable forwarded traffic on the links between the hub and each spoke.
- Allow the flow in the firewall or NVA.
- For an NVA VM, enable IP forwarding on its NIC and inside its OS.
az network vnet peering update -g rg-hub --vnet-name vnet-hub -n hub-to-spoke1 \
--set allowForwardedTraffic=true
az network vnet peering update -g rg-spoke1 --vnet-name vnet-spoke1 -n spoke1-to-hub \
--set allowForwardedTraffic=true
az network route-table route create -g rg-spoke1 --route-table-name rt-spoke1 \
-n to-spoke2 --address-prefix 10.2.0.0/16 \
--next-hop-type VirtualAppliance --next-hop-ip-address 10.0.1.4
# NVA VMs only. Azure Firewall doesn't need this.
az network nic update -g rg-hub -n nva1-nic --ip-forwarding true
Make the routes symmetric, or a stateful firewall will drop the return traffic.
Spoke to on-premises through the hub gateway
The hub link needs allowGatewayTransit and the spoke link needs useRemoteGateways.
az network vnet peering update -g rg-hub --vnet-name vnet-hub -n hub-to-spoke1 \
--set allowGatewayTransit=true
az network vnet peering update -g rg-spoke1 --vnet-name vnet-spoke1 -n spoke1-to-hub \
--set useRemoteGateways=true
A VNet that already has its own gateway can't use a remote one, and only one of its peerings can have the flag set. For cross-tenant peerings, use the CLI or PowerShell rather than the portal.
If the flags are right, check that on-premises can route back. With BGP, confirm the gateway advertises the spoke range:
az network vnet-gateway list-advertised-routes -g rg-hub -n gw-hub --peer 10.255.0.1 -o table
Name resolution fails
A private DNS zone only answers for VNets linked to it. Peering doesn't extend that link, or Azure-provided VM name resolution, to the remote VNet.
az network private-dns link vnet create -g rg-dns -z privatelink.blob.core.windows.net \
-n link-spoke1 --virtual-network <spoke1-vnet-resource-id> --registration-enabled false
Overlapping address spaces can't be fixed with a setting: Azure rejects peering between overlapping ranges, so one VNet has to be readdressed.
Prevent it next time
- Run a sync as part of every address space change, and put it in the same pipeline step.
- Define both links in the same Bicep or Terraform module, so one side can't be changed or deleted alone.
- Write NSG rules with the
VirtualNetworktag or include future ranges in your IP plan. - Audit peering health on a schedule with Azure Resource Graph (needs the
resource-graphCLI extension):
az graph query -q "
resources
| where type =~ 'microsoft.network/virtualnetworks'
| mv-expand peering = properties.virtualNetworkPeerings
| extend state = tostring(peering.properties.peeringState),
sync = tostring(peering.properties.peeringSyncLevel)
| where state != 'Connected' or sync != 'FullyInSync'
| project vnet = name, resourceGroup, peering = tostring(peering.name), state, sync"
Investigate any row, including a blank sync value.