Segmented VNet with Secure Subnets in Bicep
Deploy a 3-tier Azure VNet with NSG-enforced Zero Trust network segmentation.
Introduction
30 Second Summary
Think about how a bank separates its lobby from its vault. Customers can walk into the lobby, but only authorized staff can reach the vault through a locked door. Network segmentation works the same way, splitting a network into isolated sections so traffic can only flow where it is explicitly allowed.
In this project, you will build a segmented virtual network in Azure with three subnets, each protected by its own security rules. You will define the entire infrastructure in Bicep, Azure's infrastructure-as-code language, and deploy it from the command line.
What You'll Build
A production-style 3-tier Virtual Network in Azure that enforces Zero Trust network segmentation using Network Security Groups.
By the end of this project, you'll have:
- A deployed VNet with three isolated subnets for web, app, and database tiers.
- Three NSGs with custom inbound rules enforcing least-privilege access between subnets.
- A reusable, parameterized Bicep template following Microsoft-recommended child resource patterns.
- Secret Mission: Add Application Security Groups to replace hardcoded IP ranges in your NSG rules.
Are there any prerequisites?
You should have basic familiarity with Azure concepts like resource groups and regions. An understanding of networking fundamentals such as subnets, CIDR ranges, and ports will also help.
You will need an Azure subscription, Azure CLI, and VS Code with the Bicep extension already installed.
Set Up Your Project and Resource Group
Before you write any Bicep code, you need a local project folder, a file to write in, and an Azure resource group to deploy into.
In this step, you will set up your local environment and create the resource group that will hold your VNet, subnets, and NSGs.
In this step, get ready to:
- Create a project folder and an empty main.bicep file.
- Sign in to Azure CLI and confirm your subscription.
- Create a resource group for the project.
Create your project folder and Bicep file
Every Bicep project starts with a .bicep file. This is where you define the Azure resources you want to deploy.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir segmented-vnet
cd segmented-vnet
- Create an empty main.bicep file by running:
touch main.bicep
- Confirm the file exists by running:
ls
You should see main.bicep in the output.
Don't see main.bicep?
Make sure you ran cd segmented-vnet before running touch main.bicep. If you're on Windows and touch isn't available, use New-Item main.bicep in PowerShell instead.
Why can't I create my Bicep file?
- Open the segmented-vnet folder in VS Code by running:
code .
VS Code opens with your segmented-vnet folder in the Explorer sidebar. You should see main.bicep listed there.
- Click on main.bicep in the Explorer sidebar to open the file in the editor.
Is the Bicep extension working?
With the Bicep extension installed, VS Code recognizes .bicep files and provides syntax highlighting, IntelliSense, and error checking as you type. You should see "Bicep" in the bottom-right status bar when the file is open.
Sign in to Azure CLI
The Azure CLI is how you interact with Azure from the terminal. You need to authenticate before you can create or manage any resources.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Sign in to Azure by running this command:
az login
What happens when you run az login?
A browser window opens and prompts you to sign in with your Azure account. After you authenticate, the terminal displays a JSON list of your available subscriptions.
After signing in, your terminal displays your subscription details. Confirm you see a subscription listed with "state": "Enabled".
No subscriptions showing up?
Make sure you signed in with the same Microsoft account that has an active Azure subscription. If you have multiple accounts, you can specify which one to use with az login --tenant <your-tenant-id>.
Help me troubleshoot my Azure login
Create a resource group
A resource group is a container that holds related Azure resources. Everything you deploy in this project goes into one resource group, which makes cleanup easy at the end.
- Create your resource group by running this command:
az group create --name [[RGNAME="your resource group name e.g. segvnet-rg"]] --location [[AZREGION="your preferred Azure region e.g. eastus"]]
What does this command do?
- --name sets the name of your resource group. Pick something descriptive like segvnet-rg.
- --location specifies which Azure region to create it in. Use the region closest to you for lower latency (e.g. eastus, westus2, uksouth).
You should see a JSON response with "provisioningState": "Succeeded". This confirms your resource group is ready.
Seeing an authorization error?
Your Azure account might not have permission to create resource groups. Check that your subscription has the Contributor or Owner role assigned to your account. You can verify your role in the Azure Portal under Subscriptions > Access control (IAM).
Help me fix my Azure authorization error
Your environment is ready. Next up, you will define the VNet and its three subnets in your main.bicep file.
Define the VNet and Subnets in Bicep
Your resource group is ready. Now you need to define the actual network infrastructure that will live inside it.
A Virtual Network (VNet) is the private address space where your Azure resources communicate. To enforce Zero Trust segmentation, you divide that address space into subnets, one per tier. In this step, you will write the Bicep code that defines a VNet with three subnets using Microsoft's recommended child resource pattern.
In this step, get ready to:
- Define parameters and variables that keep your template reusable.
- Create a VNet resource with a 10.0.0.0/16 address space.
- Add three subnets as nested child resources inside the VNet.
Define parameters and variables
Parameters let anyone deploying your template customize values like region and naming prefix without editing the code. Variables then build consistent resource names from those parameters.
- Open main.bicep in VS Code.
- Add the following parameters and variables at the top of the file:
@description('Azure region for all resources')
param location string = resourceGroup().location
@description('Project name prefix used for naming resources')
param projectName string = 'segvnet'
var vnetName = '${projectName}-vnet'
var webSubnetName = 'web-subnet'
var appSubnetName = 'app-subnet'
var dbSubnetName = 'db-subnet'
var webNsgName = '${projectName}-web-nsg'
var appNsgName = '${projectName}-app-nsg'
var dbNsgName = '${projectName}-db-nsg'
What does this code do?
The @description decorator adds documentation to each parameter so other engineers understand what it controls.
- location defaults to the resource group's region. This means all resources deploy to the same region automatically.
- projectName defaults to segvnet. It is used as a prefix in the var declarations to build consistent names like segvnet-vnet and segvnet-web-nsg.
- The NSG variables (webNsgName, appNsgName, dbNsgName) are defined now because you will use them in the next step when you create the Network Security Groups.
- Save main.bicep.
You won't see a visible change yet. The next chunk adds the VNet resource that uses these parameters.
Seeing red squiggles in VS Code?
- Make sure you have the Bicep extension installed. Without it, VS Code cannot parse .bicep files.
- Check that the string interpolation uses ${projectName} (dollar sign followed by curly braces), not just curly braces alone.
Why is my Bicep file showing errors in VS Code?
Create the VNet resource with subnets
Microsoft recommends defining subnets as nested child resources inside the VNet, rather than using an inline subnets property array. This gives each subnet its own lifecycle, so you can update one without redeploying the whole VNet.
- Below your variables in main.bicep, add the VNet resource with its three subnet child resources:
// Virtual Network with Subnets as child resources
resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
}
resource webSubnet 'subnets' = {
name: webSubnetName
properties: {
addressPrefix: '10.0.1.0/24'
}
}
resource appSubnet 'subnets' = {
name: appSubnetName
properties: {
addressPrefix: '10.0.2.0/24'
}
dependsOn: [
webSubnet
]
}
resource dbSubnet 'subnets' = {
name: dbSubnetName
properties: {
addressPrefix: '10.0.3.0/24'
}
dependsOn: [
appSubnet
]
}
}
What does this code do?
- The vnet resource declares a Virtual Network with a 10.0.0.0/16 address space. That gives you over 65,000 available IP addresses to divide across subnets.
- Each resource ... 'subnets' block is a child resource nested inside the VNet. Bicep automatically sets the parent-child relationship because they are physically nested in the code.
- The three subnets each carve out a /24 range (256 addresses): web at 10.0.1.0/24, app at 10.0.2.0/24, and db at 10.0.3.0/24.
- dependsOn forces Azure to create subnets one at a time. Without it, Azure deploys child resources in parallel, which can cause "Another operation on this resource is already in progress" errors.
Why child resources instead of an inline subnets array?
Microsoft's official Bicep networking guide states that subnets should always be managed as child resources. The inline subnets property approach can accidentally delete subnets if you omit one during an update. The child resource pattern gives each subnet an independent lifecycle.
- Save main.bicep.
- Confirm there are no red squiggles or error indicators in VS Code. The Bicep extension validates syntax on save.
Seeing errors after adding the VNet resource?
- Make sure the child resource blocks (webSubnet, appSubnet, dbSubnet) are inside the vnet resource's curly braces. They must be nested, not placed after the closing brace.
- Check that dependsOn references use the symbolic name (webSubnet, appSubnet) not the string name ('web-subnet').
- Verify the API version is exactly 2025-01-01 with no typos.
Help me fix Bicep errors in my VNet child resource definition.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.bicep file so far. Make sure yours matches exactly.
@description('Azure region for all resources')
param location string = resourceGroup().location
@description('Project name prefix used for naming resources')
param projectName string = 'segvnet'
var vnetName = '${projectName}-vnet'
var webSubnetName = 'web-subnet'
var appSubnetName = 'app-subnet'
var dbSubnetName = 'db-subnet'
var webNsgName = '${projectName}-web-nsg'
var appNsgName = '${projectName}-app-nsg'
var dbNsgName = '${projectName}-db-nsg'
// Virtual Network with Subnets as child resources
resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
}
resource webSubnet 'subnets' = {
name: webSubnetName
properties: {
addressPrefix: '10.0.1.0/24'
}
}
resource appSubnet 'subnets' = {
name: appSubnetName
properties: {
addressPrefix: '10.0.2.0/24'
}
dependsOn: [
webSubnet
]
}
resource dbSubnet 'subnets' = {
name: dbSubnetName
properties: {
addressPrefix: '10.0.3.0/24'
}
dependsOn: [
appSubnet
]
}
}
Your VNet skeleton is in place with three isolated subnets. Next up, you will create the Network Security Groups that lock down what traffic can flow between them.
Create the NSGs with Security Rules
Your VNet now has three subnets, but there's nothing stopping traffic from flowing freely between them. In a real 3-tier application, you want the database layer to only accept connections from the app layer, and the app layer to only accept connections from the web layer.
This is where Network Security Groups come in. An NSG acts as a virtual firewall that you attach to a subnet. It contains a list of security rules that explicitly allow or deny traffic based on source, destination, port, and protocol.
In this step, get ready to:
- Define a web NSG that allows HTTP and HTTPS from the internet.
- Define an app NSG that only allows traffic from the web subnet.
- Define a database NSG that only allows traffic from the app subnet.
Define the web NSG
The web tier faces the public internet. Its NSG needs to allow HTTP and HTTPS traffic in, then deny everything else.
- In main.bicep, find the line that says // Virtual Network with Subnets as child resources.
- Add the following code above that comment:
// Network Security Groups
resource webNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: webNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowHTTP'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '80'
}
}
{
name: 'AllowHTTPS'
properties: {
priority: 110
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '443'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
What does this code do?
This defines a Network Security Group resource with three inbound rules, evaluated in priority order (lowest number first).
- AllowHTTP (priority 100) permits TCP traffic on port 80 from the Internet service tag. This tag represents all public IP addresses outside your VNet.
- AllowHTTPS (priority 110) permits TCP traffic on port 443 from the internet.
- DenyAllInbound (priority 4096) blocks everything else. Because Azure evaluates rules from lowest priority number first, only traffic that matched the two allow rules above gets through.
What is NSG rule priority?
Priority is a number between 100 and 4096. Azure processes rules in ascending order and stops at the first match. A rule at priority 100 is evaluated before a rule at priority 4096.
Azure also has default rules at priorities 65000 and above that you cannot override. Your custom deny-all at 4096 catches traffic before it ever reaches the default allow rules.
- Save main.bicep.
- Check that VS Code shows no red squiggly underlines in the new webNsg resource block. The Bicep extension validates your code in real time.
Seeing red underlines?
- Make sure the NSG code is placed above the VNet resource, not inside it. NSGs are top-level resources, not children of the VNet.
- Check that every opening brace { has a matching closing brace }. Each security rule has nested properties inside an outer object.
- Confirm the API version is exactly 2025-01-01 with no typos.
Help me fix Bicep syntax errors in my NSG resource definition
Define the app NSG
The app tier should only accept traffic from the web subnet. Instead of allowing the entire internet, you restrict the source to the web subnet's CIDR range.
- In main.bicep, add the following code directly below the closing } of the webNsg resource:
resource appNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: appNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowFromWebSubnet'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.1.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '8080'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
What does this code do?
- AllowFromWebSubnet only permits TCP on port 8080 when the source is 10.0.1.0/24 (the web subnet's address range). Traffic from any other source is ignored by this rule.
- DenyAllInbound blocks all other traffic. Together, only the web tier can talk to the app tier, and only on the specific port the application listens on.
Why use a CIDR range instead of a service tag?
The Internet service tag you used in the web NSG is a built-in Azure alias for all public IPs. For internal subnet-to-subnet traffic, there's no built-in tag. You specify the exact CIDR block of the source subnet instead.
This is the foundation of least-privilege network access. Each tier can only be reached by the tier directly in front of it.
- Save main.bicep.
- Confirm no red underlines appear in the appNsg resource block.
Seeing an error on sourceAddressPrefix?
- Make sure the CIDR is wrapped in single quotes: '10.0.1.0/24'. Missing quotes cause a syntax error in Bicep.
- Verify the appNsg resource is placed outside and below the webNsg resource's closing brace, not nested inside it.
Help me debug my app NSG definition
Define the database NSG
The database tier is the most restricted. It should only accept connections from the app subnet on port 1433, the default port for SQL Server.
- In main.bicep, add the following code directly below the closing } of the appNsg resource:
resource dbNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: dbNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowFromAppSubnet'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.2.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '1433'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
What does this code do?
- AllowFromAppSubnet permits TCP on port 1433 only from 10.0.2.0/24 (the app subnet). The database tier is completely invisible to the web tier and the internet.
- DenyAllInbound blocks all other traffic. This pattern implements Zero Trust networking. No traffic is implicitly trusted, even within the same VNet.
- Save main.bicep.
- Confirm no red underlines appear anywhere in the file. You should now have three NSG resources above the VNet resource.
Seeing errors in the dbNsg block?
- Double check the source CIDR is '10.0.2.0/24' (the app subnet), not '10.0.1.0/24' (the web subnet). Each NSG references the tier directly in front of it.
- Make sure the destination port is '1433' as a string in single quotes, not a bare number.
Help me fix my database NSG definition
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.bicep file as it should look at the end of this step:
@description('Azure region for all resources')
param location string = resourceGroup().location
@description('Project name prefix used for naming resources')
param projectName string = 'segvnet'
var vnetName = '${projectName}-vnet'
var webSubnetName = 'web-subnet'
var appSubnetName = 'app-subnet'
var dbSubnetName = 'db-subnet'
var webNsgName = '${projectName}-web-nsg'
var appNsgName = '${projectName}-app-nsg'
var dbNsgName = '${projectName}-db-nsg'
// Network Security Groups
resource webNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: webNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowHTTP'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '80'
}
}
{
name: 'AllowHTTPS'
properties: {
priority: 110
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '443'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
resource appNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: appNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowFromWebSubnet'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.1.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '8080'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
resource dbNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: dbNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowFromAppSubnet'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.2.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '1433'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
// Virtual Network with Subnets as child resources
resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
}
resource webSubnet 'subnets' = {
name: webSubnetName
properties: {
addressPrefix: '10.0.1.0/24'
}
}
resource appSubnet 'subnets' = {
name: appSubnetName
properties: {
addressPrefix: '10.0.2.0/24'
}
dependsOn: [
webSubnet
]
}
resource dbSubnet 'subnets' = {
name: dbSubnetName
properties: {
addressPrefix: '10.0.3.0/24'
}
dependsOn: [
appSubnet
]
}
}
You now have three NSGs with least-privilege rules that enforce traffic segmentation between your tiers. Next up, you'll wire these NSGs to their subnets and add outputs to surface the resource IDs.
Associate NSGs with Subnets and Add Outputs
You've defined three NSGs with tier-specific security rules. The rules exist, but they aren't protecting anything yet.
In this step, you'll wire each NSG to its corresponding subnet so traffic is actually filtered. You'll also add outputs that surface key resource IDs after deployment, making it easy to reference these resources from other templates or scripts.
In this step, get ready to:
- Associate each NSG with its matching subnet using the networkSecurityGroup property.
- Add outputs that expose VNet, subnet, and NSG identifiers using the :: operator.
- Validate your Bicep syntax before deploying.
Update each subnet to reference its NSG
An NSG only filters traffic when it's associated with a subnet or network interface. You'll add the networkSecurityGroup property to each subnet, passing the resource ID of the corresponding NSG.
- In main.bicep, scroll down to the webSubnet child resource inside the VNet.
- Find this block:
resource webSubnet 'subnets' = {
name: webSubnetName
properties: {
addressPrefix: '10.0.1.0/24'
}
}
- Replace the entire webSubnet resource with this updated version that includes the NSG association:
resource webSubnet 'subnets' = {
name: webSubnetName
properties: {
addressPrefix: '10.0.1.0/24'
networkSecurityGroup: {
id: webNsg.id
}
}
}
What does this change do?
The networkSecurityGroup property takes an object with an id field pointing to the NSG's resource ID. Bicep resolves webNsg.id automatically at deployment time.
Once associated, every resource in this subnet has its inbound and outbound traffic evaluated against the NSG's rules.
- Now find the appSubnet resource directly below. It currently looks like this:
resource appSubnet 'subnets' = {
name: appSubnetName
properties: {
addressPrefix: '10.0.2.0/24'
}
dependsOn: [
webSubnet
]
}
- Replace it with this version that references the App NSG:
resource appSubnet 'subnets' = {
name: appSubnetName
properties: {
addressPrefix: '10.0.2.0/24'
networkSecurityGroup: {
id: appNsg.id
}
}
dependsOn: [
webSubnet
]
}
- Finally, find the dbSubnet resource:
resource dbSubnet 'subnets' = {
name: dbSubnetName
properties: {
addressPrefix: '10.0.3.0/24'
}
dependsOn: [
appSubnet
]
}
- Replace it with this version that references the DB NSG:
resource dbSubnet 'subnets' = {
name: dbSubnetName
properties: {
addressPrefix: '10.0.3.0/24'
networkSecurityGroup: {
id: dbNsg.id
}
}
dependsOn: [
appSubnet
]
}
- Save main.bicep.
Why does each subnet need its own NSG?
This is the core of Zero Trust network segmentation. Instead of one firewall at the perimeter, each tier gets its own guard. The web subnet only accepts internet traffic. The app subnet only accepts traffic from the web subnet. The database subnet only accepts traffic from the app subnet.
If an attacker compromises a web server, they cannot directly reach the database because the DB NSG blocks all traffic except what comes from the app subnet.
Add outputs to expose resource identifiers
Bicep outputs let you surface key values after a deployment completes. Other templates, scripts, or team members can use these IDs to reference your resources without hunting through the portal.
- In main.bicep, scroll to the very bottom of the file (after the closing } of the VNet resource).
- Add the following outputs section:
// Outputs
output vnetName string = vnet.name
output webSubnetId string = vnet::webSubnet.id
output appSubnetId string = vnet::appSubnet.id
output dbSubnetId string = vnet::dbSubnet.id
output webNsgName string = webNsg.name
output appNsgName string = appNsg.name
output dbNsgName string = dbNsg.name
What does the :: operator do?
The :: operator is how Bicep references nested child resources from outside their parent. Since webSubnet is defined inside the vnet resource block, you access it externally with vnet::webSubnet.id.
Top-level resources like webNsg don't need the :: operator. You reference them directly with webNsg.name.
- Save main.bicep.
Seeing red squiggles in VS Code?
If your editor shows errors on the :: lines, make sure the child resource symbolic names (webSubnet, appSubnet, dbSubnet) match exactly between the child resource declarations and the output lines. Names are case-sensitive.
help me fix red squiggles in my Bicep outputs
Validate your Bicep syntax
Before deploying, you can catch syntax errors locally by compiling your Bicep file to ARM JSON. If the build succeeds with no output, your template is valid.
- Validate your template by running this command:
az bicep build --file main.bicep
- If the command completes with no errors, your syntax is valid. You should see no output (silence means success).
Seeing a build error?
Common causes include missing closing braces, a typo in a resource symbolic name, or a misplaced comma. The error message includes a line number. Check that line against the full code below.
help me fix my Bicep build error
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.bicep as it should look at the end of this step:
@description('Azure region for all resources')
param location string = resourceGroup().location
@description('Project name prefix used for naming resources')
param projectName string = 'segvnet'
var vnetName = '${projectName}-vnet'
var webSubnetName = 'web-subnet'
var appSubnetName = 'app-subnet'
var dbSubnetName = 'db-subnet'
var webNsgName = '${projectName}-web-nsg'
var appNsgName = '${projectName}-app-nsg'
var dbNsgName = '${projectName}-db-nsg'
// Network Security Groups
resource webNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: webNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowHTTP'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '80'
}
}
{
name: 'AllowHTTPS'
properties: {
priority: 110
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '443'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
resource appNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: appNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowFromWebSubnet'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.1.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '8080'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
resource dbNsg 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: dbNsgName
location: location
properties: {
securityRules: [
{
name: 'AllowFromAppSubnet'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.2.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '1433'
}
}
{
name: 'DenyAllInbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '*'
}
}
]
}
}
// Virtual Network with Subnets as child resources
resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
}
resource webSubnet 'subnets' = {
name: webSubnetName
properties: {
addressPrefix: '10.0.1.0/24'
networkSecurityGroup: {
id: webNsg.id
}
}
}
resource appSubnet 'subnets' = {
name: appSubnetName
properties: {
addressPrefix: '10.0.2.0/24'
networkSecurityGroup: {
id: appNsg.id
}
}
dependsOn: [
webSubnet
]
}
resource dbSubnet 'subnets' = {
name: dbSubnetName
properties: {
addressPrefix: '10.0.3.0/24'
networkSecurityGroup: {
id: dbNsg.id
}
}
dependsOn: [
appSubnet
]
}
}
// Outputs
output vnetName string = vnet.name
output webSubnetId string = vnet::webSubnet.id
output appSubnetId string = vnet::appSubnet.id
output dbSubnetId string = vnet::dbSubnet.id
output webNsgName string = webNsg.name
output appNsgName string = appNsg.name
output dbNsgName string = dbNsg.name
Your template is complete and validated. Next up, you'll deploy it to Azure and verify the segmentation is working as expected.
Deploy and Verify the Segmentation
Your Bicep template is complete, validated, and ready to become real infrastructure. The moment of truth is deploying it to Azure and confirming that your segmentation rules actually work.
In this step, you will deploy the template and then inspect the live resources to prove that each subnet is locked down by its own NSG with the correct rules.
In this step, get ready to:
- Deploy your Bicep template to Azure.
- Verify NSG rules using the Azure CLI.
- Confirm subnet-to-NSG associations.
Deploy the Bicep template to Azure
This single command sends your entire main.bicep file to Azure Resource Manager, which creates all six resources (3 NSGs, 1 VNet, 3 subnets) in the correct dependency order.
- Deploy the template by running this command:
az deployment group create --resource-group [[RG_NAME="your-resource-group-name"]] --template-file main.bicep
What does this command do?
The az deployment group create command submits your Bicep file to Azure Resource Manager. Azure compiles the Bicep into an ARM template behind the scenes, then provisions each resource in the order determined by your dependencies.
The deployment typically takes 30 to 60 seconds. You will see a JSON output block when it completes successfully.
- Wait for the deployment to finish. You should see a large JSON response with "provisioningState": "Succeeded" near the top.
Seeing an error during deployment?
- If you see InvalidTemplateDeployment, check that your resource group name is correct and that you are still logged in with az account show.
- If you see Another operation on this resource is already in progress, wait 30 seconds and run the command again. This can happen if a previous deployment is still cleaning up.
- Make sure you are running the command from the same folder that contains main.bicep.
Help me troubleshoot my Bicep deployment error
Verify the deployed NSG rules
A successful deployment does not guarantee correct rules. You need to inspect the actual NSG rules that Azure created to confirm they match your intent.
- List the rules for your web NSG by running this command:
az network nsg rule list --nsg-name segvnet-web-nsg --resource-group [[RG_NAME="your-resource-group-name"]] --output table
- You should see three rules in the table: AllowHTTP (priority 100, port 80), AllowHTTPS (priority 110, port 443), and DenyAllInbound (priority 4096).
- Now check the app NSG rules by running:
az network nsg rule list --nsg-name segvnet-app-nsg --resource-group [[RG_NAME="your-resource-group-name"]] --output table
- You should see AllowFromWebSubnet (priority 100, source 10.0.1.0/24, port 8080) and DenyAllInbound (priority 4096).
- Finally, check the database NSG rules by running:
az network nsg rule list --nsg-name segvnet-db-nsg --resource-group [[RG_NAME="your-resource-group-name"]] --output table
- You should see AllowFromAppSubnet (priority 100, source 10.0.2.0/24, port 1433) and DenyAllInbound (priority 4096).
What do these rules prove?
Each NSG only allows the specific traffic its tier needs. The web tier accepts public HTTP/HTTPS traffic. The app tier only accepts connections from the web subnet on port 8080. The database tier only accepts connections from the app subnet on port 1433.
The DenyAllInbound rule at priority 4096 blocks everything else. Because NSG rules are evaluated by priority (lowest number first), any traffic that does not match an allow rule above it gets denied.
Not seeing any rules in the table?
- Double-check the NSG name matches exactly. The default names are segvnet-web-nsg, segvnet-app-nsg, and segvnet-db-nsg.
- Make sure the --resource-group value matches the resource group you deployed to.
Help me figure out why my NSG rule list is empty
Confirm subnet-to-NSG associations
Rules only protect a subnet if the NSG is actually associated with it. This final check confirms the link between each subnet and its NSG.
- Inspect the web subnet's NSG association by running:
az network vnet subnet show --vnet-name segvnet-vnet --name web-subnet --resource-group [[RG_NAME="your-resource-group-name"]] --query networkSecurityGroup.id --output tsv
- You should see a resource ID path ending in segvnet-web-nsg.
- Check the app subnet association by running:
az network vnet subnet show --vnet-name segvnet-vnet --name app-subnet --resource-group [[RG_NAME="your-resource-group-name"]] --query networkSecurityGroup.id --output tsv
- You should see a resource ID path ending in segvnet-app-nsg.
- Check the database subnet association by running:
az network vnet subnet show --vnet-name segvnet-vnet --name db-subnet --resource-group [[RG_NAME="your-resource-group-name"]] --query networkSecurityGroup.id --output tsv
- You should see a resource ID path ending in segvnet-db-nsg.
What does this confirm?
Each subnet now has its own dedicated NSG filtering traffic. Any VM or resource deployed into these subnets will automatically inherit the NSG rules. This is Zero Trust networking in action. Traffic between tiers is explicitly allowed rather than implicitly trusted.
Seeing null or empty output?
- If the output is blank, the NSG was not associated with the subnet during deployment. Re-run az deployment group create with your main.bicep file to re-apply the associations.
- Confirm your VNet name is segvnet-vnet and the subnet names are web-subnet, app-subnet, and db-subnet.
Help me troubleshoot my subnet-to-NSG association
Your segmented VNet is live and locked down. Every subnet has its own NSG enforcing least-privilege access between tiers. Next up, you can take on the Secret Mission to replace those hardcoded IP ranges with Application Security Groups.
Secret mission
Add Application Security Groups
Your NSG rules currently use hardcoded IP ranges to control traffic between subnets. In production, teams use Application Security Groups to manage rules at scale without touching IP addresses. Can you refactor your NSG rules to use ASGs instead?
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. This project runs entirely on free Azure resources (VNets, subnets, and NSGs have no hourly or monthly charges), so there are no ongoing costs.
Resources you used:
- 1 Resource Group.
- 1 Virtual Network with 3 subnets (web, app, db).
- 3 Network Security Groups (web-nsg, app-nsg, db-nsg).
- 1 local project folder with your main.bicep template.
Keep everything running
No action needed. Choose this if you are still actively building or plan to expand this template with more resources.
- Your VNet, subnets, and NSGs remain deployed at zero cost.
- Your local main.bicep file stays on your Desktop for future modifications.
- You can redeploy or update the template at any time using the same az deployment group create command.
Pause - I'll come back to this later
Since VNets and NSGs are free, there is nothing to pause for cost savings. Keep the resources deployed and return when you are ready.
- Your resources will remain exactly as you left them with no charges.
- Bookmark the Azure portal resource group page for easy access later.
Delete - I don't want to use this again
Remove all project resources from Azure and start fresh. Deleting the resource group removes everything inside it in one step.
- Delete the Azure resource group by running this command:
az group delete --name [[RG_NAME="your resource group name"]] --yes --no-wait
- The --no-wait flag returns control immediately while Azure deletes resources in the background.
- Optionally, delete the local project folder from your Desktop if you no longer need the Bicep template.
Nice Work!
Nice Work!
You just built a segmented Azure VNet with Zero Trust network security using Bicep. Your three-tier architecture enforces least-privilege access between subnets, exactly how production cloud environments are designed.
You learned how to:
- Built a parameterized Bicep template using Microsoft-recommended child resource patterns and the :: operator.
- Deployed a 3-tier VNet with web, app, and database subnets, each isolated by its own Network Security Group.
- Verified Zero Trust segmentation by inspecting effective security rules via the Azure CLI.
- Secret Mission: Added Application Security Groups to replace hardcoded IP ranges with role-based groups.
Ready to quiz yourself?