Skip to main content

Virtual Network Design

VNet design is one of the first conversations in any Azure engagement. The decisions — address space, subnet structure, peering topology — are made before most workloads exist, which means they have to account for what's coming. I've inherited environments with 192.168.x.x address spaces assigned to Azure VNets, which instantly clashes with VPN clients on the same subnet. Getting this right takes 30 minutes. Fixing it later takes weeks.

Address space planning

Step 1: IP Address Planning

The non-negotiables I start with:

  1. Avoid overlaps with on-premises and other Azure networks
  2. Plan for growth (allocate larger ranges than current needs)
  3. Use RFC 1918 private IP ranges
  4. Document all allocations

Recommended Allocation Strategy:

Corporate IP Space: 10.0.0.0/8
├── Production: 10.0.0.0/12 (10.0.0.0 - 10.15.255.255)
├── Development: 10.16.0.0/12 (10.16.0.0 - 10.31.255.255)
├── Testing: 10.32.0.0/12 (10.32.0.0 - 10.47.255.255)
└── Sandbox: 10.48.0.0/12 (10.48.0.0 - 10.63.255.255)

Region: East US Production
└── Hub VNet: 10.0.0.0/16
└── Spoke 1 (App1): 10.1.0.0/16
└── Spoke 2 (App2): 10.2.0.0/16

Step 2: VNet and Subnet Sizing

VNet Size Recommendations:

EnvironmentVNet SizeUsable IPsUse Case
Large Enterprise Hub/16~65,000Centralized services
Application Spoke/16~65,000Multi-tier app
Small Workload/20~4,000Single app, few resources
Development/20~4,000Dev/test environments

Subnet Size Recommendations:

PurposeSizeUsable IPsNotes
AzureFirewallSubnet/2659Minimum /26 required
GatewaySubnet/2727Minimum /27, /26 recommended
AzureBastionSubnet/2659Minimum /26 required
AKS Node Pool/22 - /201,019 - 4,091Depends on pod count
Web Tier/2425110-100 VMs
App Tier/2425110-100 VMs
Data Tier/251235-50 databases
Management/2727Jump boxes, monitoring

Azure Reserved IPs per Subnet:

  • .0 - Network address
  • .1 - Default gateway (Azure)
  • .2 - Azure DNS mapping
  • .3 - Azure reserved
  • .255 - Broadcast address (last IP)

Example: Subnet 10.1.1.0/24 has 251 usable IPs (10.1.1.4 - 10.1.1.254)

Step 3: Subnet Design Patterns

Three-Tier Application

VNet: 10.1.0.0/16 (vnet-app-prod-eastus)
├── snet-web: 10.1.1.0/24 (Web/Frontend)
├── snet-app: 10.1.2.0/24 (Application Logic)
├── snet-data: 10.1.3.0/24 (Databases)
├── snet-integration:10.1.4.0/24 (App Service VNet Integration)
└── snet-privatelink:10.1.5.0/24 (Private Endpoints)

Hub VNet Structure

Hub VNet: 10.0.0.0/16 (vnet-hub-prod-eastus)
├── AzureFirewallSubnet: 10.0.0.0/26 (Firewall)
├── GatewaySubnet: 10.0.1.0/27 (VPN/ExpressRoute)
├── AzureBastionSubnet: 10.0.2.0/26 (Bastion)
├── snet-management: 10.0.3.0/24 (Jump boxes)
└── snet-shared-services: 10.0.4.0/24 (DNS, monitoring)

Step 4: VNet Peering Design

Hub-Spoke Topology (Recommended):

        Hub VNet (10.0.0.0/16)
/ | \
/ | \
/ | \
Spoke 1 Spoke 2 Spoke 3
(10.1.0.0/16)(10.2.0.0/16)(10.3.0.0/16)

Peering Options:

  • AllowVirtualNetworkAccess: Enable communication
  • AllowForwardedTraffic: Allow traffic from other networks
  • AllowGatewayTransit: Share VPN/ExpressRoute (Hub only)
  • UseRemoteGateways: Use hub's gateway (Spokes only)

No Transitive Routing: Spokes cannot communicate directly. Traffic must route through hub (Azure Firewall or NVA).

Terraform Example

# Variables for IP planning
variable "hub_vnet_cidr" {
default = "10.0.0.0/16"
}

variable "spoke_prod_cidr" {
default = "10.1.0.0/16"
}

