Azure NSGs and Route Tables for Traffic Control
You will filter traffic with Network Security Groups and steer it with user-defined routes (UDRs), including forcing traffic through a firewall appliance. By the end you will control both what is allowed and the path packets take.
These two services answer different questions, and keeping them separate in your head is the key to this lab. An NSG decides whether a packet is allowed: it is a stateful, 5-tuple filter (source IP, source port, destination IP, destination port, protocol) attached to a subnet or a NIC. A route table decides where an allowed packet goes next: it overrides Azure's built-in system routes so traffic between subnets, or out to the internet, takes the path you choose. Defense in depth in Azure networking means using both, the NSG shrinks what can talk at all, and the UDR guarantees that what can talk still passes your inspection point.
The scenario: a three-tier lab VNet with a web subnet, an app subnet, and a firewall appliance (a network virtual appliance, or NVA) in its own subnet. Internet clients may reach the web tier on 443 only; the app tier may not reach the internet directly, everything it sends must hairpin through the NVA, which inspects and forwards it.
Learning Objectives
- Write priority-ordered NSG rules for least privilege.
- Create a UDR that overrides default system routes.
- Inspect effective rules and routes on a NIC.
- Time: ~3 hours · Difficulty: Intermediate · Prereqs: a VNet with VMs.
Architecture Overview
Read the diagram left to right as a packet's life story. Inbound, the internet hits the web subnet, where the NSG is the gatekeeper: priority 100 allows TCP 443, and the implicit DenyAllInBound at priority 65500 swallows everything else. Outbound, the app subnet has a route table associated with it whose 0.0.0.0/0 route points at the firewall appliance as the next hop, so nothing leaves the subnet uninspected.
Two properties of the drawing matter more than the drawing itself. First, NSG evaluation is priority-ordered, lowest number first: the first rule that matches a flow decides it, and rules below the match are never evaluated. Second, route selection follows a fixed precedence: user-defined routes beat BGP routes, which beat system routes. That precedence is why a single UDR can redirect an entire subnet's egress without touching any VM configuration.
One more NSG property saves you half the rules you think you need: NSGs are stateful. If an inbound rule allows a flow, the return traffic of that same flow is permitted automatically regardless of your outbound rules, you do not write a matching "allow replies" rule. This is also why the lab only needs one inbound allow rule for 443 instead of two.
Environment Setup
Prerequisites
- A VNet with at least two subnets (build one in the VNet Deployment lab) and the Azure CLI configured.
- An Azure subscription with Contributor access on the resource group.
- Assumed skills: CIDR notation, what a subnet is, and basic
az networkcommands.
You will need: a VNet with at least two subnets and the Azure CLI.
Before you begin: NSG rules are evaluated by priority, lowest number first.
The lab assumes this layout. If your VNet differs, keep the names consistent or adjust every command, most "the rule does nothing" bugs in this lab are really "I edited the wrong subnet's NSG" bugs.
| Resource | Name | Address / Role |
|---|---|---|
| Resource group | lab-rg | Holds everything; commands below rely on CLI defaults |
| Virtual network | lab-vnet | 10.0.0.0/16 |
| Web subnet | web-subnet | 10.0.1.0/24, hosts web01, gets the NSG |
| App subnet | app-subnet | 10.0.2.0/24, gets the route table |
| Firewall subnet | fw-subnet | 10.0.3.0/24, NVA at 10.0.3.4 |
Sign in and pin CLI defaults so the commands below can use short -n flags without repeating --resource-group every time. This also makes the one-line command blocks throughout the page work as written.
SIGN IN AND SET CLI DEFAULTS
az login
az account set --subscription "My Subscription"
az group create -n lab-rg -l eastus
az configure --defaults group=lab-rg location=eastus
Verify the defaults took effect, az configure --list-defaults should show group=lab-rg and location=eastus. If you skip this, every az network command below fails with (MissingResourceGroup), which is a confusing error when you are actually just missing a default.
If you do not already have a suitable VNet, build the skeleton in one pass. Note that none of these subnets gets an NSG or route table at creation time, attaching them later, deliberately, is the lab:
OPTIONAL, QUICK-BUILD THE LAB VNET
az network vnet create -n lab-vnet --address-prefixes 10.0.0.0/16 \
--subnet-name web-subnet --subnet-prefixes 10.0.1.0/24
az network vnet subnet create --vnet-name lab-vnet -n app-subnet \
--address-prefixes 10.0.2.0/24
az network vnet subnet create --vnet-name lab-vnet -n fw-subnet \
--address-prefixes 10.0.3.0/24
# Minimal test VMs (one per subnet). Use your own admin password or SSH key.
az vm create -n web01 --image Ubuntu2204 --size Standard_B2s \
--vnet-name lab-vnet --subnet web-subnet --admin-username azureuser \
--generate-ssh-keys --public-ip-sku Standard
az vm create -n fw01 --image Ubuntu2204 --size Standard_B2s \
--vnet-name lab-vnet --subnet fw-subnet --admin-username azureuser \
--generate-ssh-keys
A production firewall would be a Marketplace NVA (Azure Firewall, pfSense, FortiGate); for learning the routing mechanics, a plain Linux VM with IP forwarding enabled behaves identically as a next hop, which is all this lab exercises.
Step-by-Step Execution
az network nsg create -n web-nsg && az network nsg rule create --nsg-name web-nsg -n allow-https --priority 100 --destination-port-ranges 443 --access Allow --protocol TcpWHAT: this creates the NSG itself, then adds an inbound rule named allow-https at priority 100 permitting TCP traffic to destination port 443. WHY priority 100: Azure reserves 65000-65500 for its default rules and 100-4096 is the usable band for your own. Starting your most specific allow at 100 leaves room above it for even-more-specific exceptions later, and keeps it well ahead of the final DenyAllInBound at 65500. Priority ordering is the whole game, a rule that never gets evaluated because a broader rule matched first is indistinguishable from a rule that does not exist.
Every new NSG comes with three inbound defaults you did not write: AllowVNetInBound (65000), AllowAzureLoadBalancerInBound (65001), and DenyAllInBound (65500), plus a mirrored set for outbound. Your allow rule only has to land below 65000 in number to beat the catch-all deny. The full default set, for reference:
| Default rule | Priority | Direction | Effect |
|---|---|---|---|
AllowVNetInBound | 65000 | Inbound | Allows any traffic originating inside the VNet |
AllowAzureLoadBalancerInBound | 65001 | Inbound | Allows Azure load balancer health probes |
DenyAllInBound | 65500 | Inbound | Catch-all deny for everything else inbound |
AllowVNetOutBound | 65000 | Outbound | Allows traffic destined inside the VNet |
AllowInternetOutBound | 65001 | Outbound | Allows all outbound internet traffic |
DenyAllOutBound | 65500 | Outbound | Catch-all deny for everything else outbound |
You cannot delete these defaults, you can only override them with lower-numbered rules, which is exactly what the lab does. For least privilege, also add an explicit deny for management ports from the internet so the intent is documented in the rule list, not just implied by the catch-all.
FULL RULE SET, allow 443, explicitly deny management ports
az network nsg rule create --nsg-name web-nsg -n allow-https \
--priority 100 --direction Inbound --access Allow --protocol Tcp \
--source-address-prefixes Internet --source-port-ranges '*' \
--destination-address-prefixes 10.0.1.0/24 --destination-port-ranges 443
az network nsg rule create --nsg-name web-nsg -n deny-mgmt-internet \
--priority 200 --direction Inbound --access Deny --protocol Tcp \
--source-address-prefixes Internet --source-port-ranges '*' \
--destination-address-prefixes '*' --destination-port-ranges 22 3389
Expected output:
{
"name": "allow-https",
"properties": {
"access": "Allow",
"destinationPortRange": "443",
"priority": 100,
"provisioningState": "Succeeded"
}
}
Note the service tags used above: Internet is an Azure-defined tag representing all public address space, and it saves you from ever typing 0.0.0.0/0 as a source. The same mechanism offers VirtualNetwork, AzureLoadBalancer, and per-service tags like Storage.EastUS, using tags instead of hard-coded ranges keeps rules correct when Azure's own address space changes.
Inbound is only half of least privilege. The web tier also has no business initiating outbound connections to arbitrary internet hosts, so pin its egress down too: allow what the workload needs (here, Azure platform services and the app tier) and deny the rest. Outbound rules are evaluated with the same lowest-priority-first logic.
OUTBOUND LEAST PRIVILEGE, allow what is needed, deny the rest
az network nsg rule create --nsg-name web-nsg -n allow-out-app \
--priority 100 --direction Outbound --access Allow --protocol Tcp \
--source-address-prefixes 10.0.1.0/24 --source-port-ranges '*' \
--destination-address-prefixes 10.0.2.0/24 --destination-port-ranges 8080
az network nsg rule create --nsg-name web-nsg -n deny-out-internet \
--priority 4096 --direction Outbound --access Deny --protocol '*' \
--source-address-prefixes '*' --source-port-ranges '*' \
--destination-address-prefixes Internet --destination-port-ranges '*'
Why priority 4096 for the egress deny: it sits at the top of your usable band, still ahead of the default AllowInternetOutBound (65001) that would otherwise permit outbound internet access. Any future outbound allow you add lands between 100 and 4095 and beats the deny automatically.
An NSG that is not associated with anything filters nothing. Attach it to the web subnet so it applies to every NIC in that subnet:
ASSOCIATE THE NSG WITH THE WEB SUBNET
az network vnet subnet update --vnet-name lab-vnet -n web-subnet \
--network-security-group web-nsg
VERIFY, the custom rules should list ahead of the defaults
az network nsg rule list --nsg-name web-nsg -o table
Name Priority Direction Access Protocol DestinationPortRanges
------------------ -------- --------- ------ -------- ---------------------
allow-https 100 Inbound Allow Tcp 443
allow-out-app 100 Outbound Allow Tcp 8080
deny-mgmt-internet 200 Inbound Deny Tcp 22, 3389
deny-out-internet 4096 Outbound Deny * *
Common mistake: creating the rule against the wrong NSG name (every lab has at least one orphaned NSG from a previous attempt) or forgetting the subnet association. Verify with the table above, if your rule appears but traffic behavior does not change, the association is the first thing to check with az network vnet subnet show --vnet-name lab-vnet -n web-subnet --query networkSecurityGroup.
What this teaches: NSG rules evaluate by priority, lowest first; the first match wins, so ordering is the entire game.
A user-defined route overrides Azure's default routing so traffic must pass your inspection point.
az network route-table create -n rt-app && az network route-table route create --route-table-name rt-app -n to-fw --address-prefix 0.0.0.0/0 --next-hop-type VirtualAppliance --next-hop-ip-address 10.0.3.4WHAT: a route table named rt-app holding one route, to-fw, that sends all traffic matching 0.0.0.0/0 to next-hop type VirtualAppliance at 10.0.3.4. WHY: by default, Azure system routes give every subnet a direct path to the internet and to each other. The moment this UDR is associated with the app subnet, UDR precedence kicks in and the subnet's default route becomes the NVA, without logging into a single VM. This is the standard pattern for forced tunneling through a firewall, and VirtualAppliance is the next-hop type that requires an explicit IP (unlike Internet or VirtualNetworkGateway, which are symbolic).
To see what you are overriding, look at the effective routes before associating anything. Every route in the list is sourced Default, these are the system routes Azure builds automatically from the VNet's address space, and they are what any unmodified subnet follows:
BASELINE, system routes on an app-subnet NIC before the UDR
az network nic show-effective-route-table -n app01VMNic -o table
Source AddressPrefix NextHopType NextHopIp
------- ------------- --------------- ----------
Default 10.0.0.0/16 VnetLocal
Default 0.0.0.0/0 Internet
Default 10.0.0.0/8 None
Default 172.16.0.0/12 None
Default 192.168.0.0/16 None
Expected output:
$ az network route-table route list -g tyf-rg --route-table-name tyf-rt -o table
Name AddressPrefix NextHopType NextHopIpAddress
----------- --------------- ---------------- ------------------
to-firewall 0.0.0.0/0 VirtualAppliance 10.1.1.4
Two things to notice in the baseline. The Default 0.0.0.0/0 -> Internet line is the route your UDR is about to replace, after association, the same command shows that prefix sourced as User with the appliance as next hop. And the three RFC 1918 None routes outside your VNet's range are deliberate blackholes: Azure drops private-range traffic that does not belong to the VNet, which is why peered or on-prem networks need explicit routes rather than goodwill.
Choosing the next-hop type is the design decision in any UDR. The five options:
| Next-hop type | What it means | Typical use |
|---|---|---|
VirtualAppliance | A specific IP of an NVA (firewall, IDS, proxy) | This lab, forced inspection |
VirtualNetworkGateway | VPN or ExpressRoute gateway | Force traffic back on-premises |
Internet | Azure's default internet egress | Override a forced tunnel for one prefix |
None | Blackhole, drop matching traffic | Deliberately kill a flow path |
VnetLocal | Stay inside the VNet | Exempt intra-VNet traffic from a gateway override |
If the VNet is peered to an on-premises network over ExpressRoute or VPN, one more flag matters: BGP route propagation. A gateway can advertise routes (including a 0.0.0.0/0 from your on-prem edge) that would compete with your UDRs. Creating the table with propagation disabled makes the subnet deaf to those advertisements, so your UDRs are the only user intent in play:
HYBRID BONUS, create the table deaf to BGP advertisements
az network route-table create -n rt-app --disable-bgp-route-propagation true
In this standalone lab it makes no observable difference; in a hybrid environment it is the difference between a deterministic route table and one that changes when the on-prem router does.
Like the NSG, a route table does nothing until it is associated with a subnet. Associate it with the app subnet, and enable IP forwarding on the NVA's NIC, Azure drops packets delivered to a NIC whose IP is not the packet's destination unless forwarding is explicitly on. That Azure-side flag is separate from the OS-level forwarding inside the appliance, and you need both.
ASSOCIATE THE ROUTE TABLE AND ENABLE FORWARDING ON THE NVA
az network vnet subnet update --vnet-name lab-vnet -n app-subnet \
--route-table rt-app
# Azure-side: allow the appliance NIC to receive packets not addressed to it
az network nic update -n fw01VMNic --ip-forwarding true
# OS-side (on the appliance itself, e.g. a Linux NVA):
# sysctl -w net.ipv4.ip_forward=1
# echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
VERIFY, route table contents and subnet association
az network route-table route list -g lab-rg --route-table-name rt-app -o table
Name AddressPrefix NextHopType NextHopIpAddress
----- ------------- ---------------- ------------------
to-fw 0.0.0.0/0 VirtualAppliance 10.0.3.4
Common mistake: pointing --next-hop-ip-address at the wrong interface of a multi-homed NVA (it must be an IP in a subnet the routing VM can reach, typically the appliance's trusted or LAN-side address), or forgetting that a route table can only be associated with subnets in the same region as the table. Also note that the address prefix is matched most-specific-first within the table: a 0.0.0.0/0 route is a catch-all, so any more specific route you add later for the same subnet wins over it.
What this teaches: A UDR overrides system routes by longest-prefix match; that is how you force traffic through inspection without touching the application.
WHAT: Azure merges system routes, your UDRs, and any BGP-learned routes into one effective route table per NIC, and merges all NSGs applied at the subnet and NIC level into one effective security rule view. WHY: this merged view is the ground truth. The individual resources can each look correct while the combination is wrong, a UDR on the wrong subnet, an old NSG still attached to a NIC, and the effective views are the only place where those composition errors become visible without sending traffic.
The Source column tells you where each route came from: Default for system routes, User for your UDRs, VirtualNetworkGateway for BGP-learned routes. In the output above, the User-sourced 0.0.0.0/0 entry confirms the UDR is associated and winning. Note this NIC-level command reflects the route table of the NIC's subnet, run it against a NIC in the app subnet (e.g. app01VMNic) to see the to-fw route in effect.
EFFECTIVE SECURITY RULES, the merged NSG view for a NIC
az network nic list-effective-nsg -n web01VMNic -o table
# For full detail, ask for the JSON and filter to inbound allows:
az network nic list-effective-nsg -n web01VMNic \
--query "value[].association.networkSecurityGroup.id" -o tsv
Expected output:
$ az network nic show-effective-route-table -g tyf-rg -n tyf-vm01-nic -o table
Source State AddressPrefix NextHopType NextHopIpAddress
------- ------ --------------- ---------------- ------------------
Default Active 10.1.0.0/16 VnetLocal
User Active 0.0.0.0/0 VirtualAppliance 10.1.1.4
Default Active 0.0.0.0/0 Internet
Both commands take a NIC, not a VM. If you only know the VM name, resolve its NICs first, a VM can have several, and each has its own effective views:
RESOLVE A VM'S NICS WHEN YOU ONLY KNOW THE VM NAME
az vm nic list --vm-name web01 -o table
az vm nic show --vm-name web01 --nic web01VMNic --query "ipConfigurations[].privateIPAddress"
Common mistake: running these commands immediately after a change and reading a stale view, effective views can take a minute to converge after an association change. If the output does not match what you just configured, wait 60-90 seconds and re-run before assuming the configuration is wrong. Also remember both commands target a NIC, not a VM: use az vm nic list -g lab-rg --vm-name web01 if you only know the VM name.
What this teaches: Effective rules and effective routes show the computed result, which is what actually applies, not what any single rule says.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
NSG vs UDR: Which Tool for Which Job
By this point both mechanisms are in place, and it is worth stating the division of labor explicitly because real designs mix them up constantly. If you are asking "should this be blocked?", you are writing an NSG rule. If you are asking "which path should this take?", you are writing a route. Neither can do the other's job: a UDR cannot drop a packet by policy (a None next hop drops by routing, not by 5-tuple), and an NSG cannot redirect a packet anywhere, allowed traffic follows the route table, always.
| Question | Tool | Knob |
|---|---|---|
| May the internet reach the web tier on 443? | NSG | Inbound allow rule, priority 100 |
| May anyone SSH from the internet? | NSG | Explicit deny, or the default DenyAllInBound |
| Does app-tier egress pass the firewall? | Route table | 0.0.0.0/0 UDR to the NVA's IP |
| Does a specific prefix bypass the tunnel? | Route table | More-specific UDR with Internet next hop |
| Which combination is actually in effect? | Both | Effective rules / effective routes on the NIC |
How It Works
A network security group is a stateful 5-tuple filter: source, source port, destination, destination port, protocol. Rules are evaluated in priority order, lowest number first, and the first match wins; three default rules at the bottom (allow VNet, allow load balancer, deny all inbound) catch everything the custom rules miss. Because the filter is stateful, an allow rule for inbound SSH automatically permits the return traffic without a matching outbound rule.
User-defined routes override Azure's system routes by longest-prefix match. A 0.0.0.0/0 route with a virtual-appliance next hop forces all outbound traffic through your firewall or inspection device; more specific routes win over it, so you can still carve out exceptions. The effective routes view shows the computed result of system routes plus UDRs plus BGP, which is the actual forwarding decision, not what you think you configured.
NSGs and UDRs compose in layers: the route table decides where a packet goes next, the NSG decides whether it may go at all. Debugging connectivity means checking both layers in order. The effective security rules and effective routes commands exist precisely because the applied result can differ from any single rule you wrote.
Testing & Validation
Configuration views tell you what Azure thinks the network is; the tests below prove what it does. Run all three, a policy lookup, a real connection, and a route check, because each catches a different class of mistake.
az network nic show-effective-route-table -n web01VMNic --query "value[?nextHopType=='VirtualAppliance']"You should see the appliance as the next hop for 0.0.0.0/0, confirming traffic is forced through your firewall. NSG denies should appear in flow logs.
First, use Network Watcher's IP flow verify to ask the policy engine directly whether a specific 5-tuple is allowed, without sending a single packet. This simulates an internet client at 203.0.113.10 hitting the web VM on 443:
POLICY CHECK, IP flow verify (control-plane API, needs Network Watcher enabled in the region)
az network watcher test-ip-flow --vm web01 --direction Inbound \
--protocol Tcp --local 10.0.1.4:443 --remote 203.0.113.10:54321
# Expected:
# "access": "Allow",
# "ruleName": "securityRule/allow-https"
# The same probe against port 22 should return:
# "access": "Deny",
# "ruleName": "securityRule/deny-mgmt-internet"
The ruleName in the response is the most valuable part: it names the exact rule that decided the flow, so when an answer surprises you, you know which rule to fix instead of guessing.
Second, test with real traffic, because stateful behavior and the appliance's own rules are not visible to a policy lookup. From an external host, HTTPS should connect and SSH should time out:
REAL TRAFFIC, allowed 443 succeeds, denied 22 times out
curl -vk https://<web01-public-ip>/ # TLS handshake completes
ssh -o ConnectTimeout=5 <web01-public-ip> # hangs, then: Connection timed out
The difference between timed out and connection refused is diagnostic gold: a timeout means the NSG dropped the packet (no listener ever saw it), while a refusal means the packet arrived and the host rejected it, in other words, your NSG let it through. Third, confirm from a VM in the app subnet that egress really traverses the appliance: traceroute to any external address should show 10.0.3.4 as the first hop outside the subnet.
PATH CHECK, from a VM in the app subnet, the NVA is the first hop
sudo apt install -y traceroute
traceroute -n 8.8.8.8
# Expected first hops (illustrative):
# 1 10.0.3.4 1.2 ms <- the firewall appliance
# 2 ...
#
# If hop 1 is NOT 10.0.3.4, the UDR is not associated to this subnet.
If you cannot log into the app VM to run traceroute, Network Watcher can answer the same question from the control plane. The next-hop tool reports which route a given destination would take from a given VM, the routing equivalent of IP flow verify:
PATH CHECK FROM THE CONTROL PLANE, Network Watcher next hop
az network watcher show-next-hop --vm app01 \
--source-ip 10.0.2.4 --dest-ip 8.8.8.8
# Expected:
# "nextHopType": "VirtualAppliance",
# "nextHopIpAddress": "10.0.3.4",
# "routeTableId": ".../routeTables/rt-app"
The response even names the route table responsible, which makes it the fastest way to confirm a UDR association from outside the guest OS.
OPTIONAL, enable VNet flow logs for ongoing evidence of denies
# the storage account must exist first: names are globally unique, so pick your own
az storage account create -g $RG -n labflowlogsa --sku Standard_LRS --kind StorageV2
VNETID=$(az network vnet show -g $RG -n lab-vnet --query id -o tsv)
az network watcher flow-log create -n lab-vnet-flowlog \
--target-resource-id $VNETID --storage-account labflowlogsa \
--location eastus --enabled true --format JSON --log-version 2
Flow logs record every allowed and denied 5-tuple against the rule that decided it. Note this targets the virtual network, not the NSG: Microsoft stopped allowing new NSG flow logs after 2025-06-30 and retires them on 2027-09-30, so all new flow logging uses virtual network flow logs, which cover the same traffic. After your SSH test above, the flow log for lab-vnet should contain deny records citing deny-mgmt-internet, the written proof an auditor (or your future self) wants that the deny rule is doing its job.
Troubleshooting
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
| New allow rule has no effect | A lower-priority deny or a default rule matches first; rules evaluate by priority number | Open effective security rules and find which rule actually matched the traffic. |
| VM unreachable after adding a UDR | The route points at a next hop that does not forward | Verify the appliance IP is correct and that IP forwarding is enabled on the firewall NIC. |
| Traffic blackholed to nowhere | A 0.0.0.0/0 route with a wrong next-hop IP | Fix the next hop or remove the route; check effective routes before and after. |
| Return traffic fails | Asymmetric routing: the reply takes a different path than the request | Trace both directions; the forward path working does not prove the reverse path. |
| Changes take minutes to apply | Control-plane propagation delay across the fabric | Wait a few minutes and re-check effective rules before concluding the change failed. |
Troubleshooting
- Rule has no effect: a lower-priority number wins; reorder so the intended rule precedes broad denies. Check for a stale or duplicate rule at a lower number with
az network nsg rule list --nsg-name web-nsg -o table, a forgottenallow-allat priority 90 silently overrides everything below it. Also confirm the NSG is actually associated: rules on an unattached NSG evaluate against nothing. - UDR ignored: associate the route table with the subnet, not just create it. Creating
rt-appand its routes changes nothing untilaz network vnet subnet update ... --route-table rt-appruns. Verify withaz network vnet subnet show --vnet-name lab-vnet -n app-subnet --query routeTable, anullresult means the association never happened or targeted a different subnet. - Appliance drops traffic: enable IP forwarding on the appliance NIC. Azure delivers routed packets to the NVA's NIC, but the NIC discards anything not addressed to its own IP unless
--ip-forwarding trueis set on the Azure side and the OS inside the appliance forwards too (net.ipv4.ip_forward=1on Linux). Forgetting one of the two layers is the classic half-working NVA: the route table is perfect and packets still vanish. - Traffic is allowed but the app still cannot connect: check for NSGs stacked at both levels. An NSG can attach to the subnet and to the NIC, and traffic must pass both, they are evaluated independently, subnet first for inbound. A restrictive NIC-level NSG from an earlier experiment is a frequent invisible blocker. Compare both associations in the
list-effective-nsgoutput. - Asymmetric routing through the appliance: if the UDR forces app-subnet egress through the NVA but return traffic from the web subnet takes the direct system route, stateful inspection on the NVA breaks (it sees only half the conversation) and connections reset. Fix by putting matching UDRs on every subnet involved so flows traverse the appliance symmetrically in both directions, or by disabling the NVA's statefulness for that flow if the design genuinely requires asymmetry.
Extension Ideas
- Compare cloud NSGs with the cross-cloud view in Network Security Groups.
- Audit rules with CSPM.
- Codify NSGs/UDRs with IaC.
Key Results
- Enforced least-privilege inbound rules via NSG priorities.
- Forced all egress through a firewall appliance with a UDR.
- Verified the effective route table on the VM NIC.
- Combined filtering and routing for defense in depth.
Key Takeaways
- NSGs filter, route tables steer. An NSG answers "is this flow allowed?" on the 5-tuple; a UDR answers "where does an allowed packet go next?" Use both, neither substitutes for the other.
- Priority order is the policy. Lowest number wins and evaluation stops at the first match, so specific allows go low in number and the catch-all deny (yours or the built-in 65500) cleans up behind them.
- Nothing works until it is associated. NSGs attach to subnets or NICs; route tables attach to subnets. An unassociated resource is inert, most "the rule does nothing" bugs are association bugs.
- Route precedence is fixed: user-defined routes beat BGP routes beat system routes. One UDR on a subnet redirects its whole egress path with zero guest-OS changes.
- NSGs are stateful. Allowed flows permit their own replies; you write the rule for the initiating direction only.
- Trust the effective views, then prove with traffic.
show-effective-route-tableandlist-effective-nsgshow the merged truth;test-ip-flow, a realcurl/sshprobe, and NSG flow logs confirm the behavior. - An NVA needs forwarding at two layers. Azure-side IP forwarding on the NIC and OS-side forwarding in the guest, one without the other drops routed packets silently.
Cleanup
Lab VMs and public IPs bill by the hour, so tear everything down when you are done. Because all resources live in one resource group, one command removes the whole lab. Deleting the group is irreversible, export anything you want to keep (flow logs, packet captures) first.
TEAR DOWN, delete the entire lab
az group delete -n lab-rg --yes --no-wait
Verify with az group exists -n lab-rg, it should return false once the delete completes.
Practice Challenges
Challenge 1: Default-deny with one exception
Goal: Write an NSG that denies everything inbound except 443, then prove port 80 is blocked and 443 is open.
Hint: Test with curl from outside; the blocked port timing out is the expected proof.
Challenge 2: Force traffic through inspection
Goal: Add a UDR sending 0.0.0.0/0 to a firewall IP and verify with effective routes.
Hint: Use az network nic show-effective-route-table; it shows the computed table per NIC.
Challenge 3: Read a flow log
Goal: Enable a virtual network flow log for ten minutes, generate traffic, and find your own connection in the log.
Hint: Flow logs land in a storage account as JSON; filter by your source IP.