Secure a Virtual Network with Bicep
Define a Zero Trust three-tier Azure network architecture using Bicep IaC.
Introduction
30 Second Summary
Think about how a building has locked doors between floors, badge readers at each department, and security cameras at the vault. Cloud networks need the same layered protection, but most start as one open room where everything can reach everything else.
In this project, you will write a Bicep template that builds a secure three-tier Azure network from scratch. You will define subnets, lock them down with security rules, and restrict a storage account to accept traffic from only one subnet.
What You'll Build
Run a single compile command and produce a deployment-ready ARM template that defines an entire isolated network architecture you can hand to any Azure engineer and say "this is how we segment our tiers."
By the end of this project, you'll have:
- A three-tier Virtual Network (web, app, data) with subnet-level isolation enforced by Network Security Groups that block everything by default and selectively allow only the traffic each tier needs.
- A service endpoint that routes storage traffic over the Azure backbone, paired with a storage account locked down to accept connections from one subnet only.
- Compiled ARM JSON output you can inspect to verify that your template produces the correct resource graph, all without spending a cent on deployment.
- Secret Mission: Refactor hardcoded IP-based rules into Application Security Groups for role-based, maintainable network policies.
Are there any prerequisites?
You need Azure CLI and VS Code with the Bicep extension installed on your machine. Step 1 includes verification commands and install instructions if anything is missing.
Before We Start
Before diving into any templates, let's lock in what you're about to build and why it matters. This step is your moment to commit to the secure network architecture you'll define and understand why codifying network security as Infrastructure as Code is a foundational cloud engineering skill.
Set Up and Verify Your Bicep Environment
Before you write a single resource definition, you need confidence that your toolchain can compile Bicep templates and give you real-time feedback on errors.
In this step, you will verify that the Bicep CLI is installed and up to date, create your project folder, and confirm the full compile-to-JSON loop works end to end.
In this step, get ready to:
- Verify the Bicep CLI is installed and upgrade it to the latest version.
- Create a project folder and starter Bicep file with VS Code IntelliSense active.
- Compile the template to ARM JSON and confirm the output is error-free.
Verify your Bicep CLI
The Bicep CLI is a compiler that translates .bicep files into ARM JSON templates. It ships as a self-contained binary managed by the Azure CLI.
- Press Cmd+Space to open Spotlight Search.
- Type Terminal and press Enter to open it.
- Check whether the Bicep CLI is installed by running this command:
az bicep version
✔️ I see a version number
You should see output like Bicep CLI version 0.37.4 (your exact numbers may differ). This confirms Bicep is installed and working.
ⓧ I see an error or Bicep CLI not found
Azure CLI automatically installs the Bicep binary when you first need it. Install it now by running:
az bicep install
Once it finishes, re-run az bicep version to confirm you see a version number.
- Ensure you have the latest Bicep features by running this upgrade command:
az bicep upgrade
What does this command do?
The az bicep upgrade command downloads the latest Bicep CLI binary and replaces your current version. This ensures you have support for the newest resource types and language features.
You should see output confirming the upgrade completed successfully.
Upgrade failed?
Make sure your Azure CLI itself is up to date. You need version 2.20.0 or later for Bicep support. Run az --version to check.
If your Azure CLI is outdated, update it using Homebrew on macOS, then retry the upgrade.
Help me fix my Bicep CLI upgrade
Create your project folder and starter file
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir secure-vnet-bicep
cd secure-vnet-bicep
- Confirm the folder was created by running:
ls ~/Desktop | grep secure-vnet-bicep
You should see secure-vnet-bicep in the output.
- Create the starter Bicep file by running:
touch main.bicep
- Open the project folder in VS Code by running:
code .
What if the code command is not found?
Open VS Code manually using Cmd+Space, type Visual Studio Code, and press Enter. Then click File in the menu bar, click Open Folder, and select the secure-vnet-bicep folder on your Desktop.
- In the VS Code Explorer sidebar, click main.bicep to open it.
- Type the following code into main.bicep:
@description('Azure region for all resources')
param location string = 'eastus2'
What does this code do?
The @description decorator adds metadata explaining what the parameter is for. The param keyword declares an input parameter named location with type string and a default value of eastus2.
Every resource you add later will reference this parameter for its deployment region.
- Save the file with Cmd+S.
Look at the bottom-right corner of VS Code. You should see Bicep as the language mode. This confirms the Bicep extension is active and providing IntelliSense for your file.
Don't see Bicep as the language mode?
Make sure your file is saved with the .bicep extension. VS Code uses the extension to detect the language.
If the Bicep extension is not installed, click the Extensions icon in the left sidebar (or press Cmd+Shift+X), search for Bicep, and install the Microsoft Bicep extension.
Help me troubleshoot the Bicep extension
Compile the template and verify the output
The az bicep build command compiles your .bicep file into an ARM JSON template. This is the same compilation step Azure would perform during a real deployment.
- Return to your Terminal (or open the VS Code integrated terminal with Ctrl+`).
Before you run the next command, what do you think will happen? The file has one parameter and no resources.
- Compile the Bicep template by running:
az bicep build --file main.bicep
The command completes with no output and no errors. A new file called main.json appears in your project folder.
- Confirm the file was generated by running:
ls
You should see both main.bicep and main.json listed.
- Inspect the compiled output by running:
cat main.json
You should see a JSON object with a $schema pointing to the ARM deployment schema, a parameters section containing your location parameter with its default value of eastus2, and an empty resources array.
Why is the resources array empty?
You declared a parameter but no resources yet. The compiled JSON faithfully represents what you wrote. In the next step, you will add a Virtual Network resource and see it appear in this output.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.bicep and that main.json exists in your folder.
ⓧ I'd like to double check the full code
Your main.bicep should contain exactly this:
@description('Azure region for all resources')
param location string = 'eastus2'
Seeing an error when you run az bicep build?
Make sure your terminal is inside the secure-vnet-bicep folder. Run pwd to check your current directory.
If you see a syntax error, open main.bicep in VS Code and check for typos. The Bicep extension underlines errors with red squiggly lines.
Help me fix my Bicep build error
Your Bicep environment is verified and your first template compiles cleanly. Next up, you will define a Virtual Network with three subnets that segment traffic into web, app, and data tiers.
Define the Virtual Network and Three-Tier Subnets
In the last step you verified your Bicep toolchain and compiled a minimal template. Now you have a working pipeline from .bicep source to ARM JSON output.
By default, Azure gives every resource in a Virtual Network full access to every other resource. That flat topology is the first thing an attacker exploits after a breach. Segmenting the network into purpose-specific subnets creates boundaries you can later enforce with security rules.
In this step, get ready to:
- Define a Virtual Network resource with a /16 address space.
- Create three subnet child resources (web, app, data) using the recommended parent/child pattern.
- Compile the template and inspect the ARM JSON to verify all subnets appear correctly.
Add the prefix parameter and VNet resource
Every resource in this project will share a naming prefix. Adding a parameter for it now means you can rename everything from a single place later.
- In VS Code, open main.bicep.
- Below the existing location parameter, add the prefix parameter and the VNet resource by pasting this code:
@description('Prefix for resource names')
param prefix string = 'secure'
// --- Virtual Network and Subnets ---
resource vnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
name: '${prefix}-vnet'
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
}
}
What does this code do?
The prefix parameter defaults to 'secure' and gets interpolated into every resource name using ${prefix}. This keeps naming consistent across the entire template.
The vnet resource declares a Virtual Network with a 10.0.0.0/16 address space. That /16 block gives you 65,536 IP addresses to carve into smaller subnets.
- Save main.bicep.
- Check the VS Code editor. You should see no red underlines on the new code, confirming the Bicep extension accepts the syntax.
Seeing red underlines?
- Make sure the new code sits below the existing param location string = 'eastus2' line, not above it.
- Check that curly braces and square brackets are balanced. A missing } or ] will cause errors on multiple lines.
- Confirm the file is recognized as Bicep. The status bar at the bottom of VS Code should say Bicep, not Plain Text.
Still stuck? Help me fix red underlines in my Bicep file.
Declare subnets as child resources
Bicep supports two patterns for declaring subnets: inline within the VNet properties, or as separate child resources using a parent reference. The child resource pattern is the recommended approach because it gives each subnet an independent lifecycle.
- Below the closing } of the vnet resource, add the three subnet child resources by pasting this code:
resource snetWeb 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-web'
properties: {
addressPrefix: '10.0.1.0/24'
}
}
resource snetApp 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-app'
properties: {
addressPrefix: '10.0.2.0/24'
}
dependsOn: [
snetWeb
]
}
resource snetData 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-data'
properties: {
addressPrefix: '10.0.3.0/24'
}
dependsOn: [
snetApp
]
}
What does this code do?
Each subnet uses parent: vnet to declare itself as a child of the Virtual Network. The resource type Microsoft.Network/virtualNetworks/subnets tells Azure this is a subnet, not a standalone resource.
Each subnet gets a /24 CIDR block (256 addresses) carved from the VNet's /16 space. snet-web gets 10.0.1.0/24, snet-app gets 10.0.2.0/24, and snet-data gets 10.0.3.0/24.
The dependsOn array forces Azure to deploy subnets sequentially rather than in parallel. Without this, simultaneous subnet deployments to the same VNet can fail with a conflict error.
Why three tiers?
This mirrors a classic production architecture. The web tier faces the internet. The app tier runs business logic and only talks to the web tier. The data tier holds sensitive storage and only talks to the app tier.
Right now these boundaries are just address ranges. In the next step you will add Network Security Groups that turn them into enforced traffic rules.
- Save main.bicep.
- Confirm VS Code shows no red underlines anywhere in the file.
Seeing errors on the subnet resources?
- Make sure parent: vnet references the exact symbolic name you gave the VNet resource (vnet). A typo here causes an unresolved-reference error.
- Check that dependsOn references use the Bicep symbolic name (snetWeb, snetApp), not the deployed name (snet-web, snet-app).
- Verify the subnet code sits outside the VNet's closing brace, not nested inside it.
Need more help? Help me debug subnet child resource errors in Bicep.
Compile and inspect the ARM JSON
The Bicep extension confirms syntax in real-time, but the real proof is a successful compilation to ARM JSON. The compiled output is what Azure would actually deploy.
Before you run the build, think about this: how many subnet entries do you expect to find in the compiled JSON?
- Compile your template by running this command in the terminal:
az bicep build --file main.bicep
- Open main.json in VS Code.
- Search for addressPrefix using Cmd+F (macOS) or Ctrl+F (Windows).
You should see three matches: 10.0.1.0/24, 10.0.2.0/24, and 10.0.3.0/24. Each one is a subnet entry nested under the VNet resource in the compiled ARM template.
Build command failed?
- Read the error message. Bicep points to the exact line and column with the problem.
- If you see a "resource type is not valid" error, check that the API version is exactly 2025-01-01 with no typos.
- If you see "unable to find file", make sure your terminal is in the same folder as main.bicep. Run ls to confirm.
Still failing? Help me fix az bicep build errors on my main.bicep.
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.bicep and confirmed all three CIDR blocks appear in main.json.
ⓧ I'd like to double check the full code
Your main.bicep should contain these sections in order. Scroll up and confirm each is present:
- The location parameter (already existed from Step 1).
- The prefix parameter with default 'secure'.
- The vnet resource with address space 10.0.0.0/16.
- Three subnet child resources: snetWeb (10.0.1.0/24), snetApp (10.0.2.0/24), snetData (10.0.3.0/24).
- The dependsOn chain: snetApp depends on snetWeb, and snetData depends on snetApp.
Your network foundation is in place. Three subnets exist, but right now nothing stops traffic from flowing freely between them. Next up, you will add Network Security Groups that enforce the deny-all-then-allow pattern and turn these address boundaries into real security controls.
Create NSGs with Deny-All-Then-Allow Rules
Your three subnets exist, but right now they offer zero isolation. Azure's default rule AllowVNetInBound (priority 65000) permits all traffic between subnets automatically.
In this step you will add Network Security Groups that flip the default to deny-all, then punch precise holes for only the traffic each tier needs. This transforms your subnet boundaries from cosmetic labels into real security boundaries.
In this step, get ready to:
- Create three NSG resources with an explicit deny-all rule that overrides Azure's permissive default.
- Add selective allow rules so only the correct traffic flows between tiers.
- Associate each NSG to its corresponding subnet and verify in the compiled ARM JSON.
Create the NSG resources with deny-all rules
Every NSG comes with Azure's default rules baked in. The one that matters here is AllowVNetInBound at priority 65000, which lets all VNet traffic through.
How NSG rule priority works
Rules are evaluated from lowest priority number to highest. The first matching rule wins and processing stops.
By placing a Deny rule at priority 1000, it matches before the default Allow at 65000. Valid priorities range from 100 to 4096.
- In main.bicep, add a new comment section and the nsgWeb resource ABOVE the existing // --- Virtual Network and Subnets --- comment by pasting this code:
// --- Network Security Groups ---
resource nsgWeb 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: '${prefix}-nsg-web'
location: location
properties: {
securityRules: [
{
name: 'Deny-All-VNet-Inbound'
properties: {
priority: 1000
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: 'VirtualNetwork'
sourcePortRange: '*'
destinationAddressPrefix: 'VirtualNetwork'
destinationPortRange: '*'
}
}
]
}
}
What does this rule do?
The Deny-All-VNet-Inbound rule at priority 1000 blocks all inbound traffic from the VirtualNetwork service tag. Because 1000 is evaluated before the default AllowVNetInBound at 65000, this overrides Azure's permissive default.
The wildcard * on protocol and port ranges means nothing gets through unless a higher-priority Allow rule explicitly permits it.
- Save main.bicep and compile to confirm the new resource is valid by running:
az bicep build --file main.bicep
The command completes silently and updates main.json. No output means no errors.
Seeing a compilation error?
Check that the new nsgWeb resource sits above the // --- Virtual Network and Subnets --- comment, not inside the vnet resource. Also confirm your curly braces and square brackets are balanced.
help me fix a Bicep compilation error in my NSG resource
Now add the same deny-all pattern for the app and data tiers.
- Add the nsgApp resource directly below nsgWeb by pasting this code:
resource nsgApp 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: '${prefix}-nsg-app'
location: location
properties: {
securityRules: [
{
name: 'Deny-All-VNet-Inbound'
properties: {
priority: 1000
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: 'VirtualNetwork'
sourcePortRange: '*'
destinationAddressPrefix: 'VirtualNetwork'
destinationPortRange: '*'
}
}
]
}
}
- Add the nsgData resource directly below nsgApp by pasting this code:
resource nsgData 'Microsoft.Network/networkSecurityGroups@2025-01-01' = {
name: '${prefix}-nsg-data'
location: location
properties: {
securityRules: [
{
name: 'Deny-All-VNet-Inbound'
properties: {
priority: 1000
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: 'VirtualNetwork'
sourcePortRange: '*'
destinationAddressPrefix: 'VirtualNetwork'
destinationPortRange: '*'
}
}
]
}
}
- Save main.bicep and compile again by running:
az bicep build --file main.bicep
Silent success again. You now have three NSGs that deny everything by default.
Add selective allow rules to each NSG
Deny-all is the foundation, but a network that blocks everything is useless. You need to open specific ports for each tier's legitimate traffic.
- In the nsgWeb resource, find the securityRules array opening bracket [. Add the following two rules ABOVE the existing Deny-All-VNet-Inbound rule:
{
name: 'Allow-HTTP-Inbound'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '80'
}
}
{
name: 'Allow-HTTPS-Inbound'
properties: {
priority: 110
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'Internet'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '443'
}
}
What do these rules allow?
The web tier faces the internet, so it needs to accept HTTP (port 80) and HTTPS (port 443) from any internet source. The Internet service tag represents all public IP addresses outside the VNet.
Priority 100 and 110 are evaluated long before the deny-all at 1000. Traffic that matches an allow rule is permitted without ever reaching the deny.
- Save and compile to confirm the allow rules are valid by running:
az bicep build --file main.bicep
Silent success confirms your rule syntax is correct.
Now do the same for the app and data tiers, but with tighter source restrictions.
- In the nsgApp resource, add this rule ABOVE the Deny-All-VNet-Inbound rule:
{
name: 'Allow-HTTPS-From-Web'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.1.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '443'
}
}
Why a CIDR block instead of Internet?
The app tier should only accept traffic from the web tier, not from the whole internet. Using 10.0.1.0/24 (the web subnet's address range) ensures only the web tier can reach the app tier on port 443.
- In the nsgData resource, add this rule ABOVE the Deny-All-VNet-Inbound rule:
{
name: 'Allow-HTTPS-From-App'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '10.0.2.0/24'
sourcePortRange: '*'
destinationAddressPrefix: '*'
destinationPortRange: '443'
}
}
- Save main.bicep and compile by running:
az bicep build --file main.bicep
Silent success. Each NSG now has a strict traffic flow: web accepts internet traffic, app accepts only from web, data accepts only from app.
Getting a rule validation error?
Make sure each new allow rule object sits inside the securityRules array (between [ and ]) and comes before the deny rule object. Each rule object needs opening and closing curly braces.
help me fix the NSG allow rule I just added
Associate each NSG to its corresponding subnet
The NSGs exist, but they are not attached to anything yet. You need to link each one to its subnet using the networkSecurityGroup property.
- In the snetWeb resource, find its properties block. It currently looks like this:
resource snetWeb 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-web'
properties: {
addressPrefix: '10.0.1.0/24'
}
}
- Replace it with the following, which adds the networkSecurityGroup property:
resource snetWeb 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-web'
properties: {
addressPrefix: '10.0.1.0/24'
networkSecurityGroup: {
id: nsgWeb.id
}
}
}
- Save and compile to confirm the association is valid by running:
az bicep build --file main.bicep
Silent success confirms nsgWeb.id resolves correctly.
What does nsgWeb.id do?
In Bicep, referencing a resource's .id property returns its full Azure resource ID at deployment time. The networkSecurityGroup property on a subnet expects an object with an id field pointing to the NSG resource.
Bicep automatically creates a dependency so the NSG is created before the subnet.
- Now do the same for snetApp. Find this code:
resource snetApp 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-app'
properties: {
addressPrefix: '10.0.2.0/24'
}
dependsOn: [
snetWeb
]
}
- Replace it with:
resource snetApp 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-app'
properties: {
addressPrefix: '10.0.2.0/24'
networkSecurityGroup: {
id: nsgApp.id
}
}
dependsOn: [
snetWeb
]
}
- Finally, find the snetData resource:
resource snetData 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-data'
properties: {
addressPrefix: '10.0.3.0/24'
}
dependsOn: [
snetApp
]
}
- Replace it with:
resource snetData 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
parent: vnet
name: 'snet-data'
properties: {
addressPrefix: '10.0.3.0/24'
networkSecurityGroup: {
id: nsgData.id
}
}
dependsOn: [
snetApp
]
}
Before you compile, predict what you expect to see in the ARM JSON for each subnet. Will the networkSecurityGroup property appear as a nested object or a flat string?
- Save main.bicep and compile by running:
az bicep build --file main.bicep
- Open main.json in VS Code.
- Search for networkSecurityGroup in the file. You should find three occurrences, one per subnet, each containing an id field that references the corresponding NSG resource.
- Search for Deny-All-VNet-Inbound. You should find three occurrences, each with priority set to 1000 and access set to Deny.
- Search for Allow-HTTP-Inbound. You should find it with priority set to 100 and access set to Allow.
Subnet not showing the NSG reference?
Make sure networkSecurityGroup is inside the properties block of the subnet, not outside it. Also confirm you are referencing the correct variable name (nsgWeb.id, nsgApp.id, nsgData.id).
help me fix my subnet-to-NSG association
✔️ Awesome, I've got everything!
Great. Double check you have saved main.bicep and that az bicep build --file main.bicep completes with no errors.
ⓧ I'd like to double check the full code
Scroll through your main.bicep and confirm these sections appear in order:
- The param declarations for location and prefix at the top.
- The // --- Network Security Groups --- comment followed by nsgWeb (3 rules: Allow-HTTP-Inbound at 100, Allow-HTTPS-Inbound at 110, Deny-All-VNet-Inbound at 1000).
- The nsgApp resource (2 rules: Allow-HTTPS-From-Web at 100, Deny-All-VNet-Inbound at 1000).
- The nsgData resource (2 rules: Allow-HTTPS-From-App at 100, Deny-All-VNet-Inbound at 1000).
- The // --- Virtual Network and Subnets --- comment followed by vnet, then snetWeb, snetApp, snetData, each with a networkSecurityGroup property referencing the matching NSG.
Your subnets are now real security boundaries. Next up, you will add a service endpoint to the data subnet and lock down a storage account so it only accepts traffic from that single subnet.
Add a Service Endpoint and Lock Down Storage
Your NSGs now control traffic between subnets, but Azure PaaS services like Storage exist outside your VNet entirely.
A service endpoint routes traffic to Storage over the Azure backbone instead of the public internet. However, the storage account itself still accepts connections from anywhere by default. You must configure both sides: the network endpoint on the subnet, and the storage account's own firewall rules.
In this step, get ready to:
- Add a Microsoft.Storage service endpoint to the data subnet with outbound NSG rules.
- Define a storage account locked down to accept traffic only from the data subnet.
- Compile and verify both sides of the lockdown appear in the ARM JSON.
Add the service endpoint and outbound NSG rules
Service endpoints tell Azure to keep traffic between your subnet and a PaaS service on the private backbone. But you also need outbound NSG rules that explicitly allow traffic to the Storage service tag. Without those rules, the default deny posture blocks the connection before it ever reaches the endpoint.
- In main.bicep, scroll down to the snetData resource.
- Find this block inside its properties:
networkSecurityGroup: {
id: nsgData.id
}
- Add the following serviceEndpoints array directly below the closing } of networkSecurityGroup, still inside properties:
serviceEndpoints: [
{
service: 'Microsoft.Storage'
}
]
What does this code do?
The serviceEndpoints array registers the Microsoft.Storage service on the data subnet. When deployed, Azure routes any traffic from this subnet destined for Storage over the private backbone instead of the public internet.
Now you need matching outbound rules on the data subnet's NSG so the traffic is actually allowed to leave.
- Scroll up to the nsgData resource.
- Find the last rule in the securityRules array (the Deny-All-VNet-Inbound rule).
- Add these two new outbound rules after that last rule object, still inside the securityRules array:
{
name: 'Allow-Storage-Outbound'
properties: {
priority: 100
direction: 'Outbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: 'Storage'
destinationPortRange: '443'
}
}
{
name: 'Deny-Internet-Outbound'
properties: {
priority: 200
direction: 'Outbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
sourcePortRange: '*'
destinationAddressPrefix: 'Internet'
destinationPortRange: '*'
}
}
What do these outbound rules do?
Allow-Storage-Outbound at priority 100 lets the data subnet reach Azure Storage over HTTPS (port 443) using the Storage service tag.
Deny-Internet-Outbound at priority 200 blocks all other outbound internet traffic from the data subnet. Because lower priority numbers are evaluated first, the Storage rule wins before the blanket deny kicks in.
- Save main.bicep.
- Compile the template to confirm your edits are valid by running this command:
az bicep build --file main.bicep
- Confirm the command completes with no errors and main.json is regenerated.
Seeing a compilation error?
- Make sure the serviceEndpoints array is inside the properties block of snetData, not outside it or inside dependsOn.
- Confirm the two new outbound rules are inside the securityRules array of nsgData. Each rule object must be separated by the previous rule without a trailing comma.
- Check that all curly braces and square brackets are balanced.
Help me debug my Bicep compilation error.
Define the storage account with network lockdown
You have configured the network side: the subnet knows to route Storage traffic privately. Now you need the service side. Without setting the storage account's own firewall to deny by default, it still accepts traffic from the public internet.
Why do both sides need configuration?
Enabling a service endpoint on the network side does not automatically restrict the target service. The Azure docs state: "Only turning on service endpoints for the Azure service on the network side does not give you the limited access."
You must also set the storage account's networkAcls.defaultAction to Deny and add a virtualNetworkRules entry that allows only your data subnet.
- At the bottom of main.bicep, after the snetData resource, add the following storage account resource:
// --- Storage Account with Network Rules ---
resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = {
name: '${prefix}datastor${uniqueString(resourceGroup().id)}'
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
allowBlobPublicAccess: false
networkAcls: {
bypass: 'AzureServices'
defaultAction: 'Deny'
virtualNetworkRules: [
{
id: snetData.id
action: 'Allow'
}
]
}
}
}
What does this storage account do?
- The name uses uniqueString(resourceGroup().id) to generate a globally unique storage account name at deployment time.
- minimumTlsVersion: 'TLS1_2' and supportsHttpsTrafficOnly: true enforce encrypted connections.
- allowBlobPublicAccess: false prevents anonymous blob access.
- networkAcls.defaultAction: 'Deny' blocks all traffic by default.
- virtualNetworkRules contains a single entry allowing only the data subnet (snetData.id) to reach the account.
- bypass: 'AzureServices' lets Azure monitoring and backup services still reach the account.
- Save main.bicep.
Before you compile, what do you expect to see in the ARM JSON for the storage account's network rules?
- Compile the final template by running:
az bicep build --file main.bicep
- Confirm the command completes with no errors.
- Open main.json and search for defaultAction.
- Verify the storage account's networkAcls.defaultAction is "Deny".
- Confirm virtualNetworkRules contains a reference to the data subnet's resource ID.
You should see the storage account's networkAcls section with "defaultAction": "Deny" and a virtualNetworkRules array containing the data subnet ID. This proves both sides of the lockdown are in place.
Build failed or storage account missing from JSON?
- Ensure storageAccount is placed after the snetData resource. Bicep resolves snetData.id by reference, so the subnet must be declared earlier in the file.
- Check that kind is set to 'StorageV2' (not 'Storage'). The networkAcls property requires a v2 account.
- Verify the API version is 2024-01-01. Older versions may not support all networkAcls properties.
Help me fix my storage account Bicep compilation error.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.bicep and that main.json was regenerated without errors.
ⓧ I'd like to double check the full code
Your main.bicep is now quite long. Scroll through the file and confirm these sections appear in order:
- Parameters (location and prefix).
- Three NSG resources (nsgWeb, nsgApp, nsgData). nsgData now has four rules: Allow-HTTPS-From-App, Deny-All-VNet-Inbound, Allow-Storage-Outbound, and Deny-Internet-Outbound.
- VNet resource (vnet) with 10.0.0.0/16 address space.
- Three subnet child resources (snetWeb, snetApp, snetData). snetData has serviceEndpoints with Microsoft.Storage.
- Storage account resource (storageAccount) with networkAcls.defaultAction: 'Deny' and virtualNetworkRules pointing to snetData.id.
Your template now enforces a complete lockdown: traffic from the data subnet travels over the Azure backbone to reach Storage, and the storage account refuses connections from anywhere else. Next up, you'll tackle an optional Secret Mission that makes your NSG rules even more maintainable.
Secret mission
Add Application Security Groups
Your NSG rules work, but they reference hardcoded subnet CIDRs like 10.0.1.0/24. If the network grows or subnets get renumbered, every rule breaks. Application Security Groups let you tag resources by role and write rules that survive address changes.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your local files for future reference or delete them.
Resources you used:
- Local project folder containing main.bicep and main.json.
Keep everything running
No action needed. Choose this if you plan to extend the template, deploy it to Azure, or reference it for future networking projects.
- Your main.bicep template and compiled main.json stay on your Desktop ready to deploy or iterate on.
- Azure CLI and the Bicep extension remain installed on your machine.
Pause - I'll come back to this later
Since nothing is running or costing money, there is nothing to pause. Your files sit on disk until you return.
- Leave the project folder on your Desktop as-is.
- When you come back, open the folder in VS Code and pick up where you left off.
Delete - I don't want to use this again
Remove your local project files to start fresh.
- Delete your project folder (the folder on your Desktop containing main.bicep and main.json) by running this command from your Desktop:
cd ~/Desktop
rm -rf secure-vnet-bicep
On Windows?
Use this command instead:
cd ~/Desktop
Remove-Item -Recurse -Force secure-vnet-bicep
Nice Work!
Nice Work!
Nice work! You just designed and validated a production-grade secure network architecture entirely in Bicep, without deploying a single resource or spending a cent.
You've learned how to:
- Build a three-tier Virtual Network (web/app/data) with proper CIDR planning and subnet isolation using Bicep parent/child resource patterns.
- Implement the deny-all-then-allow NSG pattern with correct priority ordering that enforces Zero Trust segmentation between subnets.
- Configure a service endpoint with both network-side (subnet endpoint) and service-side (storage networkAcls) lockdown to restrict a storage account to a single subnet.
- Secret Mission: Replace brittle IP-based NSG rules with Application Security Groups for maintainable, role-based network policies.
Ready to quiz yourself?