Azure Infrastructure as Code

Bicep Modules, Loops & Conditions: Authoring Reusable, DRY Infrastructure Templates

You shipped your first main.bicep, it deployed cleanly, and then reality arrived: you need the same storage account in three regions, four environments, and with a private endpoint in prod but not in dev. The naive answer — copy the resource block, change a name, repeat — is how a 40-line file becomes 1,200 lines of near-identical blocks that drift apart the moment someone edits one and forgets the others. That copy-paste sprawl is the exact problem modules, loops and conditions exist to kill. They are the three constructs that move you from “Bicep that works once” to DRY (Don’t Repeat Yourself) infrastructure you author once and reuse everywhere.

This is a step-by-step implementation guide, and the hands-on lab is its spine. A module is just a Bicep file you call from another Bicep file, passing parameters in and reading outputs back — the unit of reuse, the thing you write once and deploy in dev, test and prod with different inputs. A loop (the for expression) stamps out N copies of a resource or module from an array or a count, so “five subnets” or “one app per region” is one block, not five. A condition (the if keyword) gates whether a resource deploys at all, so the same template gives you a lean dev footprint and a hardened prod one without forking the code. Master these three and a single, reviewed, version-controlled template becomes your entire estate.

By the end you will have refactored a flat template into a parameterised module, deployed a configurable number of resources from a loop, gated a resource behind a boolean, wired module outputs into downstream resources, and run the full author → validate → deploy → verify → teardown loop yourself — in the portal, with the az CLI, and as committed Bicep. This is an Intermediate guide: it assumes you have already shipped one Bicep file (if not, start with Deploy Your First Bicep File From Scratch), and it takes you to the point where one template, not one-file-per-thing, is how you build on Azure.

What problem this solves

A flat Bicep template scales linearly with your estate and then collapses under its own weight. Three storage accounts means three nearly identical resource blocks; add a network rule and you edit it in three places, miss one, and now account number two allows public blob access because someone was in a hurry. Multiply that across virtual networks, subnets, app services, and the four environments every real workload needs, and you are maintaining thousands of lines where a change is a search-and-replace prayer. This is configuration drift at the source-code level, and it is the root of most “it works in dev but not in prod” incidents — the environments diverged because nothing forced them to stay the same.

What breaks without modules, loops and conditions: every new region or environment is a copy-paste-and-edit job, so they silently differ; a reviewer cannot tell a real change from boilerplate noise in a 1,200-line diff; and there is no clean way to say “prod gets a private endpoint and zone redundancy, dev does not” except forking the file into main-dev.bicep and main-prod.bicep that then rot independently. Teams end up with per-environment templates that are 90% identical and 10% subtly, dangerously different.

Who hits this: anyone past their first IaC file. Solo developers who cannot keep three environments in sync; platform teams building landing zones where the same network, policy and identity scaffolding repeats across dozens of subscriptions; anyone studying for AZ-104 or AZ-204, both of which test modular Bicep, loops and conditional deployment. The fix is not more files — it is fewer, parameterised files: a module you write once, a loop that stamps it N times, and a condition that turns parts of it on or off per environment. One source of truth, deployed many ways.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be able to author a small main.bicep with parameters, variables, a resource and an output, transpile it with bicep build, preview with what-if, deploy it with az deployment group create, and tear it down. If any of that is shaky, work through Deploy Your First Bicep File From Scratch first — this guide assumes that loop is muscle memory and builds the reuse layer on top. You also need the Azure CLI (az) version 2.20.0 or later (which bundles the Bicep CLI) or Cloud Shell in the portal, and Contributor on a resource group or subscription to deploy into.

It helps to understand the resource hierarchy — that resources live in a resource group, which lives in a subscription — because modules can target different scopes (you can call a module that deploys into another resource group or even another subscription). If that is fuzzy, skim Azure Resource Hierarchy Explained. Two adjacent skills pair tightly with this one: storing secrets out of your templates with Azure Key Vault: Secrets, Keys and Certificates, and the cloud-agnostic alternative, Terraform on Azure: Remote State, Blob Backend and Locking, whose count, for_each and module blocks are the direct analogues of what you learn here.

Where this sits: modules, loops and conditions are the composition layer of Bicep authoring — one rung above writing a single file, one rung below full pipeline-driven landing zones. Here is how the moving parts relate before we go deeper.

Construct What it does Bicep keyword/syntax The repetition it removes
Module Calls another Bicep file as a unit module name 'path.bicep' = { … } Copy-pasting the same resource group of blocks
Loop (array) Deploys one item per array element [for x in array: { … }] N near-identical resource blocks
Loop (count) Deploys a fixed number of copies [for i in range(0, n): { … }] “Make me 3 of these”
Condition Deploys a resource only if true resource r '…' = if (cond) { … } Forking into per-environment files
Parameter file Supplies inputs per environment .bicepparam / --parameters Hard-coded, environment-specific values
Output Returns a value from a module/loop output x type = … Hand-copying IDs between deployments

Core concepts

Five mental models make every later step obvious.

A module is a Bicep file you call like a function. Any .bicep file can be a module: its parameters are its inputs, its outputs are its return values, and the resources it declares are its body. A parent template uses a module declaration — module stg 'modules/storage.bicep' = { name: 'stgDeploy', params: { … } } — to invoke it. Under the hood, each module becomes a nested deployment (Microsoft.Resources/deployments) in the transpiled ARM, which is why a module gets its own name (the deployment name) and shows up as a separate deployment in the portal. You write the module once and call it from dev, test and prod templates with different params. That is the entire unit of reuse.

A loop is an expression that produces a collection of resources. The for expression — [for item in array: { … }] — tells ARM “create one of these for each element”. You loop over an array (objects or strings), over an integer range with range(start, count), or over a dictionary’s entries with items(). The loop variable is in scope inside the body, and you can also capture the index with [for (item, i) in array: …]. The result is that “five subnets” or “one web app per region” is one block whose length is data-driven — change the array, change how many you get, no code edit.

