Build a Segmented VNet with Secure Subnets

Design a 3-tier Azure VNet with NSGs that enforce strict traffic isolation.

Introduction

30 Second Summary

In a building, you would not give every person a master key to every room. You would segment access so that visitors can reach the lobby, employees can reach their floor, and only database admins can enter the server room.

In this project, you will build that same segmentation logic inside an Azure virtual network. You will create three isolated subnets and enforce strict rules about which tier of traffic can talk to which.

What You'll Build

A production-style 3-tier network architecture in Azure with NSG-enforced traffic segmentation, built entirely from the command line using Azure CLI.

By the end of this project, you'll have:

  • A Virtual Network divided into three purpose-built subnets (Web, App, and Database) using proper CIDR address planning.
  • Three Network Security Groups with custom security rules enforcing least-privilege access between tiers.
  • Verified proof that your Zero Trust segmentation works by testing allowed and blocked connectivity paths between VMs.
  • Secret Mission: Add Application Security Groups for scalable, role-based micro-segmentation.

Are there any prerequisites?

You need an Azure account with an active subscription. This project uses Azure Cloud Shell, which runs in your browser with Azure CLI pre-installed. No local installation is required.

Set Up Your Environment

Before you can build a segmented network, you need a workspace where you can run commands against your Azure subscription.

In this step, you will open Azure Cloud Shell and create a resource group to hold all the resources you build throughout this project.

In this step, get ready to:
  • Open Azure Cloud Shell and verify the Azure CLI is working.
  • Set shell variables for reuse throughout the project.
  • Create a resource group to hold all your lab resources.
Open Azure Cloud Shell

Azure Cloud Shell is a browser-based terminal with the Azure CLI pre-installed. You do not need to install anything on your local machine.

  • Open your browser and navigate to:
https://shell.azure.com
  • When prompted, select Bash as your shell environment.

First time using Cloud Shell?

Azure Cloud Shell will ask you to create a storage account the first time you open it. Accept the defaults and click Create.

This storage account stores your Cloud Shell profile and any scripts you save between sessions.

  • Verify the Azure CLI is available by running this command:
az --version

You should see a list of Azure CLI component versions printed in your terminal.

Not seeing a version list?

Make sure you selected Bash (not PowerShell) when Cloud Shell opened.

If the shell is still loading, wait a moment for the storage account provisioning to finish, then try the command again.

help me troubleshoot Azure Cloud Shell not loading or not recognizing the az command

Set variables and create a resource group

Shell variables let you reuse values like your resource group name and region across every command in this project. Setting them once now saves you from retyping long strings later.

  • Set your shell variables by running these commands:
RESOURCE_GROUP="rg-vnet-lab"
LOCATION="eastus2"
VNET_NAME="vnet-segmented-lab"

What do these variables hold?

  • RESOURCE_GROUP names the container that holds all your lab resources together.
  • LOCATION is the Azure region where your resources will be deployed.
  • VNET_NAME is the name of the virtual network you will create in the next step.

Now you can use $RESOURCE_GROUP, $LOCATION, and $VNET_NAME in every command going forward.

  • Create the resource group by running this command:
az group create \
  --name $RESOURCE_GROUP \
  --location $LOCATION

What does this command do?

This creates a resource group called rg-vnet-lab in the eastus2 region.

A resource group is a logical container that groups related Azure resources so you can manage and delete them together.

You should see a JSON response in your terminal with "provisioningState": "Succeeded".

Seeing an authorization error?

Make sure your Azure account has an active subscription. Free trial accounts work, but expired trials will block resource creation.

help me fix an authorization or subscription error when running az group create in Azure Cloud Shell

Verify the resource group was created
  • Confirm your resource group exists by running this command:
az group show --name $RESOURCE_GROUP --output table

You should see a table with your resource group name, location eastus2, and a provisioning state of Succeeded.

Seeing 'resource group not found'?

Check that you ran the variable-setting commands earlier in the same terminal session. If your Cloud Shell session timed out and reconnected, your variables may have been lost.

