About this session
| Session | Details |
|---|---|
| Duration | 2–3 hours, including exercises |
| You will build | Two peered VNets, two small Linux VMs without public IPs, an NSG with your own rules, and a storage account reachable only through a private endpoint with private DNS |
| You will learn | Address spaces and subnets, why VNets are isolated, peering, NSG rules and default rules, effective rules and IP flow verify, testing from inside a VM with Run Command, private endpoints, private DNS zones and VNet links, disabling public network access |
| Prerequisites | An Azure subscription where you can create resources; Azure CLI installed and az login done; basic networking (IP addresses, CIDR, ports) |
Note
The lab uses two B1s VMs and one private endpoint for a couple of hours: a small cost. Delete the resource group at the end (Module 10). Check current pricing for your region if cost matters.
Module 1: the ideas you need first
| Building block | What it is |
|---|---|
| VNet | A private network in one region with one or more address spaces, e.g. 10.10.0.0/16 |
| Subnet | A slice of the VNet, e.g. 10.10.1.0/24. Azure reserves 5 addresses in each |
| NSG | A stateful firewall rule list attached to a subnet or NIC |
| Peering | A private, low-latency link between two VNets. Not transitive |
| Private endpoint | A NIC with a private IP in your subnet that connects to one PaaS resource (e.g. a storage account's blob service) |
| Private DNS zone | A DNS zone visible only to linked VNets, e.g. privatelink.blob.core.windows.net |
Two VNets are isolated by default: no traffic flows between them until you peer them (or connect them another way).
What you will build:
vnet-app 10.10.0.0/16 vnet-shared 10.20.0.0/16
┌──────────────────────────────┐ peering ┌───────────────────────┐
│ snet-app 10.10.1.0/24 [NSG] │◄────────────►│ snet-shared 10.20.1.0/24│
│ vm-app (no public IP) │ │ vm-shared (no public IP)│
│ snet-pe 10.10.2.0/24 │ └───────────────────────┘
│ pe-blob ──► storage account (public access off)
└──────────────────────────────┘
private DNS zone: privatelink.blob.core.windows.net (linked to vnet-app)
Module 2: resource group and VNets
RG=rg-learn-net
LOC=uksouth # pick a region near you
az group create -n $RG -l $LOC
az network vnet create -g $RG -n vnet-app --address-prefixes 10.10.0.0/16 \
--subnet-name snet-app --subnet-prefixes 10.10.1.0/24
az network vnet subnet create -g $RG --vnet-name vnet-app -n snet-pe --address-prefixes 10.10.2.0/24
az network vnet create -g $RG -n vnet-shared --address-prefixes 10.20.0.0/16 \
--subnet-name snet-shared --subnet-prefixes 10.20.1.0/24
az network vnet list -g $RG --query "[].{name:name, space:addressSpace.addressPrefixes[0]}" -o table
Tip
Plan address spaces so they never overlap with each other or with on-premises ranges. Overlapping VNets can't be peered, and fixing it later means rebuilding.
Module 3: two VMs without public IPs
for v in app shared; do
az vm create -g $RG -n vm-$v --image Ubuntu2204 --size Standard_B1s \
--vnet-name vnet-$v --subnet snet-$v \
--public-ip-address "" --nsg "" \
--admin-username azureuser --generate-ssh-keys --no-wait
done
az vm wait -g $RG -n vm-app --created && az vm wait -g $RG -n vm-shared --created
az vm list-ip-addresses -g $RG \
--query "[].{vm:virtualMachine.name, ip:virtualMachine.network.privateIpAddresses[0]}" -o table
Note the private IPs (usually 10.10.1.4 and 10.20.1.4). With no public IP you can't SSH in from your laptop, so you'll test with Run Command, which runs a script inside the VM through the Azure agent:
run() { az vm run-command invoke -g $RG -n $1 --command-id RunShellScript --scripts "$2" \
--query "value[0].message" -o tsv; }
run vm-app "hostname; ip -4 addr show eth0 | grep inet"
Module 4: prove the VNets are isolated
Start a small web server on vm-app, then try to reach it from vm-shared:
run vm-app "nohup python3 -m http.server 8080 >/dev/null 2>&1 & sleep 1; ss -ltn | grep 8080"
run vm-shared "nc -zvw3 10.10.1.4 8080; nc -zvw3 10.10.1.4 22"
Both fail with a timeout: there's no route between the two address spaces.
Module 5: peer the VNets
Peering is created from both sides:
az network vnet peering create -g $RG -n app-to-shared --vnet-name vnet-app \
--remote-vnet vnet-shared --allow-vnet-access
az network vnet peering create -g $RG -n shared-to-app --vnet-name vnet-shared \
--remote-vnet vnet-app --allow-vnet-access
az network vnet peering list -g $RG --vnet-name vnet-app --query "[].{name:name,state:peeringState}" -o table
peeringState must be Connected on both sides. Test again:
run vm-shared "nc -zvw3 10.10.1.4 8080; nc -zvw3 10.10.1.4 22; curl -s -o /dev/null -w '%{http_code}\n' http://10.10.1.4:8080/"
Now it works. Look at the routes Azure created on vm-app's NIC:
NIC=$(az vm show -g $RG -n vm-app --query "networkProfile.networkInterfaces[0].id" -o tsv)
az network nic show-effective-route-table --ids $NIC -o table
You'll see a route for 10.20.0.0/16 with next hop type VNetPeering.
Module 6: control traffic with an NSG
No NSG is attached yet, so everything inside the peered networks is allowed. Create one for snet-app that allows only SSH from vnet-shared and denies other traffic from the virtual network:
az network nsg create -g $RG -n nsg-app
az network nsg rule create -g $RG --nsg-name nsg-app -n allow-ssh-from-shared \
--priority 100 --direction Inbound --access Allow --protocol Tcp \
--source-address-prefixes 10.20.0.0/16 --destination-port-ranges 22
az network nsg rule create -g $RG --nsg-name nsg-app -n deny-vnet-inbound \
--priority 200 --direction Inbound --access Deny --protocol '*' \
--source-address-prefixes VirtualNetwork --destination-address-prefixes '*' --destination-port-ranges '*'
az network vnet subnet update -g $RG --vnet-name vnet-app -n snet-app --network-security-group nsg-app
Rules are checked by priority, lowest number first, and the first match wins. Every NSG also has default rules at priority 65000+ (AllowVnetInBound, AllowAzureLoadBalancerInBound, DenyAllInBound), which is why traffic between peered VNets was allowed before.
Test:
run vm-shared "nc -zvw3 10.10.1.4 22; nc -zvw3 10.10.1.4 8080"
Port 22 succeeds; 8080 now times out. Compare with a port where nothing listens but the NSG allows (after the exercises): you'd get connection refused immediately. Timeout usually means a firewall dropped it; refused means it got there and nothing was listening.
See which rule decided, without guessing:
az network nic list-effective-nsg --ids $NIC \
--query "value[0].effectiveSecurityRules[?direction=='Inbound'].{name:name,prio:priority,access:access,src:sourceAddressPrefix,port:destinationPortRange}" -o table
az network watcher test-ip-flow -g $RG --vm vm-app --direction Inbound --protocol TCP \
--local 10.10.1.4:8080 --remote 10.20.1.4:50000
test-ip-flow answers Allow or Deny and names the rule. It's the fastest way to settle "is the NSG blocking it?" (Network Watcher is enabled automatically in most subscriptions; if the command complains, run az network watcher configure -g NetworkWatcherRG -l $LOC --enabled true.)
Module 7: a storage account behind a private endpoint
SA=stlearn$RANDOM$RANDOM
az storage account create -g $RG -n $SA -l $LOC --sku Standard_LRS --min-tls-version TLS1_2
SA_ID=$(az storage account show -g $RG -n $SA --query id -o tsv)
az network private-endpoint create -g $RG -n pe-blob --vnet-name vnet-app --subnet snet-pe \
--private-connection-resource-id $SA_ID --group-id blob --connection-name pe-blob-conn
az network private-dns zone create -g $RG -n privatelink.blob.core.windows.net
az network private-dns link vnet create -g $RG -z privatelink.blob.core.windows.net \
-n link-vnet-app -v vnet-app -e false
az network private-endpoint dns-zone-group create -g $RG --endpoint-name pe-blob -n default \
--private-dns-zone privatelink.blob.core.windows.net --zone-name blob
The zone group creates an A record for your account in the private zone and keeps it in sync with the endpoint's IP.
Module 8: watch DNS do its job (and fail)
run vm-app "nslookup $SA.blob.core.windows.net"
run vm-shared "nslookup $SA.blob.core.windows.net"
- From
vm-app: the name resolves through aprivatelinkCNAME to 10.10.2.x, the private endpoint. - From
vm-shared: it resolves to a public IP, because the private zone isn't linked tovnet-shared. Peering doesn't share DNS zones.
This is the single most common private endpoint problem. Fix it by linking the zone to the other VNet:
az network private-dns link vnet create -g $RG -z privatelink.blob.core.windows.net \
-n link-vnet-shared -v vnet-shared -e false
run vm-shared "nslookup $SA.blob.core.windows.net"
Now both resolve privately. (In real hub-and-spoke designs, the zones live in the hub and are linked to every spoke, or resolved through a DNS forwarder or Private DNS Resolver; see private endpoint DNS.)
Module 9: turn off public access
az storage account update -g $RG -n $SA --public-network-access Disabled
# From your laptop (public internet) - should now fail
az storage container list --account-name $SA --auth-mode key -o table
The request from your laptop is refused because public network access is disabled. From inside vm-app, the private endpoint still works:
run vm-app "curl -s -o /dev/null -w '%{http_code}\n' https://$SA.blob.core.windows.net/?comp=list"
You'll get an HTTP status from the storage service (for example 400 or 403 because the request has no credentials). The point is that the connection reached it privately; with no private path you'd get a timeout or connection error instead.
Module 10: clean up
az group delete -n $RG --yes --no-wait
Everything (VMs, disks, NICs, VNets, NSG, private endpoint, DNS zone, storage) is in the one resource group, so this removes it all.
Exercises
- Add an NSG rule that allows port 8080 from
10.20.1.4/32only, at priority 150. Prove it withncandtest-ip-flow. Why must its priority be lower than 200? - Create a third VNet
vnet-spoke2peered only tovnet-shared. Canvnet-spoke2reachvnet-app? Explain using the word "transitive". - Remove the DNS zone group from
pe-bloband recreate the A record by hand. What breaks if the endpoint's IP ever changes? - Add an outbound NSG rule on
snet-appthat deniesInternetand allowsStorage, using service tags. What still works fromvm-app? - Attach an NSG to
vm-app's NIC as well as the subnet. In which order are subnet and NIC NSGs evaluated for inbound traffic?
Knowledge check
- Why couldn't the two VMs talk before peering?
- In what order are NSG rules evaluated, and what happens after a match?
- Why was traffic between peered VNets allowed before you added your own NSG rules?
- What's the practical difference between a timeout and "connection refused" when testing a port?
- Why did the storage name resolve to a public IP from
vm-sharedat first? - What does disabling public network access on the storage account change?
Answers
- VNets are isolated by default; there was no route between their address spaces until peering added one.
- By priority, lowest number first. The first matching rule decides and evaluation stops.
- The default rule
AllowVnetInBound(priority 65000) allows traffic from theVirtualNetworkservice tag, which includes peered VNets. - A timeout usually means something (NSG, firewall, routing) silently dropped the packets. Refused means the packet reached the host and nothing was listening on that port.
- The private DNS zone was linked only to
vnet-app, sovm-sharedused public DNS. Peering doesn't share DNS zones. - The account rejects connections arriving over its public endpoint; only private endpoints (and any allowed exceptions) can reach it.
Summary
- VNets are isolated; peering connects two of them, non-transitively.
- NSGs are stateful, priority-ordered rule lists with default rules you must know.
- Use effective routes, effective NSG rules and
test-ip-flowinstead of guessing. - Private endpoints need private DNS that every client VNet can resolve.
- Once private access works, disable public network access.
Further reading
- Microsoft Learn: Virtual network concepts and best practices, Network security groups: how network security groups filter traffic, Azure Private Endpoint DNS configuration.
- On this site: NSG blocking traffic cheat sheet, private endpoint DNS, Azure resource locks.