A condition decides whether a resource exists. Append = if (<boolean>) to a resource or module — resource pe '…' = if (deployPrivateEndpoint) { … } — and ARM deploys it only when the expression is true. The same template then produces a lean dev footprint (no private endpoint, single instance, cheap SKU) and a hardened prod one (private endpoint on, zone-redundant, premium SKU) purely from a parameter, with no forked files. Conditions combine with loops: an if inside a loop body filters which iterations actually deploy.

Names must be unique, and ARM computes them, not you. Two resources of the same type in the same scope cannot share a name, so anything inside a loop must derive a unique name from the loop variable or index — name: 'snet-${subnet.name}' or name: 'stg${i}${uniqueString(resourceGroup().id)}'. The same applies to module deployment names: each iteration of a module loop needs a distinct name, or the second nested deployment overwrites the first. Forgetting this is the single most common loop bug, and it surfaces as a confusing “resource already exists” or a silently missing resource.

Everything resolves at deploy time, against ARM’s limits. Bicep is declarative: you describe the destination, ARM figures out the order and dependencies (it reads your references and builds the graph automatically — you almost never write dependsOn). But ARM has hard ceilings that loops make easy to hit: 800 resources per deployment, 256 parameters / 256 variables / 256 outputs per template, and 64 levels of nested-template (module) depth. A loop that stamps 900 resources fails the whole deployment; the fix is to split across module deployments, each of which is its own 800-resource budget.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side.