Re-run the three variable lines from the previous substep, then try az group show again.

help me troubleshoot why az group show says my resource group was not found

Your workspace is ready. Next up, you will create the virtual network and carve it into three purpose-built subnets.

Create the VNet and Subnets

Your resource group is ready. Now it's time to build the actual network your resources will live in.

A Virtual Network (VNet) is your own private network inside Azure. You'll divide it into three subnets that represent the tiers of a production application: Web, App, and Database.

In this step, get ready to:
  • Create a VNet with a /16 address space and the Web subnet.
  • Add the App and Database subnets with their own address ranges.
  • Verify all three subnets are correctly laid out.
Create the VNet with the Web subnet

Every VNet needs a CIDR address space that defines how many IP addresses are available. You'll use 10.0.0.0/16, which gives you 65,536 addresses to carve into subnets.

  • Create the VNet and its first subnet (Web) in one command by running:
az network vnet create \
  --resource-group $RESOURCE_GROUP \
  --name $VNET_NAME \
  --address-prefixes 10.0.0.0/16 \
  --subnet-name snet-web \
  --subnet-prefixes 10.0.1.0/24

What does this command do?

This single command does two things at once:

  • Creates the VNet vnet-segmented-lab with a /16 address space (65,536 total addresses).
  • Carves out the first subnet snet-web with a /24 range (256 addresses, 251 usable since Azure reserves 5 per subnet).
  • Wait for the JSON output to confirm the VNet was created successfully.

Seeing an error about address space?

  • Make sure your $RESOURCE_GROUP and $VNET_NAME variables are still set. If your Cloud Shell session timed out, re-run the variable commands from Step 1.
  • Check that you typed the CIDR prefix correctly: 10.0.0.0/16 for the VNet and 10.0.1.0/24 for the subnet.

Help me troubleshoot my VNet creation error in Azure CLI

Create the App and Database subnets

Your VNet currently has one subnet. A 3-tier architecture needs three separate subnets so you can apply different security rules to each tier.

  • Add the App subnet by running:
az network vnet subnet create \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --name snet-app \
  --address-prefixes 10.0.2.0/24
  • Add the Database subnet by running:
az network vnet subnet create \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --name snet-db \
  --address-prefixes 10.0.3.0/24

What does the address layout look like?

Each subnet gets its own /24 block, meaning 256 addresses each. The third octet identifies which tier the traffic belongs to:

  • 10.0.1.0/24 = Web tier.
  • 10.0.2.0/24 = App tier.
  • 10.0.3.0/24 = Database tier.

This numbering scheme makes it easy to identify traffic sources when writing security rules later.

  • Wait for each command to return JSON output confirming the subnet was created.

Seeing an overlap error?

  • Azure rejects subnets whose address ranges overlap. Double-check that you used 10.0.2.0/24 for App and 10.0.3.0/24 for Database (not 10.0.1.0/24 which is already taken by the Web subnet).
  • If the subnet already exists from a previous attempt, you can move on to the verification step.

Help me fix a subnet address overlap error

Verify the VNet and subnet layout
  • List all subnets in your VNet by running:
az network vnet subnet list \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --output table

You should see a table with three rows: snet-web (10.0.1.0/24), snet-app (10.0.2.0/24), and snet-db (10.0.3.0/24).

Why three separate subnets instead of one big one?

Splitting your network into subnets by tier is the foundation of Zero Trust network segmentation. Each subnet becomes a boundary where you can attach different security rules.

Without subnets, every resource in the VNet can talk to every other resource freely. Subnets let you enforce that only the Web tier can reach the App tier, and only the App tier can reach the Database.

Don't see all three subnets?

  • Scroll up in your terminal to check if any of the previous subnet create commands returned an error.
  • Make sure $VNET_NAME is still set to vnet-segmented-lab. Run echo $VNET_NAME to confirm.

Help me figure out why my subnet list is incomplete

Your network skeleton is in place. Three subnets, each with its own address range, ready to be locked down. Next up, you'll create Network Security Groups that control exactly which traffic can flow between these tiers.