# Hub VNet
resource "azurerm_virtual_network" "hub" {
name = "vnet-hub-prod-${var.region}"
resource_group_name = azurerm_resource_group.hub.name
location = var.region
address_space = [var.hub_vnet_cidr]

dns_servers = ["10.0.4.4", "10.0.4.5"] # Custom DNS if needed

tags = {
Environment = "Production"
Purpose = "Hub"
CostCenter = "IT-Network"
}
}

# Hub Subnets
resource "azurerm_subnet" "firewall" {
name = "AzureFirewallSubnet" # Name is mandatory
resource_group_name = azurerm_resource_group.hub.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = ["10.0.0.0/26"]
}

resource "azurerm_subnet" "gateway" {
name = "GatewaySubnet" # Name is mandatory
resource_group_name = azurerm_resource_group.hub.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = ["10.0.1.0/27"]
}

resource "azurerm_subnet" "bastion" {
name = "AzureBastionSubnet" # Name is mandatory
resource_group_name = azurerm_resource_group.hub.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = ["10.0.2.0/26"]
}

# Spoke VNet
resource "azurerm_virtual_network" "spoke_prod" {
name = "vnet-spoke-prod-${var.region}"
resource_group_name = azurerm_resource_group.spoke.name
location = var.region
address_space = [var.spoke_prod_cidr]

tags = {
Environment = "Production"
Purpose = "Application Workloads"
Application = "MyApp"
}
}

# Application Tier Subnets
locals {
spoke_subnets = {
web = {
name = "snet-web"
prefix = "10.1.1.0/24"
}
app = {
name = "snet-app"
prefix = "10.1.2.0/24"
}
data = {
name = "snet-data"
prefix = "10.1.3.0/24"
# Disable service endpoints for data tier
service_endpoints = ["Microsoft.Sql", "Microsoft.Storage"]
}
}
}

resource "azurerm_subnet" "spoke" {
for_each = local.spoke_subnets

name = each.value.name
resource_group_name = azurerm_resource_group.spoke.name
virtual_network_name = azurerm_virtual_network.spoke_prod.name
address_prefixes = [each.value.prefix]

service_endpoints = lookup(each.value, "service_endpoints", [])
}

# VNet Peering: Hub to Spoke
resource "azurerm_virtual_network_peering" "hub_to_spoke_prod" {
name = "peer-hub-to-spoke-prod"
resource_group_name = azurerm_resource_group.hub.name
virtual_network_name = azurerm_virtual_network.hub.name
remote_virtual_network_id = azurerm_virtual_network.spoke_prod.id

allow_virtual_network_access = true
allow_forwarded_traffic = true
allow_gateway_transit = true # Hub shares gateway
use_remote_gateways = false
}

# VNet Peering: Spoke to Hub
resource "azurerm_virtual_network_peering" "spoke_prod_to_hub" {
name = "peer-spoke-prod-to-hub"
resource_group_name = azurerm_resource_group.spoke.name
virtual_network_name = azurerm_virtual_network.spoke_prod.name
remote_virtual_network_id = azurerm_virtual_network.hub.id

allow_virtual_network_access = true
allow_forwarded_traffic = true
allow_gateway_transit = false
use_remote_gateways = false # Set true after gateway exists

depends_on = [
azurerm_virtual_network_peering.hub_to_spoke_prod
]
}

# User Defined Route (Force traffic through Firewall)
resource "azurerm_route_table" "spoke" {
name = "rt-spoke-prod"
resource_group_name = azurerm_resource_group.spoke.name
location = var.region
disable_bgp_route_propagation = false

route {
name = "to-internet-via-firewall"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.hub.ip_configuration[0].private_ip_address
}

route {
name = "to-onprem-via-firewall"
address_prefix = "192.168.0.0/16" # On-prem CIDR
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.hub.ip_configuration[0].private_ip_address
}
}

# Associate Route Table to Subnets
resource "azurerm_subnet_route_table_association" "spoke" {
for_each = azurerm_subnet.spoke

subnet_id = each.value.id
route_table_id = azurerm_route_table.spoke.id
}

BGP propagation and NVA routing

When I route 0.0.0.0/0 to an NVA (FortiGate, Azure Firewall, etc.) and disable BGP route propagation on the route table, every route to spokes, internet, and Azure services must be manually configured. This gives total control over traffic -- nothing flows unless I've explicitly allowed it -- but it makes network rule management significantly harder as the environment grows.

BGP propagation disabled behavior

Route table with BGP disabled