Concept One-line definition Where it lives Why it matters
Module A Bicep file invoked from another modules/*.bicep The unit of reuse; a nested deployment
Module deployment name The name of a module call Parent’s module block Must be unique per call / loop iteration
Parameter A typed input to a template/module param declaration How you vary behaviour per environment
Output A typed return value output declaration Passes IDs/values to downstream resources
for expression Produces a collection of resources [for … : { }] Removes N copy-pasted blocks
Loop index The 0-based position in the loop [for (x, i) in …] Unique names; “first one is special” logic
range(start, count) An integer array for count loops Built-in function “Make me N of these”
items() Turns an object into key/value array Built-in function Loop over a dictionary/map
Condition if (bool) on a resource/module After the = Deploys it only when true
@batchSize(n) Deploy loop iterations n-at-a-time Decorator on the loop Serialise/throttle dependent or rate-limited resources
.bicepparam A typed parameter file *.bicepparam Per-environment inputs, in git
Scope The RG/sub/MG a module targets scope: on the module Deploy cross-RG / cross-subscription

Modules: the unit of reuse

A module is the answer to “I keep writing the same handful of resources together.” You extract that cluster — a storage account plus its blob service plus a container, say — into its own file, expose the parts that vary as parameters, and call it wherever you need that cluster. The parent never repeats the body; it just calls the module with different inputs.

Anatomy of a module call

A module declaration has four parts, and three of them are mandatory.

Part Syntax Required? Notes
Symbolic name module stg … Yes The Bicep-local handle you reference outputs through
Path 'modules/storage.bicep' Yes Relative path, a registry ref (br/…), or a template spec
Deployment name name: 'stgDeploy' Yes The nested-deployment name in ARM; unique per call
Params params: { … } If the module has required params Must supply every non-defaulted parameter
Scope scope: resourceGroup('other-rg') No Target a different RG/sub/MG than the parent
Condition = if (cond) { … } No Deploy the whole module only when true

Here is a minimal reusable storage module. Note what is parameterised (everything that varies) and what is fixed (the safe defaults you want everywhere).

// modules/storage.bicep — a reusable storage account
@description('Globally-unique storage account name (3-24 lowercase alphanumeric).')
@minLength(3)
@maxLength(24)
param name string

@description('Azure region. Defaults to the parent resource group location.')
param location string = resourceGroup().location

@description('Redundancy SKU.')
@allowed([ 'Standard_LRS', 'Standard_ZRS', 'Standard_GRS' ])
param sku string = 'Standard_LRS'

@description('Block all public blob access (recommended true).')
param allowBlobPublicAccess bool = false

resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: name
  location: location
  sku: { name: sku }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: allowBlobPublicAccess
    supportsHttpsTrafficOnly: true
  }
}

output id string = sa.id
output name string = sa.name
output primaryBlobEndpoint string = sa.properties.primaryEndpoints.blob

And the parent that calls it twice with different inputs — no copy-pasted resource block:

// main.bicep
param location string = resourceGroup().location

module dataStg 'modules/storage.bicep' = {
  name: 'data-storage'
  params: {
    name: 'stdata${uniqueString(resourceGroup().id)}'
    location: location
    sku: 'Standard_ZRS'
  }
}

module logsStg 'modules/storage.bicep' = {
  name: 'logs-storage'
  params: {
    name: 'stlogs${uniqueString(resourceGroup().id)}'
    location: location
    // sku omitted → uses the module default Standard_LRS
  }
}

output dataAccountId string = dataStg.outputs.id

Passing values in and out

The module boundary is strict and that is the point: a parent can only influence a module through its parameters, and only read back through its outputs. You reference an output as <symbolicName>.outputs.<outputName>dataStg.outputs.id. This is also how you chain modules: feed one module’s output into the next module’s params, and ARM infers the dependency and orders them correctly. Never reach into a module’s internal resources from the parent — if the parent needs a value, the module must output it.

Direction How Example Gotcha
Parent → module params: { … } params: { name: 'stg1' } Must supply every required (non-defaulted) param
Module → parent output x type = … output id string = sa.id Outputs evaluate after the module deploys
Module → module output of A into params of B params: { stgId: a.outputs.id } Creates an implicit dependency; no dependsOn needed
Secret out of a module reference a Key Vault getSecret() kv.getSecret('dbPassword') Never output a raw secret — it lands in deployment history in plain text

That last row matters: deployment outputs are stored in ARM deployment history and are readable by anyone with read access to the resource group. Outputting a connection string or password leaks it. Pass secrets via the getSecret() function on an existing Key Vault reference, or via a secure parameter, never as an output.

Module sources: local, registry, or template spec

A module path can point at three things, and which you use depends on how widely the module is shared.

Source Path syntax When to use Versioning
Local file 'modules/storage.bicep' Within one repo/solution Git history of the file
Bicep registry (ACR) 'br/MyReg:storage:1.2.0' Shared across many repos/teams Immutable tags in the registry
Template spec 'ts/sub:rg:storageSpec:1.0' Versioned, governed catalog in Azure Versions stored in the spec resource
Public registry 'br/public:avm/res/storage/storage-account:0.9.0' Microsoft-maintained Azure Verified Modules Published semantic versions

For a single repo, local files are correct and simplest. The moment two repos need the same module, publish it to a private Bicep registry in Azure Container Registry (az bicep publish) so consumers pin a version instead of copying the file — that is how you stop the same module from drifting across teams.

Loops: deploying N from one block

A loop turns “I need several of these” into a single, data-driven block. The shape is [for <item> in <collection>: { <resource body> }], and it works on resources, modules, properties (like an array of subnets inside a VNet), variables and outputs.

The four loop forms

Form Syntax Loops over Use when
Array of objects [for s in subnets: { … s.name … }] A list of config objects Each item has several distinct properties
Array of strings [for name in names: { … name … }] A simple list Only the name/one value varies
Integer count [for i in range(0, n): { … i … }] range(start, count) “Make exactly N copies”
Dictionary [for kv in items(map): { … kv.key … kv.value … }] items(object) Config is a key→value map

An array-of-objects loop, the workhorse, stamping subnets into a VNet — the loop is on a property, not a top-level resource:

param subnets array = [
  { name: 'web',  prefix: '10.10.1.0/24' }
  { name: 'app',  prefix: '10.10.2.0/24' }
  { name: 'data', prefix: '10.10.3.0/24' }
]

resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = {
  name: 'vnet-app'
  location: resourceGroup().location
  properties: {
    addressSpace: { addressPrefixes: [ '10.10.0.0/16' ] }
    subnets: [ for s in subnets: {
      name: s.name
      properties: { addressPrefix: s.prefix }
    } ]
  }
}

A count loop with range(), creating N storage accounts where the index guarantees a unique name:

param accountCount int = 3

resource accounts 'Microsoft.Storage/storageAccounts@2023-05-01' = [ for i in range(0, accountCount): {
  name: 'stg${i}${uniqueString(resourceGroup().id)}'
  location: resourceGroup().location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: { minimumTlsVersion: 'TLS1_2', allowBlobPublicAccess: false }
} ]

Capturing the index

Add (item, i) to get the 0-based index alongside the value. The index is essential for unique naming and for “the first one is special” logic.

param regions array = [ 'eastus', 'westeurope', 'southeastasia' ]

module sites 'modules/webapp.bicep' = [ for (region, i) in regions: {
  name: 'site-${region}'           // unique module deployment name per iteration
  params: {
    name: 'app-${region}-${i}'
    location: region
    isPrimary: (i == 0)            // first region is primary
  }
} ]
Built-in Returns Example Note
range(start, count) Integer array [start … start+count-1] range(0, 3)[0,1,2] count is a length, not an end index
length(array) Element count range(0, length(items)) Loop a parallel array by index
items(object) Array of { key, value } items({ a: 1, b: 2 }) Sorted by key; loop a map
(item, index) Loop var + 0-based index [for (x, i) in arr: …] Index for naming and conditionals

Controlling concurrency with @batchSize

By default ARM deploys all loop iterations in parallel. That is fast and usually right, but sometimes wrong: when iterations depend on each other (sequential resources), when the target service rate-limits creates, or when you want a controlled rollout. The @batchSize(n) decorator deploys n iterations at a time, waiting for each batch before the next.

@batchSize(2)   // deploy 2 at a time, not all at once
resource accounts 'Microsoft.Storage/storageAccounts@2023-05-01' = [ for i in range(0, 6): {
  name: 'stg${i}${uniqueString(resourceGroup().id)}'
  location: resourceGroup().location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
} ]
@batchSize value Behaviour Use when
Omitted (default) All iterations in parallel Independent resources; fastest
@batchSize(1) Strictly sequential Iterations depend on the previous one
@batchSize(n) n-at-a-time, batch by batch Throttle to avoid rate limits; staged rollout

Conditions: deploy it only when you mean to

A condition appends = if (<boolean>) to a resource or module so it deploys only when the expression is true. This is what lets one template serve every environment: dev gets the minimum, prod gets the works, from a single parameter.

@allowed([ 'dev', 'test', 'prod' ])
param environment string = 'dev'

var isProd = environment == 'prod'

// Private endpoint only in prod
resource pe 'Microsoft.Network/privateEndpoints@2023-11-01' = if (isProd) {
  name: 'pe-data'
  location: resourceGroup().location
  properties: { /* … */ }
}

// Zone-redundant SKU in prod, cheaper LRS elsewhere
module stg 'modules/storage.bicep' = {
  name: 'data-storage'
  params: {
    name: 'stdata${uniqueString(resourceGroup().id)}'
    sku: isProd ? 'Standard_ZRS' : 'Standard_LRS'
  }
}

The reference trap

The one thing that bites everyone: if resource B references conditional resource A, and A did not deploy, B’s reference is null at deploy time and the deployment fails. Bicep has a clean idiom for this — guard the reference with the same condition using a ternary, so B reads A’s property only when A exists.