Create NSGs with Tiered Security Rules

Your VNet has three subnets, but right now any resource in any subnet can freely communicate with any other.

Azure's default behavior allows all inbound traffic between subnets within a VNet (via a built-in rule called AllowVnetInBound at priority 65000). In this step, you will create Network Security Groups (NSGs) with custom rules that override that default and enforce strict tier-to-tier isolation.

In this step, get ready to:
  • Create one NSG per subnet tier (Web, App, Database).
  • Add inbound security rules that control which traffic each tier accepts.
  • Understand priority-based rule evaluation and explicit Deny rules.
Create three NSGs

An NSG is a firewall-like resource that contains a list of security rules. You attach it to a subnet (or NIC), and Azure evaluates every inbound and outbound packet against those rules.

  • Create one NSG for each tier by running these commands:
az network nsg create \
  --resource-group $RESOURCE_GROUP \
  --name nsg-web

az network nsg create \
  --resource-group $RESOURCE_GROUP \
  --name nsg-app

az network nsg create \
  --resource-group $RESOURCE_GROUP \
  --name nsg-db

What does this do?

Each command creates an empty NSG in your resource group. The names follow a convention: nsg-web for the web tier, nsg-app for the application tier, and nsg-db for the database tier.

Every new NSG comes with Azure's default rules (AllowVnetInBound at priority 65000, DenyAllInBound at priority 65500). You will add custom rules at lower priority numbers to override the defaults.

  • Verify the NSGs were created by running:
az network nsg list \
  --resource-group $RESOURCE_GROUP \
  --output table

You should see three NSGs listed: nsg-web, nsg-app, and nsg-db.

Not seeing all three NSGs?

Make sure your $RESOURCE_GROUP variable is still set. Run echo $RESOURCE_GROUP to check. If it's empty, re-run the variable assignment from Step 1.

help me troubleshoot why my Azure NSGs didn't create

Add rules to the Web NSG

The Web tier faces the public internet. It needs to accept HTTP (port 80), HTTPS (port 443), and SSH (port 22) traffic from external users.

  • Allow HTTP inbound from the internet by running:
az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-web \
  --name AllowHTTP \
  --priority 100 \
  --direction Inbound \
  --access Allow \
  --protocol Tcp \
  --source-address-prefixes Internet \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges 80
  • Allow HTTPS inbound from the internet by running:
az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-web \
  --name AllowHTTPS \
  --priority 110 \
  --direction Inbound \
  --access Allow \
  --protocol Tcp \
  --source-address-prefixes Internet \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges 443
  • Allow SSH inbound for administration by running:
az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-web \
  --name AllowSSH \
  --priority 120 \
  --direction Inbound \
  --access Allow \
  --protocol Tcp \
  --source-address-prefixes Internet \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges 22

How does priority-based rule evaluation work?

Azure evaluates NSG rules from lowest priority number to highest. The first rule that matches a packet wins, and processing stops. Priority numbers range from 100 to 4096.

The Internet service tag represents all public IP addresses outside Azure. Using service tags instead of IP ranges keeps your rules readable and automatically updated by Azure.

  • Verify the Web NSG rules by running:
az network nsg rule list \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-web \
  --output table

You should see three custom rules: AllowHTTP (priority 100), AllowHTTPS (priority 110), and AllowSSH (priority 120).

Seeing a 'duplicate priority' error?

Each rule in an NSG must have a unique priority number within its direction (Inbound or Outbound). If you accidentally ran the same priority twice, delete the duplicate with az network nsg rule delete and re-create it with the correct priority.

help me fix my NSG rule priority conflict

Add rules to the App and Database NSGs

The App and Database tiers should NOT accept traffic from the public internet. Each tier only accepts connections from the tier directly above it in the architecture.

Why do we need an explicit Deny rule?

Azure's default AllowVnetInBound rule (priority 65000) allows ALL traffic between subnets in the same VNet. Without a custom Deny rule at a lower priority number, the Web subnet could reach the Database subnet directly.