I've used this pattern in environments where the security team wants zero implicit connectivity. It works, but the operational cost is real -- every new spoke, every new PaaS service that needs a service tag route, every Azure service that needs a management endpoint becomes a manual route table entry. I prefer keeping BGP propagation enabled and controlling traffic at the firewall/NVA policy layer instead.

Best Practices

IP Address Planning

Do:

  • Use /16 for VNets to allow for growth
  • Document IP allocations in a central spreadsheet
  • Reserve IP ranges for future regions and environments
  • Use consistent CIDR allocation across environments
  • Test for overlaps before deployment

Avoid:

  • Overlapping IP ranges with on-premises
  • Using public IP ranges for private networks
  • Allocating too small subnets that can't grow
  • I avoid 192.168.x.x because it clashes with most home networks and VPN clients

Subnet Design

Do:

  • Create separate subnets for each tier (web, app, data)
  • Use /24 or larger for application subnets
  • Plan for at least 2x current capacity
  • Use descriptive naming (snet-web, snet-app, snet-data)
  • Apply NSGs to every subnet

Avoid:

  • Putting all resources in one subnet
  • Using /29 or smaller (too few IPs, only 3 usable)
  • Mixing security zones in same subnet
  • Creating too many tiny subnets (management overhead)

VNet Peering

Do:

  • Use hub-spoke for enterprise deployments
  • Enable forwarded traffic in hub
  • Document peering relationships
  • Use resource locks on peering connections

Avoid:

  • Creating spoke-to-spoke peerings (use hub routing)
  • Full mesh peering (doesn't scale, use Virtual WAN)
  • Forgetting bidirectional peering requirements
  • Deleting peerings without impact analysis

Routing

Do:

  • Force tunnel traffic through Azure Firewall in hub
  • Use User Defined Routes (UDR) for traffic control
  • Document routing decisions
  • Test failover scenarios

Avoid:

  • Allowing direct internet access from data tier
  • Creating routing loops
  • Bypassing security controls with 0.0.0.0/0 to Internet

Things to Avoid

Don't change VNet address space after deployment (breaks resources) ❌ Don't create VNets without planning for hybrid connectivity ❌ Don't use overlapping CIDRs across environments ❌ Don't deploy production without route tables ❌ Don't mix environments (prod/dev) in same VNet ❌ Don't create more than 500 peerings per VNet ❌ Don't use default names (vnet1, subnet1) ❌ Don't skip DNS planning (use Private DNS Zones) ❌ Don't forget to enable DDoS Protection on production VNets

CIDR Calculation Reference

CIDRSubnet MaskTotal IPsUsable IPs (Azure)Use Case
/16255.255.0.065,536~65,000Large VNet
/20255.255.240.04,096~4,000Medium VNet
/22255.255.252.01,024~1,000AKS nodes
/24255.255.255.0256251Standard subnet
/25255.255.255.128128123Small subnet
/26255.255.255.1926459Firewall/Bastion
/27255.255.255.2243227Gateway subnet
/28255.255.255.2401611Very small
/29255.255.255.24883Not recommended

Advanced Scenarios

Multi-Region Hub-Spoke

East US Hub (10.0.0.0/16) <--Global VNet Peering--> West US Hub (10.10.0.0/16)
| |
+-- Spoke 1 (10.1.0.0/16) +-- Spoke 4 (10.11.0.0/16)
+-- Spoke 2 (10.2.0.0/16) +-- Spoke 5 (10.12.0.0/16)
+-- Spoke 3 (10.3.0.0/16)

Global VNet Peering Considerations:

  • Cross-region bandwidth charges apply
  • Latency depends on region distance
  • Use for disaster recovery and geo-distribution

Azure Virtual WAN (For Very Large Deployments)

Consider Virtual WAN when:

  • 50+ branch locations
  • Multiple Azure regions
  • Need managed routing
  • SD-WAN integration

Monitoring & Troubleshooting

Verify VNet Configuration

# List all VNets
az network vnet list -o table

# Show VNet details
az network vnet show \
--resource-group rg-network \
--name vnet-hub-prod-eastus

# List subnets
az network vnet subnet list \
--resource-group rg-network \
--vnet-name vnet-hub-prod-eastus \
-o table

# Check peering status
az network vnet peering list \
--resource-group rg-network \
--vnet-name vnet-hub-prod-eastus \
-o table

Check Effective Routes

# See routes for a specific NIC
az network nic show-effective-route-table \
--resource-group rg-app \
--name nic-vm-web-01 \
-o table