// Safe: only read pe.id when the private endpoint actually deployed
output privateEndpointId string = isProd ? pe.id : ''
Condition pattern Syntax Result
Resource only if true resource r '…' = if (cond) { } Resource skipped entirely when false
Module only if true module m '…' = if (cond) { } Whole nested deployment skipped when false
Pick a value cond ? 'A' : 'B' Ternary; choose a SKU/size/flag inline
Safe reference to conditional cond ? r.id : '' Avoids null-reference failure when r is skipped
Filter inside a loop [for x in arr: if (x.enabled) { }] Only deploys iterations where the predicate holds

Combining loops and conditions

The two compose directly: put if (...) after the for to deploy only the matching iterations. This deploys a subnet for every entry that is flagged enabled, skipping the rest, while still keeping one declarative block.

param subnets array = [
  { name: 'web',  prefix: '10.10.1.0/24', enabled: true }
  { name: 'app',  prefix: '10.10.2.0/24', enabled: true }
  { name: 'bastion', prefix: '10.10.9.0/27', enabled: false }
]

// NOTE: conditional loops apply at the resource level, not on a sub-property array.
module subnetMods 'modules/subnet.bicep' = [ for s in subnets: if (s.enabled) {
  name: 'subnet-${s.name}'
  params: { name: s.name, prefix: s.prefix }
} ]

Architecture at a glance

The diagram below is the picture this article builds toward: one parameterised template, fanned out by loops and gated by conditions, deploying the same code into three environments with different shapes. Read it left to right. On the far left, the source is a single repo holding one main.bicep, a reusable modules/storage.bicep, and a .bicepparam file per environment — that is your one source of truth. It flows into Azure Resource Manager, the control plane that receives the deployment, evaluates every for loop into a concrete count of resources and every if condition into deploy-or-skip, and orchestrates the nested module deployments in dependency order.

From ARM the deployment fans out into three environment zones. Dev receives the lean shape: a single storage account, no private endpoint (the if (isProd) evaluated false), cheapest LRS SKU. Test receives a middle shape. Prod receives the full shape: a loop has stamped N zone-redundant storage accounts, the private-endpoint condition is true so a Microsoft.Network/privateEndpoints resource exists, and secrets resolve from Key Vault via getSecret() rather than being passed in plain text. The numbered badges mark the four places this goes wrong in practice — a duplicate module deployment name, a null reference to a skipped conditional resource, the 800-resource ceiling, and a leaked secret in deployment outputs — each mapped in the legend to how you confirm and fix it.

Architecture diagram: a single Bicep repo (main.bicep, reusable storage module, per-environment bicepparam files) flowing into Azure Resource Manager, which evaluates for-loops and if-conditions and fans the same code out into dev, test and prod resource groups with different shapes — lean dev with one LRS storage account, full prod with looped zone-redundant accounts plus a conditional private endpoint and Key Vault secret references — with numbered failure badges on the duplicate deployment-name, null-conditional-reference, 800-resource-limit and leaked-output hops.

Real-world scenario

Northwind Retail runs an e-commerce platform on Azure and had, until last quarter, three environments defined by three hand-maintained Bicep files: infra-dev.bicep, infra-test.bicep, infra-prod.bicep, each around 600 lines, each 90% identical. The platform engineer who wrote them had left. A regression went out when a junior engineer added a new storage account with public blob access enabled to dev for a quick test, copied the block into prod during a release “to keep them consistent”, and shipped it — prod blob containers were briefly internet-readable until a Defender for Storage alert caught it. The root cause was not the engineer; it was three files that had to be edited in parallel and inevitably diverged.

The fix was a two-week refactor to one parameterised template. They extracted four modules — storage.bicep, network.bicep, appservice.bicep, keyvault.bicep — each exposing only the parameters that legitimately vary per environment and hard-coding the safe defaults (allowBlobPublicAccess: false, minimumTlsVersion: 'TLS1_2') so a junior engineer cannot re-introduce the public-access bug from a parameter file. The storage accounts became a single loop over an array whose length differs by environment (dev: 1, prod: 4). The private endpoint, zone redundancy, and a second region became conditions gated on environment == 'prod'. Environment-specific values moved into dev.bicepparam, test.bicepparam and prod.bicepparam.

The numbers tell the story. Total Bicep dropped from roughly 1,800 lines across three files to about 520 lines across one main.bicep plus four modules — a 71% reduction. A new fourth environment (a staging slot for a Black Friday rehearsal) was created by writing one new 18-line .bicepparam file, not a new 600-line template, and deployed in an afternoon. Crucially, drift became impossible-by-construction: the only differences between environments are now the visible, reviewed values in the parameter files, and the security defaults live in one place. The same change that previously meant “edit three files and pray” is now one edit to one module, reviewed in one pull-request diff.

The one pain point worth flagging: the first prod deployment after the refactor failed with The deployment exceeded the limit of 800 resources because a loop over regions multiplied by a loop over subnets had grown past the ceiling. They split the network into its own module deployment (each module is a fresh 800-resource budget), and it deployed cleanly. That single failure taught the team the most important loop lesson — covered in the troubleshooting section below.

Advantages and disadvantages

Modules, loops and conditions are a near-unambiguous win for any estate past one file, but they are not free — they trade copy-paste sprawl for a layer of indirection you have to understand.

Advantages Disadvantages
One source of truth; edit a module once, every consumer gets the fix Indirection: a value’s origin can be three files away (param file → main → module)
Environments differ only by visible parameter-file values Over-modularising tiny resources adds ceremony for no reuse benefit
Loops make “N of these” data-driven — change the array, not the code Loops over loops multiply resource counts fast → 800-resource ceiling
Conditions kill per-environment forks; no main-prod.bicep to rot Conditional references need careful null-guarding or they fail at deploy
Reviewable: a real change stands out from boilerplate in the diff Each module is a nested deployment → more deployment objects to read in errors
Modules are versionable/shareable via a registry Cross-team registry modules add publish/pin workflow overhead
Idempotent and deterministic across dev/test/prod A module-output typo surfaces as a late, sometimes cryptic, error