By adding a DenyAllVNet rule at priority 4000, you block all VNet traffic that wasn't already matched by a higher-priority Allow rule. This enforces Zero Trust segmentation: no implicit trust between tiers.

  • Add rules to the App NSG by running these commands:
az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-app \
  --name AllowWebToApp \
  --priority 100 \
  --direction Inbound \
  --access Allow \
  --protocol Tcp \
  --source-address-prefixes 10.0.1.0/24 \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges 8080

az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-app \
  --name DenyAllVNet \
  --priority 4000 \
  --direction Inbound \
  --access Deny \
  --protocol '*' \
  --source-address-prefixes VirtualNetwork \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges '*'

What do these App NSG rules do?

  • AllowWebToApp (priority 100) permits TCP traffic on port 8080, but ONLY from the Web subnet (10.0.1.0/24).
  • DenyAllVNet (priority 4000) blocks all other VNet traffic that did not match a higher-priority rule.

Because NSGs are stateful, return traffic for connections initiated by the App tier is automatically allowed. You only need inbound rules to control who can start connections.

  • Add rules to the Database NSG by running these commands:
az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-db \
  --name AllowAppToDb \
  --priority 100 \
  --direction Inbound \
  --access Allow \
  --protocol Tcp \
  --source-address-prefixes 10.0.2.0/24 \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges 1433

az network nsg rule create \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-db \
  --name DenyAllVNet \
  --priority 4000 \
  --direction Inbound \
  --access Deny \
  --protocol '*' \
  --source-address-prefixes VirtualNetwork \
  --source-port-ranges '*' \
  --destination-address-prefixes '*' \
  --destination-port-ranges '*'

What do these Database NSG rules do?

  • AllowAppToDb (priority 100) permits TCP traffic on port 1433 (SQL Server) from the App subnet (10.0.2.0/24) only.
  • DenyAllVNet (priority 4000) blocks everything else from within the VNet, including the Web subnet.

This is the core of your segmentation: the Web tier can never directly reach the Database tier. Traffic must flow Web to App to Database.

  • Verify the App NSG rules by running:
az network nsg rule list \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-app \
  --output table
  • Verify the Database NSG rules by running:
az network nsg rule list \
  --resource-group $RESOURCE_GROUP \
  --nsg-name nsg-db \
  --output table

Each NSG should show two custom rules: an Allow rule at priority 100 and a DenyAllVNet rule at priority 4000.

Rules not showing up?

Double check that the --nsg-name value matches exactly (nsg-app or nsg-db). NSG names are case-sensitive.

Also confirm you used the correct protocol value. The DenyAllVNet rule uses '*' (with quotes) for protocol, not Tcp.

help me troubleshoot my Azure NSG rules

Your three NSGs now have custom security rules enforcing strict tier isolation. Next up, you will associate these NSGs with their subnets and deploy test VMs to prove the segmentation works.

Associate NSGs to Subnets and Deploy Test VMs

Your NSGs have all the right rules, but they aren't protecting anything yet.

An NSG only starts filtering traffic once it's associated with a subnet (or a network interface). In this step, you'll link each NSG to its matching subnet and then deploy lightweight VMs so you can prove your segmentation actually works.

In this step, get ready to:
  • Associate each NSG with its corresponding subnet.
  • Deploy three Ubuntu test VMs (one per subnet) using subnet-level NSGs only.
  • Record the private IP addresses of each VM for connectivity testing.
Associate each NSG with its subnet

Until you explicitly attach an NSG to a subnet, Azure's default rules apply and all VNet-to-VNet traffic flows freely.

  • Associate nsg-web with snet-web by running this command:
az network vnet subnet update \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --name snet-web \
  --network-security-group nsg-web
  • Associate nsg-app with snet-app by running:
az network vnet subnet update \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --name snet-app \
  --network-security-group nsg-app
  • Associate nsg-db with snet-db by running:
az network vnet subnet update \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --name snet-db \
  --network-security-group nsg-db

What does this command do?

