
Azure Virtual WAN provides a Microsoft-managed networking platform for connecting Azure virtual networks, branch locations, remote users, and ExpressRoute circuits through regional virtual hubs.
In this workshop, you will build a complete Azure Virtual WAN environment from the command line using PowerShell 7 and Azure CLI.
No Azure portal configuration is required.
The workshop creates:
one Standard Azure Virtual WAN;
one regional virtual hub;
one Virtual WAN site-to-site VPN gateway;
two Azure spoke VNets;
one emulated branch VNet;
one active-active Azure VPN Gateway;
two redundant Virtual WAN VPN sites;
two branch-side VPN connections;
optional Linux test VMs;
optional ExpressRoute gateway integration.
The emulated branch represents an on-premises datacenter or remote office. Because no physical VPN appliance is available, an Azure VPN Gateway is used as the branch VPN device.
Cost warningAzure Virtual WAN hubs, VPN gateways, public IP addresses, ExpressRoute gateways, and virtual machines are billable.Gateway creation and deletion can take a significant amount of time. Complete the cleanup section when the workshop is finished.
Workshop overview
Item | Configuration |
Level | Intermediate to advanced |
Deployment method | PowerShell 7 and Azure CLI |
Azure region | West Europe |
Virtual WAN type | Standard |
Virtual hubs | One |
Azure spoke VNets | Two |
Branch simulation | Azure VNet with active-active VPN Gateway |
VPN protocol | Route-based IPsec/IKEv2 |
Redundancy | Two branch public IPs and two VPN sites |
Routing | Virtual WAN managed routing |
ExpressRoute | Optional existing circuit integration |
Validation | Effective routes and Linux test VMs |
Estimated completion time:
Infrastructure deployment: 60–90 minutes
Connectivity validation: 20–30 minutes
Cleanup: 30–90 minutes
Architecture
Optional ExpressRoute
circuit
│
│
┌───────────▼───────────┐
│ Virtual WAN │
│ ExpressRoute Gateway │
└───────────┬───────────┘
│
┌────────────────────────────────▼─────────────────────────────────┐
│ Azure Virtual WAN │
│ │
│ Virtual WAN: vwan-deepdive-weu │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Virtual Hub: vhub-deepdive-weu │ │
│ │ Address prefix: 10.0.0.0/23 │ │
│ │ │ │
│ │ Virtual WAN VPN Gateway │ │
│ │ Active-active │ │
│ └─────────────┬───────────────────────┬──────────────────────┘ │
│ │ │ │
└────────────────┼───────────────────────┼─────────────────────────┘
│ │
IPsec/IKEv2 IPsec/IKEv2
│ │
┌─────────▼───────────────────────▼──────────┐
│ Emulated branch VNet │
│ 172.16.0.0/16 │
│ │
│ Active-active Azure VPN Gateway │
│ Two public IP addresses │
│ Two Virtual WAN VPN sites │
│ Two branch VPN connections │
│ │
│ Workload subnet: 172.16.1.0/24 │
└───────────────────────────────────────────┘
Virtual hub VNet connections
│ │
┌──────────▼───┐ ┌────▼────────────┐
│ App VNet │ │ Data VNet │
│ 10.10.0.0/16 │ │ 10.20.0.0/16 │
│ │ │ │
│ 10.10.1.0/24 │ │ 10.20.1.0/24 │
└──────────────┘ └─────────────────┘
Address plan
Component | Address range |
Virtual hub | 10.0.0.0/23 |
Application VNet | 10.10.0.0/16 |
Application subnet | 10.10.1.0/24 |
Data VNet | 10.20.0.0/16 |
Data subnet | 10.20.1.0/24 |
Branch VNet | 172.16.0.0/16 |
Branch workload subnet | 172.16.1.0/24 |
Branch GatewaySubnet | 172.16.255.0/27 |
All network prefixes must be unique and non-overlapping.
What you will learn
By completing this workshop, you will learn how to:
Create a Standard Azure Virtual WAN.
Deploy a regional virtual hub.
Add a site-to-site VPN gateway to the hub.
Connect Azure VNets to Virtual WAN.
Emulate an on-premises branch in Azure.
Configure an active-active Azure VPN Gateway.
Create redundant Virtual WAN VPN sites.
Establish site-to-site IPsec connectivity.
Inspect Virtual WAN effective routes.
Validate branch-to-spoke connectivity.
Validate spoke-to-spoke transit.
Test VPN redundancy.
Create an optional ExpressRoute gateway.
Troubleshoot routing and tunnel problems.
Remove all workshop resources.
1. Verify the prerequisites
You need:
PowerShell 7;
Azure CLI;
an Azure subscription;
sufficient permissions to create networking and compute resources;
quota for Azure VPN Gateway;
optional VM quota for connectivity testing.
Verify PowerShell:
pwsh --version
Verify Azure CLI:
az version
Confirm that the required commands are available:
$RequiredCommands = @(
"pwsh"
"az"
)
foreach ($Command in $RequiredCommands) {
$ResolvedCommand = Get-Command `
-Name $Command `
-ErrorAction SilentlyContinue
if (-not $ResolvedCommand) {
throw "Required command '$Command' was not found."
}
[pscustomobject]@{
Command = $Command
Path = $ResolvedCommand.Source
}
}
2. Sign in to Azure
Authenticate with Azure:
az login
List the subscriptions available to your identity:
az account list `
--query "[].{
Name:name,
SubscriptionId:id,
TenantId:tenantId,
IsDefault:isDefault
}" `
--output table
Select the target subscription:
$SubscriptionId = "<your-subscription-id>"
az account set `
--subscription $SubscriptionId
Verify the active subscription:
az account show `
--query "{
Subscription:name,
SubscriptionId:id,
TenantId:tenantId,
User:user.name
}" `
--output table
3. Install the Virtual WAN Azure CLI extension
Check whether the Virtual WAN extension is already installed:
$VirtualWanExtension = az extension list `
--query "[?name=='virtual-wan'].name | [0]" `
--output tsv
Install or update it:
if ([string]::IsNullOrWhiteSpace($VirtualWanExtension)) {
az extension add `
--name virtual-wan `
--only-show-errors
}
else {
az extension update `
--name virtual-wan `
--only-show-errors
}
Verify the commands used in the workshop:
az network vwan --help
az network vhub --help
az network vhub connection --help
az network vpn-gateway --help
az network vpn-site --help
az network vpn-connection --help
4. Define the workshop variables
Keep the same PowerShell session open while running the workshop.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$SubscriptionId = az account show `
--query id `
--output tsv
$TenantId = az account show `
--query tenantId `
--output tsv
if ([string]::IsNullOrWhiteSpace($SubscriptionId)) {
throw "No active Azure subscription was found."
}
$Location = "westeurope"
$ResourceGroup = "rg-vwan-deepdive-weu"
# Azure Virtual WAN
$VirtualWanName = "vwan-deepdive-weu"
$VirtualHubName = "vhub-deepdive-weu"
$VirtualHubPrefix = "10.0.0.0/23"
$VwanVpnGatewayName = "vpngw-vhub-deepdive-weu"
# Application spoke
$AppVnetName = "vnet-app-vwan-weu"
$AppVnetPrefix = "10.10.0.0/16"
$AppSubnetName = "snet-app"
$AppSubnetPrefix = "10.10.1.0/24"
$AppHubConnection = "conn-vhub-to-app"
# Data spoke
$DataVnetName = "vnet-data-vwan-weu"
$DataVnetPrefix = "10.20.0.0/16"
$DataSubnetName = "snet-data"
$DataSubnetPrefix = "10.20.1.0/24"
$DataHubConnection = "conn-vhub-to-data"
# Emulated branch
$BranchVnetName = "vnet-branch-emulated-weu"
$BranchVnetPrefix = "172.16.0.0/16"
$BranchWorkloadSubnetName = "snet-branch-workload"
$BranchWorkloadSubnetPrefix = "172.16.1.0/24"
$BranchGatewaySubnetPrefix = "172.16.255.0/27"
$BranchGatewayName = "vpngw-branch-emulated-weu"
$BranchGatewayPip1 = "pip-vpngw-branch-01-weu"
$BranchGatewayPip2 = "pip-vpngw-branch-02-weu"
# Virtual WAN VPN sites
$BranchVpnSite1 = "site-branch-instance-01"
$BranchVpnSite2 = "site-branch-instance-02"
$VwanVpnConnection1 = "conn-vwan-to-branch-01"
$VwanVpnConnection2 = "conn-vwan-to-branch-02"
# Branch local network gateways
$BranchLocalGateway1 = "lng-vwan-instance-01"
$BranchLocalGateway2 = "lng-vwan-instance-02"
$BranchVpnConnection1 = "conn-branch-to-vwan-01"
$BranchVpnConnection2 = "conn-branch-to-vwan-02"
# Optional ExpressRoute gateway
$DeployExpressRouteGateway = $false
$ExpressRouteGatewayName = "ergw-vhub-deepdive-weu"
$ExpressRouteConnection = "conn-ergw-to-existing-circuit"
$ExpressRoutePrivatePeeringId = ""
# Optional Linux test VMs
$DeployTestVMs = $true
$TestNsgName = "nsg-vwan-test-vms"
$BranchVmName = "vm-branch-vwan-test"
$BranchVmNic = "nic-branch-vwan-test"
$BranchVmPip = "pip-branch-vwan-test"
$BranchVmIp = "172.16.1.4"
$AppVmName = "vm-app-vwan-test"
$AppVmNic = "nic-app-vwan-test"
$AppVmPip = "pip-app-vwan-test"
$AppVmIp = "10.10.1.4"
$DataVmName = "vm-data-vwan-test"
$DataVmNic = "nic-data-vwan-test"
$DataVmPip = "pip-data-vwan-test"
$DataVmIp = "10.20.1.4"
# Generate a random IPsec pre-shared key.
$PskBytes = [byte[]]::new(32)
[System.Security.Cryptography.RandomNumberGenerator]::Fill(
$PskBytes
)
$SharedKey = [System.Convert]::ToHexString(
$PskBytes
)
az account set `
--subscription $SubscriptionId
Review the configuration:
[pscustomobject]@{
SubscriptionId = $SubscriptionId
TenantId = $TenantId
Location = $Location
ResourceGroup = $ResourceGroup
VirtualWan = $VirtualWanName
VirtualHub = $VirtualHubName
HubPrefix = $VirtualHubPrefix
DeployTestVMs = $DeployTestVMs
DeployExpressRouteGateway = $DeployExpressRouteGateway
SharedKeyGenerated = -not [string]::IsNullOrWhiteSpace($SharedKey)
}
Security noteDo not display or store the value of $SharedKey in source control, tickets, screenshots, or workshop documentation.
5. Check for an existing lab
$ResourceGroupExists = az group exists `
--subscription $SubscriptionId `
--name $ResourceGroup `
--output tsv
if ($ResourceGroupExists -eq "true") {
throw @"
Resource group '$ResourceGroup' already exists.
Delete the previous deployment or change the resource-group name.
"@
}
6. Create the resource group
az group create `
--subscription $SubscriptionId `
--name $ResourceGroup `
--location $Location `
--tags `
workshop=vwan-deep-dive `
environment=lab `
managedBy=azure-cli `
--only-show-errors `
--output none
Verify the result:
az group show `
--subscription $SubscriptionId `
--name $ResourceGroup `
--query "{
Name:name,
Location:location,
State:properties.provisioningState,
Tags:tags
}" `
--output jsonc
7. Create the Standard Virtual WAN
az network vwan create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualWanName `
--location $Location `
--type Standard `
--branch-to-branch-traffic true `
--tags `
workshop=vwan-deep-dive `
environment=lab `
managedBy=azure-cli `
--only-show-errors `
--output none
Verify the Virtual WAN:
az network vwan show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualWanName `
--query "{
Name:name,
Type:type,
Location:location,
BranchToBranch:allowBranchToBranchTraffic,
State:provisioningState
}" `
--output jsonc
8. Create the virtual hub
az network vhub create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--location $Location `
--vwan $VirtualWanName `
--address-prefix $VirtualHubPrefix `
--sku Standard `
--hub-routing-preference ExpressRoute `
--tags `
workshop=vwan-deep-dive `
environment=lab `
managedBy=azure-cli `
--no-wait `
--only-show-errors `
--output none
The hub deployment continues while the remaining network resources are created.
9. Create the application spoke
az network vnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $AppVnetName `
--location $Location `
--address-prefixes $AppVnetPrefix `
--subnet-name $AppSubnetName `
--subnet-prefixes $AppSubnetPrefix `
--tags `
workshop=vwan-deep-dive `
role=application-spoke `
managedBy=azure-cli `
--only-show-errors `
--output none
10. Create the data spoke
az network vnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $DataVnetName `
--location $Location `
--address-prefixes $DataVnetPrefix `
--subnet-name $DataSubnetName `
--subnet-prefixes $DataSubnetPrefix `
--tags `
workshop=vwan-deep-dive `
role=data-spoke `
managedBy=azure-cli `
--only-show-errors `
--output none
11. Create the emulated branch
Create the branch VNet and workload subnet:
az network vnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVnetName `
--location $Location `
--address-prefixes $BranchVnetPrefix `
--subnet-name $BranchWorkloadSubnetName `
--subnet-prefixes $BranchWorkloadSubnetPrefix `
--tags `
workshop=vwan-deep-dive `
role=emulated-branch `
managedBy=azure-cli `
--only-show-errors `
--output none
Create the required gateway subnet:
az network vnet subnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $BranchVnetName `
--name GatewaySubnet `
--address-prefixes $BranchGatewaySubnetPrefix `
--only-show-errors `
--output none
ImportantDo not associate an NSG with GatewaySubnet.
12. Create the branch gateway public IP addresses
Create the first public IP:
az network public-ip create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayPip1 `
--location $Location `
--sku Standard `
--tier Regional `
--allocation-method Static `
--version IPv4 `
--zone 1 2 3 `
--tags `
workshop=vwan-deep-dive `
role=branch-vpn-gateway `
instance=01 `
--only-show-errors `
--output none
Create the second public IP:
az network public-ip create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayPip2 `
--location $Location `
--sku Standard `
--tier Regional `
--allocation-method Static `
--version IPv4 `
--zone 1 2 3 `
--tags `
workshop=vwan-deep-dive `
role=branch-vpn-gateway `
instance=02 `
--only-show-errors `
--output none
13. Create the active-active branch VPN gateway
az network vnet-gateway create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayName `
--location $Location `
--vnet $BranchVnetName `
--public-ip-addresses `
$BranchGatewayPip1 `
$BranchGatewayPip2 `
--gateway-type Vpn `
--vpn-type RouteBased `
--sku VpnGw2AZ `
--vpn-gateway-generation Generation2 `
--tags `
workshop=vwan-deep-dive `
role=emulated-branch `
managedBy=azure-cli `
--no-wait `
--only-show-errors `
--output none
The two public IP addresses cause the VPN gateway to operate in active-active mode.
14. Wait for the virtual hub
$HubDeadline = (Get-Date).AddHours(2)
do {
$HubDetails = az network vhub show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--query "{
ProvisioningState:provisioningState,
RoutingState:routingState
}" `
--output json |
ConvertFrom-Json
Write-Host (
"Virtual hub — Provisioning: {0}, Routing: {1}" -f `
$HubDetails.ProvisioningState,
$HubDetails.RoutingState
)
if (
$HubDetails.ProvisioningState -eq "Failed" -or
$HubDetails.RoutingState -eq "Failed"
) {
throw "The Virtual WAN hub entered a failed state."
}
if (
$HubDetails.ProvisioningState -ne "Succeeded" -or
$HubDetails.RoutingState -ne "Provisioned"
) {
Start-Sleep -Seconds 30
}
}
until (
(
$HubDetails.ProvisioningState -eq "Succeeded" -and
$HubDetails.RoutingState -eq "Provisioned"
) -or
(Get-Date) -ge $HubDeadline
)
if (
$HubDetails.ProvisioningState -ne "Succeeded" -or
$HubDetails.RoutingState -ne "Provisioned"
) {
throw "The virtual hub did not become ready before the timeout."
}
Display the hub:
az network vhub show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--query "{
Name:name,
ProvisioningState:provisioningState,
RoutingState:routingState,
AddressPrefix:addressPrefix,
RoutingPreference:hubRoutingPreference
}" `
--output table
Resolve its resource ID:
$VirtualHubId = (
az network vhub show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--query id `
--output tsv
).Trim()
15. Create the Virtual WAN VPN gateway
az network vpn-gateway create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VwanVpnGatewayName `
--location $Location `
--vhub $VirtualHubName `
--scale-unit 1 `
--tags `
workshop=vwan-deep-dive `
role=vwan-s2s-gateway `
managedBy=azure-cli `
--no-wait `
--only-show-errors `
--output none
16. Connect the Azure spokes to the hub
Resolve the VNet IDs:
$AppVnetId = (
az network vnet show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $AppVnetName `
--query id `
--output tsv
).Trim()
$DataVnetId = (
az network vnet show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $DataVnetName `
--query id `
--output tsv
).Trim()
Connect the application VNet:
az network vhub connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--name $AppHubConnection `
--remote-vnet $AppVnetId `
--internet-security false `
--no-wait `
--only-show-errors `
--output none
Connect the data VNet:
az network vhub connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--name $DataHubConnection `
--remote-vnet $DataVnetId `
--internet-security false `
--no-wait `
--only-show-errors `
--output none
Wait for both connections:
az network vhub connection wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--name $AppHubConnection `
--created `
--interval 30 `
--timeout 3600
az network vhub connection wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--name $DataHubConnection `
--created `
--interval 30 `
--timeout 3600
Display the connections:
az network vhub connection list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--query "[].{
Name:name,
State:provisioningState,
RemoteVnet:remoteVirtualNetwork.id,
InternetSecurity:enableInternetSecurity
}" `
--output table
17. Wait for both VPN gateways
Wait for the branch gateway:
Write-Host `
"Waiting for the active-active branch VPN gateway..." `
-ForegroundColor Cyan
az network vnet-gateway wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayName `
--created `
--interval 60 `
--timeout 7200
Wait for the Virtual WAN VPN gateway:
Write-Host `
"Waiting for the Virtual WAN VPN gateway..." `
-ForegroundColor Cyan
az network vpn-gateway wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VwanVpnGatewayName `
--created `
--interval 30 `
--timeout 7200
Validate the branch gateway:
az network vnet-gateway show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayName `
--query "{
Name:name,
State:provisioningState,
GatewayType:gatewayType,
VpnType:vpnType,
ActiveActive:activeActive,
Sku:sku.name
}" `
--output table
Validate the Virtual WAN VPN gateway:
az network vpn-gateway show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VwanVpnGatewayName `
--query "{
Name:name,
State:provisioningState,
ScaleUnit:vpnGatewayScaleUnit,
VirtualHub:virtualHub.id
}" `
--output jsonc
Expected branch gateway state:
ActiveActive true
State Succeeded
18. Resolve the VPN tunnel endpoints
Get the two branch public IP addresses:
$BranchGatewayIp1 = (
az network public-ip show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayPip1 `
--query ipAddress `
--output tsv
).Trim()
$BranchGatewayIp2 = (
az network public-ip show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchGatewayPip2 `
--query ipAddress `
--output tsv
).Trim()
Get the Virtual WAN gateway tunnel IP addresses:
$VwanTunnelIps = @(
az network vpn-gateway show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VwanVpnGatewayName `
--query "bgpSettings.bgpPeeringAddresses[].tunnelIpAddresses[]" `
--output tsv |
ForEach-Object {
$_.Trim()
} |
Where-Object {
-not [string]::IsNullOrWhiteSpace($_)
}
)
if ($VwanTunnelIps.Count -lt 2) {
throw @"
Expected two Virtual WAN VPN gateway tunnel IP addresses.
Found: $($VwanTunnelIps.Count)
"@
}
$VwanTunnelIp1 = $VwanTunnelIps[0]
$VwanTunnelIp2 = $VwanTunnelIps[1]
Display all endpoints:
@(
[pscustomobject]@{
Endpoint = "Branch gateway instance 1"
IP = $BranchGatewayIp1
}
[pscustomobject]@{
Endpoint = "Branch gateway instance 2"
IP = $BranchGatewayIp2
}
[pscustomobject]@{
Endpoint = "Virtual WAN gateway instance 1"
IP = $VwanTunnelIp1
}
[pscustomobject]@{
Endpoint = "Virtual WAN gateway instance 2"
IP = $VwanTunnelIp2
}
) |
Format-Table -AutoSize
19. Create the Virtual WAN VPN sites
Each active-active branch gateway public IP is represented by a separate Virtual WAN VPN site.
Create the first site:
az network vpn-site create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnSite1 `
--location $Location `
--virtual-wan $VirtualWanName `
--ip-address $BranchGatewayIp1 `
--address-prefixes $BranchVnetPrefix `
--device-vendor Microsoft `
--device-model "Azure VPN Gateway" `
--link-speed 100 `
--with-link true `
--tags `
workshop=vwan-deep-dive `
role=emulated-branch `
gatewayInstance=01 `
--only-show-errors `
--output none
Create the second site:
az network vpn-site create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnSite2 `
--location $Location `
--virtual-wan $VirtualWanName `
--ip-address $BranchGatewayIp2 `
--address-prefixes $BranchVnetPrefix `
--device-vendor Microsoft `
--device-model "Azure VPN Gateway" `
--link-speed 100 `
--with-link true `
--tags `
workshop=vwan-deep-dive `
role=emulated-branch `
gatewayInstance=02 `
--only-show-errors `
--output none
Resolve the site resource IDs:
$BranchVpnSite1Id = (
az network vpn-site show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnSite1 `
--query id `
--output tsv
).Trim()
$BranchVpnSite2Id = (
az network vpn-site show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnSite2 `
--query id `
--output tsv
).Trim()
20. Connect the sites to the Virtual WAN VPN gateway
Create the first Virtual WAN connection:
az network vpn-gateway connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--name $VwanVpnConnection1 `
--remote-vpn-site $BranchVpnSite1Id `
--connection-bandwidth 100 `
--protocol-type IKEv2 `
--shared-key $SharedKey `
--enable-bgp false `
--internet-security false `
--with-link true `
--no-wait `
--only-show-errors `
--output none
Create the second Virtual WAN connection:
az network vpn-gateway connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--name $VwanVpnConnection2 `
--remote-vpn-site $BranchVpnSite2Id `
--connection-bandwidth 100 `
--protocol-type IKEv2 `
--shared-key $SharedKey `
--enable-bgp false `
--internet-security false `
--with-link true `
--no-wait `
--only-show-errors `
--output none
Wait for both resources:
az network vpn-gateway connection wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--name $VwanVpnConnection1 `
--created `
--interval 30 `
--timeout 3600
az network vpn-gateway connection wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--name $VwanVpnConnection2 `
--created `
--interval 30 `
--timeout 3600
21. Create the branch local network gateways
The branch local network gateways represent the two Virtual WAN VPN gateway tunnel IP addresses.
Create the first local network gateway:
az network local-gateway create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchLocalGateway1 `
--location $Location `
--gateway-ip-address $VwanTunnelIp1 `
--local-address-prefixes `
$AppVnetPrefix `
$DataVnetPrefix `
--tags `
workshop=vwan-deep-dive `
remoteGateway=vwan-instance-01 `
--only-show-errors `
--output none
Create the second local network gateway:
az network local-gateway create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchLocalGateway2 `
--location $Location `
--gateway-ip-address $VwanTunnelIp2 `
--local-address-prefixes `
$AppVnetPrefix `
$DataVnetPrefix `
--tags `
workshop=vwan-deep-dive `
remoteGateway=vwan-instance-02 `
--only-show-errors `
--output none
22. Create the branch VPN connections
Create the first branch connection:
az network vpn-connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnConnection1 `
--location $Location `
--vnet-gateway1 $BranchGatewayName `
--local-gateway2 $BranchLocalGateway1 `
--shared-key $SharedKey `
--tags `
workshop=vwan-deep-dive `
tunnelGroup=01 `
--only-show-errors `
--output none
Create the second branch connection:
az network vpn-connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnConnection2 `
--location $Location `
--vnet-gateway1 $BranchGatewayName `
--local-gateway2 $BranchLocalGateway2 `
--shared-key $SharedKey `
--tags `
workshop=vwan-deep-dive `
tunnelGroup=02 `
--only-show-errors `
--output none
23. Wait for the VPN tunnels
$ConnectionDeadline = (Get-Date).AddMinutes(30)
$AllConnected = $false
do {
$BranchConnection1Status = (
az network vpn-connection show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnConnection1 `
--query connectionStatus `
--output tsv
).Trim()
$BranchConnection2Status = (
az network vpn-connection show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVpnConnection2 `
--query connectionStatus `
--output tsv
).Trim()
$VwanConnection1Status = (
az network vpn-gateway connection show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--name $VwanVpnConnection1 `
--query connectionStatus `
--output tsv
).Trim()
$VwanConnection2Status = (
az network vpn-gateway connection show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--name $VwanVpnConnection2 `
--query connectionStatus `
--output tsv
).Trim()
$Statuses = [ordered]@{
BranchConnection01 = $BranchConnection1Status
BranchConnection02 = $BranchConnection2Status
VwanConnection01 = $VwanConnection1Status
VwanConnection02 = $VwanConnection2Status
}
Write-Host "`nVPN connection status" -ForegroundColor Cyan
$Statuses.GetEnumerator() |
ForEach-Object {
[pscustomobject]@{
Connection = $_.Key
Status = $_.Value
}
} |
Format-Table -AutoSize
$NotConnected = @(
$Statuses.Values |
Where-Object {
$_ -ne "Connected"
}
)
$AllConnected = $NotConnected.Count -eq 0
if (-not $AllConnected) {
Start-Sleep -Seconds 30
}
}
until (
$AllConnected -or
(Get-Date) -ge $ConnectionDeadline
)
if (-not $AllConnected) {
throw "The VPN connections did not all reach Connected state."
}
Write-Host `
"All site-to-site VPN connections are connected." `
-ForegroundColor Green
24. Validate the control plane
Display the Virtual WAN:
az network vwan show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualWanName `
--query "{
Name:name,
Type:type,
BranchToBranch:allowBranchToBranchTraffic,
State:provisioningState
}" `
--output jsonc
Display the hub:
az network vhub show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--query "{
Name:name,
State:provisioningState,
RoutingState:routingState,
AddressPrefix:addressPrefix
}" `
--output table
Display the VNet connections:
az network vhub connection list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--query "[].{
Name:name,
State:provisioningState,
RemoteVnet:remoteVirtualNetwork.id
}" `
--output table
Display the Virtual WAN VPN connections:
az network vpn-gateway connection list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--query "[].{
Name:name,
State:provisioningState,
Status:connectionStatus,
IngressBytes:ingressBytesTransferred,
EgressBytes:egressBytesTransferred
}" `
--output table
25. Inspect the effective routes
Resolve the application VNet connection ID:
$AppHubConnectionId = (
az network vhub connection show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vhub-name $VirtualHubName `
--name $AppHubConnection `
--query id `
--output tsv
).Trim()
Display its effective routes:
az network vhub get-effective-routes `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--resource-type HubVirtualNetworkConnection `
--resource-id $AppHubConnectionId `
--output jsonc
Expected route prefixes include:
10.10.0.0/16
10.20.0.0/16
172.16.0.0/16
26. Optional ExpressRoute integration
A real ExpressRoute connection requires an existing ExpressRoute circuit with Azure Private Peering.
The ExpressRoute gateway is disabled by default:
$DeployExpressRouteGateway = $false
Enable it only when needed:
$DeployExpressRouteGateway = $true
Create the Virtual WAN ExpressRoute gateway:
if ($DeployExpressRouteGateway) {
az network express-route gateway create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $ExpressRouteGatewayName `
--location $Location `
--virtual-hub $VirtualHubId `
--min-val 2 `
--max-val 2 `
--tags `
workshop=vwan-deep-dive `
role=vwan-expressroute-gateway `
managedBy=azure-cli `
--no-wait `
--only-show-errors `
--output none
}
Wait for the gateway:
if ($DeployExpressRouteGateway) {
az network express-route gateway wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $ExpressRouteGatewayName `
--created `
--interval 30 `
--timeout 7200
}
Set the Azure Private Peering resource ID:
$ExpressRoutePrivatePeeringId = @"
/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.Network/expressRouteCircuits/<circuit-name>/peerings/AzurePrivatePeering
"@.Trim()
Create the ExpressRoute connection:
if (
$DeployExpressRouteGateway -and
-not [string]::IsNullOrWhiteSpace(
$ExpressRoutePrivatePeeringId
) -and
$ExpressRoutePrivatePeeringId -notmatch "<"
) {
az network express-route gateway connection create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $ExpressRouteGatewayName `
--name $ExpressRouteConnection `
--peering $ExpressRoutePrivatePeeringId `
--internet-security false `
--no-wait `
--only-show-errors `
--output none
}
27. Deploy optional Linux test VMs
Create a shared NSG:
if ($DeployTestVMs) {
az network nsg create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $TestNsgName `
--location $Location `
--tags `
workshop=vwan-deep-dive `
role=connectivity-testing `
--only-show-errors `
--output none
}
Allow traffic from the workshop networks:
if ($DeployTestVMs) {
az network nsg rule create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--nsg-name $TestNsgName `
--name Allow-Lab-Private-Traffic `
--priority 100 `
--direction Inbound `
--access Allow `
--protocol "*" `
--source-address-prefixes `
$BranchVnetPrefix `
$AppVnetPrefix `
$DataVnetPrefix `
--source-port-ranges "*" `
--destination-address-prefixes "*" `
--destination-port-ranges "*" `
--description "Allow workshop private traffic." `
--only-show-errors `
--output none
}
Define the VM inventory:
$VmDefinitions = @(
[pscustomobject]@{
Name = $BranchVmName
Nic = $BranchVmNic
Pip = $BranchVmPip
Vnet = $BranchVnetName
Subnet = $BranchWorkloadSubnetName
PrivateIp = $BranchVmIp
}
[pscustomobject]@{
Name = $AppVmName
Nic = $AppVmNic
Pip = $AppVmPip
Vnet = $AppVnetName
Subnet = $AppSubnetName
PrivateIp = $AppVmIp
}
[pscustomobject]@{
Name = $DataVmName
Nic = $DataVmNic
Pip = $DataVmPip
Vnet = $DataVnetName
Subnet = $DataSubnetName
PrivateIp = $DataVmIp
}
)
Create the VM public IP addresses:
if ($DeployTestVMs) {
foreach ($Vm in $VmDefinitions) {
az network public-ip create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $Vm.Pip `
--location $Location `
--sku Standard `
--allocation-method Static `
--zone 1 2 3 `
--only-show-errors `
--output none
}
}
Create the NICs:
if ($DeployTestVMs) {
foreach ($Vm in $VmDefinitions) {
az network nic create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $Vm.Nic `
--location $Location `
--vnet-name $Vm.Vnet `
--subnet $Vm.Subnet `
--private-ip-address $Vm.PrivateIp `
--public-ip-address $Vm.Pip `
--network-security-group $TestNsgName `
--only-show-errors `
--output none
}
}
Create the Linux VMs:
if ($DeployTestVMs) {
foreach ($Vm in $VmDefinitions) {
az vm create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $Vm.Name `
--location $Location `
--nics $Vm.Nic `
--image Ubuntu2204 `
--size Standard_B1s `
--admin-username azureuser `
--authentication-type ssh `
--generate-ssh-keys `
--no-wait `
--only-show-errors `
--output none
}
}
Wait for the VM deployments:
if ($DeployTestVMs) {
foreach ($Vm in $VmDefinitions) {
az vm wait `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $Vm.Name `
--created `
--interval 15 `
--timeout 1800
}
}
28. Test end-to-end connectivity
Test from the branch VM:
if ($DeployTestVMs) {
az vm run-command invoke `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVmName `
--command-id RunShellScript `
--scripts @"
set -x
ping -c 4 -W 2 $AppVmIp
ping -c 4 -W 2 $DataVmIp
"@ `
--query "value[].message" `
--output tsv
}
Test from the application VM:
if ($DeployTestVMs) {
az vm run-command invoke `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $AppVmName `
--command-id RunShellScript `
--scripts @"
set -x
ping -c 4 -W 2 $BranchVmIp
ping -c 4 -W 2 $DataVmIp
"@ `
--query "value[].message" `
--output tsv
}
Test from the data VM:
if ($DeployTestVMs) {
az vm run-command invoke `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $DataVmName `
--command-id RunShellScript `
--scripts @"
set -x
ping -c 4 -W 2 $BranchVmIp
ping -c 4 -W 2 $AppVmIp
"@ `
--query "value[].message" `
--output tsv
}
Successful tests validate:
Branch → Application VNet
Branch → Data VNet
Application VNet → Branch
Data VNet → Branch
Application VNet → Data VNet
Data VNet → Application VNet
29. Inspect effective NIC routes
Branch VM:
if ($DeployTestVMs) {
az network nic show-effective-route-table `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $BranchVmNic `
--output table
}
Application VM:
if ($DeployTestVMs) {
az network nic show-effective-route-table `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $AppVmNic `
--output table
}
Data VM:
if ($DeployTestVMs) {
az network nic show-effective-route-table `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $DataVmNic `
--output table
}
30. Troubleshooting
The virtual hub is not ready
az network vhub show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $VirtualHubName `
--query "{
ProvisioningState:provisioningState,
RoutingState:routingState
}" `
--output table
Required state:
ProvisioningState Succeeded
RoutingState Provisioned
The VPN remains disconnected
Check the Virtual WAN side:
az network vpn-gateway connection list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--gateway-name $VwanVpnGatewayName `
--query "[].{
Name:name,
State:provisioningState,
Status:connectionStatus
}" `
--output table
Check the branch side:
az network vpn-connection list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{
Name:name,
State:provisioningState,
Status:connectionStatus
}" `
--output table
Typical causes include:
mismatched pre-shared keys;
incorrect branch public IP addresses;
incorrect Virtual WAN tunnel addresses;
overlapping address spaces;
incomplete gateway provisioning;
a failed Virtual WAN hub routing state.
The VPN is connected but traffic fails
Inspect the branch-side remote prefixes:
az network local-gateway list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{
Name:name,
GatewayIP:gatewayIpAddress,
RemotePrefixes:localNetworkAddressSpace.addressPrefixes
}" `
--output jsonc
Expected remote prefixes:
10.10.0.0/16
10.20.0.0/16
Inspect the Virtual WAN VPN sites:
az network vpn-site list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{
Name:name,
AddressPrefixes:addressSpace.addressPrefixes
}" `
--output jsonc
Expected branch prefix:
172.16.0.0/16
31. Cleanup
Review all resources:
az resource list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{
Name:name,
Type:type,
Location:location
}" `
--output table
Require explicit confirmation:
$DeleteConfirmation = Read-Host @"
Type the resource-group name to delete the workshop:
$ResourceGroup
"@
if ($DeleteConfirmation -ne $ResourceGroup) {
throw "Deletion cancelled."
}
Delete the complete workshop:
az group delete `
--subscription $SubscriptionId `
--name $ResourceGroup `
--yes `
--no-wait `
--only-show-errors
Monitor deletion:
$DeletionDeadline = (Get-Date).AddHours(3)
do {
$StillExists = az group exists `
--subscription $SubscriptionId `
--name $ResourceGroup `
--output tsv
Write-Host `
"Resource group exists: $StillExists"
if ($StillExists -eq "true") {
Start-Sleep -Seconds 60
}
}
until (
$StillExists -eq "false" -or
(Get-Date) -ge $DeletionDeadline
)
if ($StillExists -eq "true") {
Write-Warning @"
Resource-group deletion is still running.
Virtual WAN and VPN gateway deletion can take an extended period.
"@
}
else {
Write-Host `
"Workshop resources were deleted." `
-ForegroundColor Green
}
Clear the pre-shared key:
$SharedKey = $null
$PskBytes = $null
Summary
This workshop created the following entirely from PowerShell and Azure CLI:
Standard Azure Virtual WAN
│
└── Regional virtual hub
│
├── Application VNet connection
├── Data VNet connection
├── Virtual WAN VPN gateway
│ │
│ ├── VPN site 1
│ └── VPN site 2
│
└── Optional ExpressRoute gateway
Emulated branch VNet
│
├── Workload subnet
├── GatewaySubnet
├── Active-active Azure VPN Gateway
├── Two public IP addresses
├── Two local network gateways
└── Two VPN connection resources
The core deployment workflow was:
Create the resource group
↓
Create Standard Virtual WAN
↓
Create the virtual hub
↓
Create and connect Azure spoke VNets
↓
Deploy the Virtual WAN VPN gateway
↓
Create the active-active branch gateway
↓
Create two Virtual WAN VPN sites
↓
Create redundant IPsec connections
↓
Validate effective routes
↓
Test end-to-end connectivity
↓
Delete the workshop resource group
The most important design principles are:
Use Standard Virtual WAN for VNet transit and ExpressRoute integration.
Use unique, non-overlapping address spaces.
Connect workload VNets directly to the managed virtual hub.
Represent both public IPs of an active-active branch gateway.
Configure matching pre-shared keys on both sides.
Validate routes and workload traffic, not only the VPN connection state.
Treat ExpressRoute as a separate provider-backed service.
Delete the billable workshop resources after testing.
Comments