When each matters: the advantages dominate the moment you have more than one of anything — more than one environment, region, or instance. The disadvantages bite mainly when you over-apply the pattern (a module wrapping a single resource that is never reused is pure overhead) or when you let loops multiply unchecked. The right altitude is to modularise clusters that deploy together and recur, loop over genuine collections, and condition on real environment differences — not to modularise reflexively. For a single resource group of unique, one-off resources, a flat file is still correct; reach for these constructs when repetition appears, not before.

Hands-on lab

This is the centrepiece. You will start from a flat template, refactor it into a module, add a loop and a condition, ship it to two “environments” via parameter files, and tear it all down. Everything is free-tier-friendly (storage accounts cost nothing at rest with no data) and runs in Cloud Shell or any terminal with az. Do it once end to end and the constructs stop being abstract.

Step 0 — Prerequisites and environment check

Confirm your tooling before you write a line. You need az 2.20.0+ (which bundles Bicep) and Contributor on a subscription.

az version                       # az >= 2.20.0
az bicep version                 # Bicep CLI present; if not: az bicep install
az account show -o table         # confirm the right subscription is active

Expected output: az version prints a JSON blob with "azure-cli": "2.x"; az bicep version prints a version like Bicep CLI version 0.3x.x; az account show shows your subscription name and State: Enabled. If az bicep version errors, run az bicep install.

Create a working folder and a resource group for the lab.

mkdir -p bicep-lab/modules && cd bicep-lab
az group create --name rg-bicep-lab --location eastus -o table

Expected output: a table row with Name: rg-bicep-lab and ProvisioningState: Succeeded.

Step 0 check Command Pass looks like
CLI version az version azure-cli ≥ 2.20.0
Bicep present az bicep version a 0.3x version string
Right subscription az account show -o table the intended sub, Enabled
RG created az group create … ProvisioningState: Succeeded

Step 1 — Start from a flat template (the “before”)

First, the anti-pattern you are about to fix. Create flat.bicep with two near-identical storage blocks — this is what copy-paste produces.

// flat.bicep — the BEFORE: two copy-pasted blocks
param location string = resourceGroup().location

resource dataSa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: 'stdata${uniqueString(resourceGroup().id)}'
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: { minimumTlsVersion: 'TLS1_2', allowBlobPublicAccess: false, supportsHttpsTrafficOnly: true }
}

resource logsSa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: 'stlogs${uniqueString(resourceGroup().id)}'
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: { minimumTlsVersion: 'TLS1_2', allowBlobPublicAccess: false, supportsHttpsTrafficOnly: true }
}

Preview it with what-if — do not deploy it; you are only confirming it is valid and seeing the duplication ARM would create.

az deployment group what-if --resource-group rg-bicep-lab --template-file flat.bicep

Expected output: two + Microsoft.Storage/storageAccounts create lines. Note how the two blocks are identical except the name prefix — that is exactly the repetition the next steps remove.

Step 2 — Extract a reusable module

Create the module file at modules/storage.bicep (the same one from the Modules section — parameterise everything that varies, hard-code the safe defaults).

// modules/storage.bicep
@minLength(3)
@maxLength(24)
param name string
param location string = resourceGroup().location
@allowed([ 'Standard_LRS', 'Standard_ZRS', 'Standard_GRS' ])
param sku string = 'Standard_LRS'
param allowBlobPublicAccess bool = false

resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: name
  location: location
  sku: { name: sku }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: allowBlobPublicAccess
    supportsHttpsTrafficOnly: true
  }
}

output id string = sa.id
output name string = sa.name

Validate the module compiles on its own (a module is still a valid Bicep file):

az bicep build --file modules/storage.bicep --stdout > /dev/null && echo "module OK"

Expected output: module OK with no errors. If you see a BCP warning about an unused parameter, that is fine — you will use them from the parent next.

Step 3 — Call the module from main.bicep

Replace the flat file with a parent that calls the module twice. Create main.bicep.

// main.bicep
param location string = resourceGroup().location

module dataStg 'modules/storage.bicep' = {
  name: 'data-storage'
  params: { name: 'stdata${uniqueString(resourceGroup().id)}', location: location, sku: 'Standard_ZRS' }
}

module logsStg 'modules/storage.bicep' = {
  name: 'logs-storage'
  params: { name: 'stlogs${uniqueString(resourceGroup().id)}', location: location }
}

output dataAccountId string = dataStg.outputs.id

Preview, then deploy for real.

az deployment group what-if --resource-group rg-bicep-lab --template-file main.bicep
az deployment group create  --resource-group rg-bicep-lab --template-file main.bicep -o table

Expected output: what-if shows two storage-account creates (now via two nested module deployments); create ends with ProvisioningState: Succeeded. Verify the result and note that each module is its own deployment:

az deployment group list --resource-group rg-bicep-lab \
  --query "[].{name:name, state:properties.provisioningState}" -o table
az storage account list --resource-group rg-bicep-lab --query "[].name" -o tsv

Expected output: the deployment list shows the parent deployment plus data-storage and logs-storage as separate nested deployments; the storage list shows two account names. You just proved a module is a nested deployment.

Step 4 — Replace the two calls with a loop

Two calls is still two blocks. Drive it from an array instead. Edit main.bicep to loop a single module over a config array.

// main.bicep — loop version
param location string = resourceGroup().location
param stores array = [
  { suffix: 'data', sku: 'Standard_ZRS' }
  { suffix: 'logs', sku: 'Standard_LRS' }
  { suffix: 'temp', sku: 'Standard_LRS' }
]

module stg 'modules/storage.bicep' = [ for s in stores: {
  name: 'storage-${s.suffix}'                       // unique module deployment name per item
  params: {
    name: 'st${s.suffix}${uniqueString(resourceGroup().id)}'
    location: location
    sku: s.sku
  }
} ]

// Collection output: every account id the loop created
output accountIds array = [ for (s, i) in stores: stg[i].outputs.id ]