The az network vnet subnet update command modifies an existing subnet's configuration.

The --network-security-group flag tells Azure which NSG to attach. Once attached, every packet entering or leaving that subnet is evaluated against the NSG's rules.

  • Verify the associations by running:
az network vnet subnet list \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --output table

You should see each subnet listed with its corresponding NSG name in the output.

NSG column showing blank?

Make sure you used the exact NSG name (e.g., nsg-web) and that your $RESOURCE_GROUP and $VNET_NAME variables are still set.

If your Cloud Shell session timed out, re-run the variable assignments from Step 1 before trying again.

Help me troubleshoot my NSG-to-subnet association

Deploy a test VM in each subnet

You need a VM inside each subnet to test whether traffic flows or gets blocked. Each VM uses the free-tier-eligible Standard_B1s size.

  • Deploy the Web VM by running this command:
az vm create \
  --resource-group $RESOURCE_GROUP \
  --name vm-web \
  --image Ubuntu2204 \
  --size Standard_B1s \
  --vnet-name $VNET_NAME \
  --subnet snet-web \
  --nsg "" \
  --generate-ssh-keys \
  --no-wait
  • Deploy the App VM by running:
az vm create \
  --resource-group $RESOURCE_GROUP \
  --name vm-app \
  --image Ubuntu2204 \
  --size Standard_B1s \
  --vnet-name $VNET_NAME \
  --subnet snet-app \
  --nsg "" \
  --generate-ssh-keys \
  --no-wait
  • Deploy the Database VM by running:
az vm create \
  --resource-group $RESOURCE_GROUP \
  --name vm-db \
  --image Ubuntu2204 \
  --size Standard_B1s \
  --vnet-name $VNET_NAME \
  --subnet snet-db \
  --nsg "" \
  --generate-ssh-keys

Why these flags?

  • --nsg "" prevents Azure from creating a NIC-level NSG on each VM. This ensures traffic control is purely at the subnet level, which keeps our segmentation clean.
  • --no-wait on the first two VMs lets them provision in parallel so you don't wait three times.
  • The last command runs without --no-wait so your terminal blocks until all VMs are ready.
  • --generate-ssh-keys creates an SSH key pair if one doesn't already exist in your Cloud Shell session.

Wait for the final command to finish. You should see a JSON output with the details of vm-db including its provisioning state.

VM creation taking a long time?

VM provisioning can take 2-4 minutes. The third command will block your terminal while all three VMs finish deploying.

If you see a quota or size error, your subscription may not have B1s availability in eastus2. Try changing your LOCATION variable to eastus or westus2 and recreate the resource group.

Help me fix a VM creation error in Azure CLI

Note the private IPs of each VM

You'll need the private IP addresses to test connectivity between VMs in the next step.

  • Retrieve the private IPs of all three VMs by running:
az vm list-ip-addresses \
  --resource-group $RESOURCE_GROUP \
  --output table

What should I see?

You should see a table with three rows. The private IPs should be approximately:

  • vm-web: 10.0.1.4
  • vm-app: 10.0.2.4
  • vm-db: 10.0.3.4

Azure reserves the first 5 addresses in each subnet for internal use, so the first available host IP is always .4.

  • Record these private IPs. You'll use them in the next step to test which connections your NSGs allow and which they block.

Your segmented network is now fully wired up with real VMs behind real NSGs. Next up, you'll prove that your security rules actually work by testing connectivity between tiers.

Test Network Segmentation

Your VMs are deployed and your NSGs are locked in place. The network architecture looks right on paper, but does it actually enforce the rules you set?

In this step, you will run connectivity tests between your VMs to prove that segmentation works. You will confirm that allowed paths succeed and blocked paths fail.

In this step, get ready to:
  • Test that the Web tier can reach the App tier on port 8080.
  • Verify that the Web tier cannot reach the Database tier directly.
  • Confirm the App tier can reach the Database tier on port 1433.
Test the allowed path from Web to App on port 8080

