top of page
Jul 24
18 min read

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:

  1. Create a Standard Azure Virtual WAN.

  2. Deploy a regional virtual hub.

  3. Add a site-to-site VPN gateway to the hub.

  4. Connect Azure VNets to Virtual WAN.

  5. Emulate an on-premises branch in Azure.

  6. Configure an active-active Azure VPN Gateway.

  7. Create redundant Virtual WAN VPN sites.

  8. Establish site-to-site IPsec connectivity.

  9. Inspect Virtual WAN effective routes.

  10. Validate branch-to-spoke connectivity.

  11. Validate spoke-to-spoke transit.

  12. Test VPN redundancy.

  13. Create an optional ExpressRoute gateway.

  14. Troubleshoot routing and tunnel problems.

  15. 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:

  1. Use Standard Virtual WAN for VNet transit and ExpressRoute integration.

  2. Use unique, non-overlapping address spaces.

  3. Connect workload VNets directly to the managed virtual hub.

  4. Represent both public IPs of an active-active branch gateway.

  5. Configure matching pre-shared keys on both sides.

  6. Validate routes and workload traffic, not only the VPN connection state.

  7. Treat ExpressRoute as a separate provider-backed service.

  8. Delete the billable workshop resources after testing.

 
 
 

Comments


bottom of page