Preview and deploy.

az deployment group what-if --resource-group rg-bicep-lab --template-file main.bicep
az deployment group create  --resource-group rg-bicep-lab --template-file main.bicep -o table

Expected output: what-if now shows three storage accounts — one per array element — and three nested module deployments (storage-data, storage-logs, storage-temp). Adding a fourth store is now a one-line array edit, not a new block. Confirm three accounts exist:

az storage account list --resource-group rg-bicep-lab --query "length(@)"

Expected output: 3. You have referenced loop-created modules by index (stg[i].outputs.id) in the collection output — the correct way to read from a loop.

Step 5 — Add a condition for the prod-only resource

Now make the template environment-aware. Add an environment parameter and gate an extra, prod-only storage account behind a condition, and switch SKUs with a ternary.

// add to main.bicep
@allowed([ 'dev', 'test', 'prod' ])
param environment string = 'dev'
var isProd = environment == 'prod'

// A prod-only audit account — deployed ONLY when environment == 'prod'
module auditStg 'modules/storage.bicep' = if (isProd) {
  name: 'storage-audit'
  params: {
    name: 'staudit${uniqueString(resourceGroup().id)}'
    location: location
    sku: 'Standard_GRS'                 // geo-redundant for audit
  }
}

// Safe reference: only read the conditional module's output when it exists
output auditAccountId string = isProd ? auditStg.outputs.id : 'not-deployed-in-${environment}'

Preview with the default (dev) and then overriding to prod to see the condition flip.

# dev: condition false → no audit account
az deployment group what-if --resource-group rg-bicep-lab --template-file main.bicep

# prod: condition true → audit account appears
az deployment group what-if --resource-group rg-bicep-lab --template-file main.bicep \
  --parameters environment=prod

Expected output: the first what-if shows three accounts (no audit); the second shows four (the audit account added). The same template, two shapes, one parameter. Notice the safe isProd ? … : … output guard — without it, reading auditStg.outputs.id in dev would fail with a null reference.

Step 6 — Parameterise per environment with .bicepparam files

Hard-coded --parameters environment=prod does not scale. Create typed parameter files — one per environment — so each environment’s inputs live in git, reviewed.

Create dev.bicepparam:

using './main.bicep'
param environment = 'dev'
param stores = [
  { suffix: 'data', sku: 'Standard_LRS' }
]

Create prod.bicepparam:

using './main.bicep'
param environment = 'prod'
param stores = [
  { suffix: 'data', sku: 'Standard_ZRS' }
  { suffix: 'logs', sku: 'Standard_ZRS' }
  { suffix: 'temp', sku: 'Standard_LRS' }
]

Deploy each with its own param file — same template, different inputs.

az deployment group create --resource-group rg-bicep-lab \
  --template-file main.bicep --parameters dev.bicepparam -o table

Expected output: Succeeded, and the dev param file produces just one storage account (its stores array has one element and environment=dev skips the audit account). Confirm the environment-specific shape:

az storage account list --resource-group rg-bicep-lab --query "length(@)"
Lab step What you build Verify command Expected
1 Flat two-block template what-if on flat.bicep 2 create lines
2 Reusable module az bicep build … modules/storage.bicep compiles, “module OK”
3 Parent calls module ×2 az deployment group list parent + 2 nested deployments
4 Module driven by a loop az storage account list --query length(@) 3
5 Prod-only conditional module what-if --parameters environment=prod 4th account appears
6 Per-env .bicepparam deploy with dev.bicepparam 1 account

Step 7 — Do it in the portal (Custom deployment)

You will rarely deploy modules from the portal in practice, but knowing the path helps when a teammate hands you a template. Modules referenced by local path cannot be uploaded as a single file to the portal — the portal needs the transpiled, self-contained ARM JSON, which inlines the modules as nested templates. Build it first:

az bicep build --file main.bicep --outfile main.json

Then in the portal:

  1. Search for and open Deploy a custom template.
  2. Click Build your own template in the editor.
  3. Click Load file and select the generated main.json (the modules are now inlined as Microsoft.Resources/deployments nodes — you can see them in the JSON).
  4. Click Save. The portal renders the parameters (environment, stores, location).
  5. Select Resource group = rg-bicep-lab, set environment = dev, leave the rest, click Review + create, then Create.
  6. When it completes, open the deployment → Deployment details. You will see the nested module deployments listed exactly as the CLI showed them.

Expected output: the deployment succeeds and the Deployment details pane lists the nested deployments (one per module), confirming that a portal “custom template deploy” of a Bicep file is really a deploy of its transpiled ARM. The portal cannot consume .bicep directly — always az bicep build first, or deploy via CLI.

Portal field Value for this lab Notes
Template source main.json (transpiled) Portal can’t load .bicep modules by path
Resource group rg-bicep-lab Same RG as the CLI steps
environment dev Drives the condition
stores (default array) Edit inline to add/remove accounts
Region inherited from RG location defaults to RG location

Step 8 — Validate the whole result

Confirm the deployed estate matches intent before you tear it down.

# All storage accounts and their SKU/TLS/public-access posture
az storage account list --resource-group rg-bicep-lab \
  --query "[].{name:name, sku:sku.name, tls:minimumTlsVersion, public:allowBlobPublicAccess}" -o table

# All deployments (parent + nested modules) and their state
az deployment group list --resource-group rg-bicep-lab \
  --query "[].{name:name, state:properties.provisioningState, ts:properties.timestamp}" -o table

Expected output: every account shows minimumTlsVersion: TLS1_2 and allowBlobPublicAccess: False — the hard-coded module defaults held across every environment, which is the whole point. The deployment list shows the parent plus one nested deployment per module call.

Step 9 — Teardown

Delete the resource group to remove everything at once — accounts, deployments, the lot — so the lab costs nothing.

az group delete --name rg-bicep-lab --yes --no-wait