Your first test checks whether the Web VM can reach the App VM on port 8080. According to your NSG rules, this traffic should be allowed because the AllowWebToApp rule on nsg-app permits TCP traffic from 10.0.1.0/24 on port 8080.

  • Run a connectivity test from vm-web to vm-app by running this command:
az vm run-command invoke \
  --resource-group $RESOURCE_GROUP \
  --name vm-web \
  --command-id RunShellScript \
  --scripts "nc -zv 10.0.2.4 8080 -w 5 2>&1 || echo 'Connection failed/timed out'"

What does this command do?

az vm run-command invoke executes a script on the VM through Azure's management plane. This means the command works regardless of your NSG rules. It does not need network access to the VM itself.

The script uses nc -zv (netcat) to attempt a TCP connection to the target IP and port. The -w 5 flag sets a 5-second timeout so blocked connections fail quickly instead of hanging.

This command takes 30-60 seconds to complete. You should see output containing Connection refused in the response.

Why does Connection refused mean success?

"Connection refused" means the packet reached the destination VM, but no service is listening on that port. This confirms the NSG allowed the traffic through.

"Connection timed out" is the opposite. It means the NSG dropped the packet before it ever reached the VM. You will see this difference in the next test.

Seeing an error about the VM not being found?

Make sure your $RESOURCE_GROUP variable is still set. If your Cloud Shell session timed out, you may need to re-set your variables from Step 1.

Also confirm all three VMs are running by checking az vm list -g $RESOURCE_GROUP --output table. If a VM shows "VM deallocated", start it with az vm start --resource-group $RESOURCE_GROUP --name vm-web.

Help me troubleshoot my az vm run-command invoke error

Test the blocked path from Web to Database on port 1433

This is the critical test. Your nsg-db has a DenyAllVNet rule that should block any traffic not explicitly allowed. Since only the App subnet (10.0.2.0/24) is permitted, the Web subnet should be completely shut out.

  • Test connectivity from vm-web to vm-db on port 1433 by running this command:
az vm run-command invoke \
  --resource-group $RESOURCE_GROUP \
  --name vm-web \
  --command-id RunShellScript \
  --scripts "nc -zv 10.0.3.4 1433 -w 5 2>&1 || echo 'Connection failed/timed out'"

What should I see?

You should see Connection timed out in the output. This confirms that nsg-db is blocking traffic from the Web subnet. The packet never reached vm-db.

This test takes slightly longer because netcat waits the full 5-second timeout before giving up. That wait is itself proof that the NSG is silently dropping the traffic.

Seeing Connection refused instead of timed out?

If you see "Connection refused" instead of "Connection timed out", your NSG is not blocking the traffic. Check that nsg-db has the DenyAllVNet rule at priority 4000.

Also verify nsg-db is associated with snet-db by running az network vnet subnet show --resource-group $RESOURCE_GROUP --vnet-name $VNET_NAME --name snet-db and checking the networkSecurityGroup field.

Help me figure out why my Web VM can reach my Database VM when it should be blocked

Test the allowed path from App to Database on port 1433

The final test proves that the App tier can reach the Database tier. The AllowAppToDb rule on nsg-db allows TCP traffic from 10.0.2.0/24 on port 1433, so this connection should succeed.

  • Test connectivity from vm-app to vm-db on port 1433 by running this command:
az vm run-command invoke \
  --resource-group $RESOURCE_GROUP \
  --name vm-app \
  --command-id RunShellScript \
  --scripts "nc -zv 10.0.3.4 1433 -w 5 2>&1 || echo 'Connection failed/timed out'"

What should I see?

You should see Connection refused again. This confirms that nsg-db let the traffic through from the App subnet. No SQL service is listening, but the packet successfully arrived at the VM.

Compare this to the previous test. The Web VM got "timed out" to the same destination. The App VM got "refused" to the same destination. Same port, same target, different source subnet, different result. That is your NSG segmentation in action.

Seeing Connection timed out from the App VM?

If the App VM's traffic is also being blocked, check that the AllowAppToDb rule has priority 100, which is lower (higher priority) than the DenyAllVNet rule at priority 4000. NSG rules are evaluated in priority order, and processing stops at the first match.

