Azure Bicep Loops, Dependencies, and What-If Deployments

All right, so by the end of the last post your template was split into modules, each one a small file with a clear job, wired together through parameters and outputs. That structure is what makes the next three tools worth having, because each of them gets more useful the more resources you're managing at once.
First, loops, so you can deploy several of something without copy-pasting the same resource block over and over. Second, dependencies, which is how Bicep decides what gets deployed first, and the answer is that it mostly works it out on its own. And third, what-if, the one command I wish someone had shown me much earlier, because it tells you exactly what a deployment is going to do before it touches anything.
By the end of this post you'll have a full stack, a virtual network with subnets, a network security group, a storage account and a web app, all deployed from one template. And before any of it deploys, you'll know precisely what Azure is about to do.
Loops Stop the Copy-Paste
Here's the scenario. You need three storage accounts. Without loops, you write the same resource block three times and change the name in each one. It works, but it's ugly, and it falls apart the moment someone asks for ten.
Bicep has a for loop, and it goes right inside the resource declaration:
resource storageAccounts 'Microsoft.Storage/storageAccounts@2025-06-01' = [for i in range(0, 3): {
name: 'st${environment}${i}${uniqueString(resourceGroup().id)}'
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
}
}]
The shape is square brackets, the word for, an iterator variable, the word in, and then the collection you're looping over. range(0, 3) produces 0, 1 and 2, so that's three iterations and three storage accounts. The iterator i goes into the name, which keeps every account unique.
That's the syntax, but it isn't how you'll usually use it. Most of the time you don't want three identical resources. You want one resource per item in a list, and subnets are the classic example. Start with an array parameter:
param subnetNames array = [
'frontend'
'backend'
'data'
]
Then loop over it. When you need the position as well as the value, the loop takes both, the item first and the index second:
resource subnets 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = [for (name, i) in subnetNames: {
parent: vnet
name: name
properties: {
addressPrefix: '10.0.${i}.0/24'
}
}]
On the first pass name is frontend and i is 0, so that subnet gets 10.0.0.0/24. The second pass gives you backend on 10.0.1.0/24, and so on. If you need a fourth subnet, you add a fourth name to the array and change nothing else. You write one subnet block instead of three, and it keeps working no matter how long the list gets.
Pro tip: Loop over a list of meaningful names rather than a bare
range()whenever the resources aren't genuinely interchangeable. Subnets calledfrontendanddatatell the next person what each one is for. Subnets calledsubnet0andsubnet1make them go and find out.
Dependencies Bicep Works Out for You
Once you have several resources, some of them have to exist before others. A web app needs its App Service plan first. Subnets need their virtual network first. So how does Bicep know the order?
Most of the time it works it out on its own, and the mechanism is simple. When one resource references another, Bicep sees the reference and deploys the referenced one first:
resource webApp 'Microsoft.Web/sites@2024-04-01' = {
name: name
location: location
properties: {
serverFarmId: appServicePlan.id
}
}
That appServicePlan.id is the whole trick. The web app can't know the plan's ID until the plan exists, so Bicep puts the plan first. That's an implicit dependency, and you didn't write any extra code to get it.
You don't have to take that on trust, either. Compile the template and look at what Bicep actually generated:
az bicep build --file main.bicep
Open the main.json it produces and you'll find a dependsOn entry on the web app pointing at the plan. You never wrote that line. The compiler inferred it from the reference, and that's exactly how you want dependencies to work almost all of the time.
There are rare cases where one resource needs another to exist first but doesn't reference any of its properties. For those, you can say it explicitly:
resource webApp 'Microsoft.Web/sites@2024-04-01' = {
name: 'app-demo'
// ... properties
dependsOn: [
someOtherResource
]
}
I want to be clear about how rare that is, though. If you find yourself writing dependsOn a lot, it usually means there's a reference you could be using instead, and the reference is better, because it can't drift out of sync with what the resource actually needs.
The last piece is the existing keyword, for when you need to use a resource that's already deployed without redeploying it:
resource existingVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = {
name: 'vnet-existing'
}
That tells Bicep the resource is already there, so it doesn't create it and just gives you a handle to it. Now existingVnet.id and existingVnet.properties work anywhere in your template, which is super useful when you're deploying into an environment that already has shared networking set up by another team.
A Full Stack From One Template
So now we have loops and we understand dependencies. Let's put them together into something real: a virtual network with subnets, a network security group, a storage account and a web app, all as modules, all wired together.
Here's the network module. The subnets are created with the loop from earlier:
param vnetName string
param location string
param subnetNames array
param nsgId string
param addressPrefix string = '10.0.0.0/16'
resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
addressPrefix
]
}
}
}
@batchSize(1)
resource subnets 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = [for (name, i) in subnetNames: {
parent: vnet
name: name
properties: {
addressPrefix: '10.0.${i}.0/24'
networkSecurityGroup: {
id: nsgId
}
}
}]
output vnetId string = vnet.id
output subnetIds array = [for (name, i) in subnetNames: subnets[i].id]
Notice the output is looped too, so the module hands back an array of subnet IDs, one per name. And that @batchSize(1) decorator changes how the loop deploys. By default, every instance of a loop deploys in parallel. @batchSize(1) deploys them one at a time instead. For a few subnets that all share the same parent network, I'd rather they went in one after another than all at once.
Gotcha: In the video I define these subnets inline, inside the virtual network's
subnetsproperty. The written version doesn't, and Microsoft's own guidance is the reason. Their networking guidance for Bicep says subnets "should always be managed as child resources, and the subnets property should never be defined within the virtual network resource." Declaring them as child resources, the way this module does, gives the network and its subnets independent lifecycles, so a change to one doesn't quietly rewrite the other.
The NSG module allows HTTP and HTTPS inbound:
param nsgName string
param location string
resource nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = {
name: nsgName
location: location
properties: {
securityRules: [
{
name: 'AllowHTTP'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '80'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
{
name: 'AllowHTTPS'
properties: {
priority: 110
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
]
}
}
output nsgId string = nsg.id
And main.bicep wires all four modules together. The storage and web app modules are the ones from the modules post, with storage returning its blob endpoint as an output called endpoint:
param environment string
param location string = resourceGroup().location
param subnetNames array = [
'frontend'
'backend'
'data'
]
module nsg './modules/nsg.bicep' = {
name: 'nsgDeployment'
params: {
nsgName: 'nsg-${environment}-demo'
location: location
}
}
module network './modules/network.bicep' = {
name: 'networkDeployment'
params: {
vnetName: 'vnet-${environment}-demo'
location: location
subnetNames: subnetNames
nsgId: nsg.outputs.nsgId
}
}
module storage './modules/storage.bicep' = {
name: 'storageDeployment'
params: {
name: 'st${environment}${uniqueString(resourceGroup().id)}'
location: location
}
}
module webapp './modules/webapp.bicep' = {
name: 'webappDeployment'
params: {
name: 'app-${environment}-demo'
location: location
storageEndpoint: storage.outputs.endpoint
}
}
Now look at the dependency chain, because there isn't a single dependsOn anywhere in that file. The web app takes storage.outputs.endpoint, so storage has to finish first. The network takes nsg.outputs.nsgId, so the NSG has to finish first. Nothing links those two pairs to each other, so they run side by side. Here's what the compiler actually generates:
nsgDeployment starts immediately
storageDeployment starts immediately
networkDeployment waits for nsgDeployment
webappDeployment waits for storageDeployment
That's two independent chains running in parallel, and you didn't hand-order any of it.
Gotcha: In the video, the network and NSG modules deploy at the same time. They can, because in that version the NSG is never attached to a subnet. That also means it isn't protecting anything. Attach it, as this version does by passing
nsg.outputs.nsgIdinto the network module, and Bicep correctly moves the NSG ahead of the network, because a subnet can't point at a security group that doesn't exist yet. It's the dependency rule from the last section doing exactly its job.
Deploy it with the parameter file from the parameters post:
az deployment group create \
--resource-group rg-bicep-demo \
--template-file main.bicep \
--parameters dev.bicepparam
When it finishes, the resource group holds a virtual network with three subnets, each behind the NSG, plus a storage account and a web app wired to it. That's a real infrastructure stack, from one template.
What-If Before You Deploy
Now the command I've been wanting to show you. Here's the scenario: your infrastructure is deployed, and you need to change it. Maybe that's a new NSG rule, a different SKU, or another subnet. You update the Bicep file, and then what? Do you just deploy it and hope? How do you know what's actually going to change?
This is what what-if is for:
az deployment group what-if \
--resource-group rg-bicep-demo \
--template-file main.bicep \
--parameters dev.bicepparam
It works out what the deployment would do and changes nothing. The output is color-coded, and it opens with a legend of the symbols it uses:
Resource and property changes are indicated with these symbols:
- Delete
+ Create
~ Modify
Each resource is also given a change type. Create means it doesn't exist yet and will be created. Modify means it exists and some of its properties will change. NoChange means it's already exactly what the file describes.
Right after a deployment, everything should come back as no change. So let's make a change. Add an SSH rule to the NSG module:
{
name: 'AllowSSH'
properties: {
priority: 120
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '22'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
Run what-if again, and the NSG comes back as a Modify, with the new AllowSSH rule marked as an addition. Everything else is unchanged. That's the whole value of the command. You see exactly what Azure is going to do to your environment before it does it, so there are no surprises.
Warning: Don't read that
- Deleteline as a promise that what-if will warn you before a whole resource disappears. Per Microsoft's what-if documentation, a resource only shows up as Delete in complete mode, and the default is incremental. In incremental mode, a resource you've removed from the file is simply left alone in Azure, not deleted, which is also why what-if won't flag it. You'll still see-on individual properties inside a Modify, like a tag or a rule that's about to go, and that's where to look closely.
Gotcha: On this exact template, what-if will report the web app's
STORAGE_ENDPOINTsetting as changing on every single run, even when nothing has changed. That's because the value comes from another module's output, which compiles to areference()call, and Microsoft's docs note that what-if can't resolve those. It compares the live value against the unevaluated expression, and they never match. The change only really happens on deploy if the value genuinely differs. The output even opens by warning that results "may contain false positive predictions (noise)", and this is one of the ones worth recognizing.
When you're happy with what it shows, deploy for real. Or skip a step, because there's a switch that runs what-if first and then asks you before going ahead:
az deployment group create \
--resource-group rg-bicep-demo \
--template-file main.bicep \
--parameters dev.bicepparam \
--confirm-with-what-if
Run what-if once more after that, and everything comes back as no change. That's how you know the deployment did exactly what you asked and nothing else.
Pro tip: If a deployment does fail, the error Azure shows at the top level is usually a wrapper that tells you almost nothing. I walked through how to dig the real error out of an InvalidTemplateDeployment failure, and the same approach works here, one nested module deployment at a time.
The Bigger Lesson
We started this series with a single storage account in episode two. This post ends with a full multi-resource stack, and the three tools that got us here are what make Bicep practical for real projects, not just demos.
Loops mean you stop copy-pasting. Implicit dependencies mean things deploy in the right order without you having to micromanage the ordering. And what-if means you never deploy blind.
What connects all three is that you're describing the result and letting the tooling work out the steps. You don't write "create subnet one, then subnet two, then subnet three". You describe a list. You don't hand-order the modules. You reference what each one needs, and the order falls out of that. You don't guess what a change will do, because you ask first. Every one of these is the same move in a different form: say what you want and let the engine prove it.
There's one thing we've been doing this entire series that doesn't scale, though, which is me running a command from my laptop. That's fine on your own. On a team, where someone pushes a change and it needs to deploy without a person in the loop, you want a pipeline that runs what-if, deploys to dev automatically and holds production for an approval. That's where this series goes next. For now, the GitHub Actions CI/CD guide covers the pipeline side, and if you want to go deeper on validating templates before they ship, I cover PSRule and CI checks in testing Bicep before it breaks production.
If you want to practice everything in this post against a real Azure environment, the Azure Bicep Conditions, Loops and What-If Deployments lab covers loops, conditions and what-if hands-on.
You started this post knowing how to deploy one resource at a time. You're finishing it able to deploy a whole stack, in the right order, knowing exactly what it will do before it does it. The subnets were never the point. Being able to change real infrastructure without flinching is.
This article is based on episode five of my Bicep from Scratch series on YouTube (cloudwithsingh).