Expected output: the command returns immediately (--no-wait); deletion completes in the background. Confirm it is gone:

az group exists --name rg-bicep-lab        # eventually prints: false

Expected output: false once deletion finishes. Storage accounts cost nothing at rest with no data, but deleting the group is the clean reset and removes the deployment history too.

Common mistakes & troubleshooting

These are the failures every Bicep author hits with modules, loops and conditions — symptom, root cause, how to confirm, and the fix. Most surface as a BCP error at build time (fast, before deploy) or as an ARM error at deploy time (slower).

# Symptom Root cause Confirm with Fix
1 The deployment exceeded the limit of 800 resources A loop (or nested loops) created >800 resources in one deployment Count: regions × subnets …; check the failed deployment’s resource count Move the loop into its own module (each module = fresh 800 budget)
2 BCP120: This expression is being used in an assignment to the "name" property … which requires a value that can be calculated at the start A loop-created resource’s name depends on another resource’s runtime property Build error points at the name: line Derive the name from the loop item/index (name: 'stg${i}'), not a runtime reference
3 Second resource in a module loop overwrites the first; only one ends up deployed All loop iterations share the same module name: (deployment name) az deployment group list shows fewer nested deployments than expected Make the module name unique per iteration: name: 'storage-${s.suffix}'
4 BCP034: The enclosing array expected an item of type … but the provided item was of type … Loop body returns the wrong shape (e.g. object where a string is expected) Build error names both types Match the loop output to the property’s expected type
5 Deployment fails reading .outputs.id of a conditional resource The conditional resource was skipped (if false) but something referenced it Error mentions a null/The language expression property … doesn't exist Guard the reference: cond ? r.outputs.id : ''
6 BCP180 / cannot reference a resource inside a loop without an index Referenced a looped resource by symbolic name without [i] Build error on the reference line Index it: stg[0].outputs.id or loop the output too
7 A secret/connection string appears in deployment history in plain text The value was returned via output az deployment group show … --query properties.outputs shows it Never output secrets; use kv.getSecret() or a @secure() param
8 BCP037: The property "X" is not allowed on objects of type … after refactor Param object shape in the array doesn’t match what the module/resource expects Build error names the unexpected property Align the array object keys with the module’s parameters
9 Module deploys to the wrong resource group/subscription Missing or wrong scope: on the module az deployment group list in the expected RG is empty Set scope: resourceGroup('target-rg') (and ensure RG exists)
10 @batchSize ignored / still all-parallel Decorator placed on the resource, not the loop, or value ≤ 0 Re-read the build; iterations still concurrent in the timeline Put @batchSize(n) directly above the for line; n ≥ 1
11 The template parameter 'X' is not found when using a .bicepparam Param file declares a param the template doesn’t have (or using points at the wrong file) az bicep build-params --file env.bicepparam errors Fix the using './main.bicep' path; match param names exactly
12 Nested loop fails to compile Bicep does not support resource loops nested inside another resource loop body Build error about nested iteration Flatten the data into one array, or split into modules and loop the module

The single most common real-world failure is #1, the 800-resource ceiling, and the fix is the most important architectural lesson here: a module call is a separate deployment with its own 800-resource budget. When a loop grows past the limit, you do not reduce what you deploy — you push the loop body into a module so each iteration becomes its own nested deployment. The reference limits worth memorising:

ARM / Bicep limit Value What hits it Workaround
Resources per deployment 800 Big or nested loops Split into module deployments
Parameters per template 256 Over-parameterised templates Group into object params
Variables per template 256 Many computed values Consolidate; use objects
Outputs per template 256 Outputting per-loop-item values Output one array, not N scalars
Nested template depth 64 Modules calling modules deeply Flatten the module tree
Template request body size ~4 MB Huge transpiled JSON from large loops Modularise; use linked/registry modules
Resource group name length 90 Generated RG names in loops Keep generated names short
Storage account name 3–24, lowercase alnum uniqueString + long prefix Trim the prefix; uniqueString is 13 chars

Best practices

Security notes

Modules are a security control, not just a DRY one. By hard-coding the safe posture inside a module and exposing only benign parameters, you make insecure configurations unrepresentable from a parameter file — the Northwind public-blob regression simply cannot recur once allowBlobPublicAccess: false lives in the module body rather than a copy-pasted block.

Concern Risk with naive Bicep The modular/loop/condition fix
Secrets in templates Hard-coded passwords; secrets in output history @secure() params; kv.getSecret(); never output a secret
Insecure defaults drift One copied block re-enables public access Bake allowBlobPublicAccess:false, TLS1_2, httpsOnly:true into the module
Over-broad deployment identity Pipeline SPN with Owner everywhere Scope module deployments to the exact RG; least-privilege the deploy identity
Environment blast radius A prod-only resource accidentally in dev Gate it on if (isProd); one parameter, not a manual block
Cross-scope deploys A module silently lands in the wrong subscription Explicit scope:; review the target in what-if
Public network exposure in prod Private endpoint forgotten in one of three files Conditional if (isProd) private endpoint in the one template

Two specifics. First, the deployment identity: a module that targets another resource group or subscription needs the deploying principal (your user, or a pipeline’s managed identity / service principal) to have rights at that scope — grant the minimum (Contributor on the target RG, not Owner on the subscription). For identity choices, see Managed Identity: System vs User-Assigned Patterns. Second, enforce the secure defaults at the platform level too: an Azure Policy with a deny effect on public blob access is the backstop that catches anything your modules miss — defence in depth, template plus policy.

Cost & sizing

Modules, loops and conditions have no direct cost — they are authoring constructs; ARM deployments themselves are free. The bill comes entirely from the resources you deploy, and that is exactly where these constructs help: a loop or a condition is the cheapest possible cost lever because it changes how many and which resources exist per environment with a one-line edit.