Verify the rule exists by running az network nsg rule list --resource-group $RESOURCE_GROUP --nsg-name nsg-db --output table. You should see AllowAppToDb with source 10.0.2.0/24.

Help me figure out why my App VM cannot reach my Database VM

Why do NSGs need explicit Deny rules?

Azure includes a default rule called AllowVnetInBound at priority 65000 on every NSG. This rule allows all VNet-to-VNet traffic by default. Without your DenyAllVNet rule at priority 4000, all three subnets could talk freely to each other.

This is the core of Zero Trust network segmentation. You do not rely on Azure's permissive defaults. You explicitly deny everything, then allow only the traffic paths your architecture requires.

Secret mission

Add Application Security Groups for Micro-Segmentation

Your NSG rules currently use hardcoded IP ranges to control traffic between tiers. This works, but what happens when you add a second web server? You would need to update every rule that references the Web subnet. Application Security Groups (ASGs) solve this by letting you group VMs by role instead of IP address.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The VMs in this project cost approximately $0.03/hr total while running, though they are covered by Azure free tier for the first 12 months (750 hrs/month of B1s usage).

Cost warning

If you are outside Azure's free tier period, the three B1s VMs will cost approximately $0.03/hr combined. Public IP addresses also incur a small charge. If you are not planning to extend this lab immediately, choose Pause or Delete to avoid unexpected charges.

Resources you used:

  • 3 x Standard_B1s Virtual Machines (vm-web, vm-app, vm-db).
  • 1 x Virtual Network (vnet-segmented-lab) with 3 subnets.
  • 3 x Network Security Groups (nsg-web, nsg-app, nsg-db).
  • Public IP addresses assigned to each VM.
  • 1 x Resource Group (rg-vnet-lab) containing all of the above.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend this lab (for example, the Bicep Infrastructure as Code follow-on project).

  • Your 3 VMs will continue running at approximately $0.03/hr total.
  • If you are within your first 12 months on Azure, B1s VMs are covered by the free tier for 750 hours/month.
  • Your VNet and NSG configuration will remain intact and ready for further experimentation.

Pause - I'll come back to this later

Deallocate your VMs to stop compute charges while keeping your entire network configuration in place. You can restart them later without rebuilding anything.

  • Deallocate all three VMs by running this command in Cloud Shell:
az vm deallocate --resource-group rg-vnet-lab --name vm-web
az vm deallocate --resource-group rg-vnet-lab --name vm-app
az vm deallocate --resource-group rg-vnet-lab --name vm-db
  • Deallocated VMs stop incurring compute charges. Your VNet, subnets, and NSGs remain configured at no cost.
  • To restart your VMs later, replace deallocate with start in each command above.

Delete - I don't want to use this again

Remove all project resources completely by deleting the resource group. This is irreversible and deletes everything inside it.

  • Delete the entire resource group by running this command:
az group delete --name rg-vnet-lab --yes --no-wait
  • The --no-wait flag returns control immediately while Azure deletes resources in the background.
  • This removes all 3 VMs, the VNet, all subnets, all NSGs, and all public IPs in one action.
  • To confirm deletion is complete, run az group show --name rg-vnet-lab after a few minutes. You should see a "ResourceGroupNotFound" error, confirming everything is gone.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-style segmented network from scratch using Azure CLI.

You've learned how to:

  • Created a VNet with 3 purpose-built subnets using CIDR address planning and proved Zero Trust network isolation by testing allowed and blocked connectivity paths.
  • Designed and implemented 3 Network Security Groups with priority-based security rules that enforce strict tier-to-tier communication between Web, App, and Database layers.
  • Deployed test VMs into each subnet and used az vm run-command invoke to verify that direct Web-to-Database traffic is blocked while the proper App-to-Database path is allowed.
  • Extended segmentation with Application Security Groups for scalable, role-based micro-segmentation that eliminates manual IP address management.

Ready to quiz yourself?