Cost driver Where it shows up How loops/conditions help Rough figure
Resource count Per-resource hourly/usage charges A count/array loop right-sizes count per env (dev:1, prod:4) Storage accounts: ~$0 at rest, no data
SKU/tier per env Premium vs standard Ternary picks SKU: isProd ? 'Premium' : 'Standard' LRS vs ZRS vs GRS: GRS ~2× LRS storage rate
Prod-only resources Private endpoint, extra region if (isProd) keeps them out of dev/test Private endpoint ≈ ₹600–800/mo (~$7–10) each
Zone redundancy ZRS/zone-redundant SKUs Condition it on prod only ZRS storage ~25% over LRS for the same data
Deployment itself ARM control plane Free regardless of size $0 — deployments are not billed
Cross-region copies A for region in regions loop The array length is the region count and cost Each region multiplies the per-region spend

Right-sizing rule of thumb: let the parameter file carry the cost knobs. Dev’s .bicepparam deploys the minimum count and cheapest SKUs and skips prod-only resources via the condition; prod’s deploys the full count and hardened SKUs. Because the difference is data, not code, you can tune dev down to near-zero (one LRS storage account, no private endpoint) without touching the template — and a finance review reads the parameter files, not 600 lines of Bicep, to see exactly what each environment costs. For storage SKU economics specifically, Azure Storage Redundancy: LRS, ZRS, GRS, RA-GRS Explained breaks down the per-GB rates the ternary chooses between.

Interview & exam questions

These map to AZ-104 (Azure Administrator — IaC/Bicep deployment objectives) and AZ-204 (Developer — provisioning with Bicep). Each is a question and a model answer.

1. What is a Bicep module and what does it become in ARM? A module is a Bicep file invoked from another via a module declaration; its parameters are inputs and outputs are returns. At transpile time each module becomes a nested deployment (Microsoft.Resources/deployments), which is why it needs its own deployment name and appears as a separate deployment in the portal.

2. How do you deploy a variable number of identical resources? Use a for loop — either over an array ([for x in items: { … }]) or an integer count ([for i in range(0, n): { … }]). The count is data-driven, so changing the array or n changes how many deploy with no code change. Each must derive a unique name from the loop item or index.

3. How do you deploy a resource only in production? Append = if (<boolean>) to the resource or module, gating it on a parameter like environment == 'prod'. The same template then produces different shapes per environment from a single parameter, with no forked files.

4. What is the resource limit per ARM deployment, and how do you exceed it? 800 resources per deployment. You do not reduce resources — you push the loop into a module, because each module is a separate nested deployment with its own 800-resource budget.

5. How do you capture the index in a loop and why would you? Use [for (item, i) in array: …]. The 0-based index gives unique names (stg${i}) and enables “first one is special” logic (isPrimary: i == 0).

6. How do you reference a resource created inside a loop? Index it by position — myLoop[0].outputs.id — or loop the output too ([for (x, i) in arr: myLoop[i].id]). Referencing a looped resource by bare symbolic name without an index is a build error.

7. Why must you never return a secret as a module output? Outputs are stored in ARM deployment history and readable by anyone with read access to the resource group, so a connection string or password in an output is leaked. Pass secrets via @secure() parameters or keyVault.getSecret() instead.

8. What does @batchSize(n) do? It overrides the default all-parallel loop behaviour to deploy n iterations at a time, in batches. @batchSize(1) is strictly sequential — used when iterations depend on each other or the target service rate-limits creates.

9. What is the difference between a parameter and a variable in a module? A parameter (param) is an external input supplied by the caller (or a .bicepparam file) and can be overridden per deployment; a variable (var) is computed inside the template and cannot be supplied externally. You parameterise what varies per environment; you use variables for derived values.

10. How do you ship the same template to dev, test and prod? Keep one main.bicep and one .bicepparam file per environment, each with a using './main.bicep' line and the environment-specific parameter values. Deploy with --parameters dev.bicepparam. The only differences between environments are the reviewed values in those files.

11. What goes wrong if you reference a conditional resource that was skipped? The reference is null and the deployment fails (commonly “the language expression property doesn’t exist”). Guard it with the same condition using a ternary: isProd ? pe.id : ''.

12. When should you publish a module to a registry instead of using a local file? When more than one repository or team needs the same module. Publishing to a Bicep registry in ACR (az bicep publish) lets consumers pin an immutable version (br/Reg:storage:1.2.0) instead of copying the file, which prevents the module from drifting across teams.

Quick check

  1. You need three storage accounts that differ only by name. What single construct removes the copy-paste, and how do you guarantee unique names?
  2. A reviewer asks why your prod template deploys a private endpoint but dev’s does not, with no separate prod file. What construct makes that possible, and what keyword?
  3. Your loop creates 900 resources and the deployment fails. What is the limit, and what is the fix (not “deploy fewer resources”)?
  4. You returned a database connection string as a module output and security flagged it. Why is that wrong, and what should you do instead?
  5. A module loop deploys only one resource even though your array has three items. What did you forget?

Answers

  1. A for loop over an array (or range() count). Guarantee unique names by deriving each name from the loop index or item plus uniqueString(resourceGroup().id) — e.g. name: 'stg${i}${uniqueString(resourceGroup().id)}'.
  2. A condition — append = if (environment == 'prod') to the private-endpoint resource/module. The same template produces both shapes from one parameter.
  3. The limit is 800 resources per deployment. The fix is to move the loop body into a module, because each module is a separate nested deployment with its own 800-resource budget.
  4. Outputs are stored in deployment history and readable by anyone with read access to the resource group, so the secret is leaked. Use a @secure() parameter or keyVault.getSecret() and never output the secret.
  5. You gave every iteration the same module name (deployment name), so each nested deployment overwrote the previous. Make name unique per iteration — name: 'storage-${s.suffix}'.

Glossary

Next steps

AzureBicepInfrastructure as CodeModulesLoopsConditionsARMaz CLI
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading