Business Challenge
Post #25 ended on the observation that Bicep compiles to ARM JSON, and that knowing the compiled form is what makes Bicep's errors legible. This post takes that seriously, because two features carry most of the weight in real Bicep code and both are best understood by what they become.
The documentation states it plainly: Bicep modules are converted into a single ARM template with nested templates. A loop compiles to the ARM copy element. Neither is a new capability — both are nicer syntax over constructs that already existed, with the constraints those constructs already had.
Three consequences catch people, and each is a direct inheritance:
- A module has a deployment name, whether or not you give it one. It becomes a nested deployment resource, and deployment names are not free-form labels — two concurrent deployments using the same static module name against the same scope can produce the wrong output.
- A loop cannot exceed 800 iterations, which is exactly the number of resources a template may contain. The loop limit is not a separate rule; it is the template limit seen from inside the loop.
- A loop's count must be knowable before the deployment starts. Loops only work with values determinable at the start of deployment — the same staged evaluation from post #25, showing up as a restriction on iteration.
Bicep's error messages, its limits and its surprising behaviours are all easier to reason about once you can picture the JSON. That does not mean writing JSON; it means knowing that a module is a deployment resource with a name, and a loop is a copy block with a count. Two facts, and most of the confusion goes away.
Architecture
Modules: a Bicep file another Bicep file deploys
The definition is deliberately unremarkable. A module is a Bicep file that another Bicep file deploys — and it can also be an ARM JSON template, which matters for estates migrating gradually. Declared with a symbolic name, a path, and parameters:
The symbolic name is an identifier, not a string: it may contain a-z, A-Z, 0-9 and underscore, cannot start with a number, and cannot collide with a parameter, variable or resource name. It is how the rest of the file refers to the module — most importantly to read its outputs, via moduleName.outputs.something.
Modules are the only construct that crosses a scope boundary, which post #24 covered from the deployment end. Set scope to a scope object — either the symbolic name of a resource group the same file creates, or one of the four scope functions resourceGroup(), subscription(), managementGroup(), tenant(). When scope is not provided, the module deploys at the parent's target scope.
Like resources, modules deploy in parallel unless they depend on something, and the documentation is clear that dependencies are usually determined implicitly — passing one module's output into another's parameters is the dependency. Explicit dependsOn on a module is nearly always a sign that the data flow could have expressed it instead.
The name property is optional and becomes the nested deployment's name; omitted, a GUID is generated. That sounds like a cosmetic choice and is not. If you deploy a module with a static name concurrently to the same scope, one deployment can interfere with the output from the other — two pipelines running the same module named examplemodule against one resource group, and one of them reads the wrong output. Microsoft's guidance is to leave name out entirely, and the no-module-name linter rule now flags any module that still sets one. If a readable name is genuinely wanted, derive it: '${deployment().name}-storageDeploy' is unique as long as the parent deployment name is.
Where modules come from
Four sources, and the choice is mostly about how far the module needs to travel:
| Source | Syntax | When |
|---|---|---|
| Local file | '../storage.bicep' | Same repository. Forward slashes only — the Windows backslash is not supported, so a path that works on one machine and not another is usually this. |
| Public registry | 'br/public:avm/res/…:0.18.0' | Azure Verified Modules — prebuilt, pretested and owned by Microsoft, versioned by tag. |
| Private registry | 'br:reg.azurecr.io/path:v1' | Organisation-wide sharing, with the version pinned in the reference. |
| Template spec | 'ts:sub/rg/spec:2.0' | When non-Bicep callers also need it — template specs deploy from the API, CLI, PowerShell and portal. |
One constraint worth knowing before designing a module distribution strategy: Bicep validates registry hostnames against a built-in allowlist — the azurecr domains, mcr.microsoft.com, mcr.azure.cn and ghcr.io. Since Bicep CLI v0.43.1 a custom domain in front of a registry is blocked outright with BCP446, and the remedy is to revert references to the native hostname and re-run bicep restore. An organisation that fronted its registry with a vanity domain has a migration rather than a preference.
Loops: five forms, one compilation target
Loops define multiple copies of a resource, module, variable, property or output — note that last two, since looping a property is how a virtual network gets its subnets without repeating syntax. The five forms map to five questions:
| Form | The question it answers |
|---|---|
[for i in range(0, n): ...] | I want this many instances. |
[for item in collection: ...] | One instance per element of an array. |
[for item in items(object): ...] | One per entry of a dictionary, converted to an array by items(). |
[for (item, i) in collection: ...] | One per element, but I need the index as well. |
[for item in collection: if(cond) ...] | Many instances, each deployed only when a condition holds. |
The rule that governs all five: each instance must have a unique value for the name property. So the index or an array value has to reach the name, which is why nearly every loop example interpolates i or an element into it.
Four limits, each explaining an error people meet:
- Values must be determinable at the start of deployment. Nothing computed at runtime can decide how many times a loop runs — the staged evaluation of post #25, again.
- Iterations cannot be negative or exceed 800. Against a template limit of 800 resources, the difference is 800 − 800 = 0: the loop ceiling is the template ceiling, not an additional constraint.
- A resource cannot loop with nested child resources. The documented fix is to promote the children to top-level resources with a
parentproperty — which is worth doing anyway, since it makes the child's own lifecycle visible. - To loop on multiple property levels, use the lambda
mapfunction. Nestedforexpressions are not the tool.
batchSize is a dependency, not a throttle
This is the detail most worth internalising, because the mental model people bring is wrong in a way that matters.
By default, looped resources deploy in parallel, the order in which they are created is not guaranteed, and there is no limit on parallelism other than the template's own 800. Adding @batchSize(n) does not rate-limit that. The documentation says what it actually does: a dependency is created during earlier instances in the loop, so it doesn't start one batch until the previous batch completes.
So @batchSize(2) over four instances is not "two at a time, smoothly" — it is a dependency graph with two stages, and stage two waits for all of stage one. @batchSize(1) is fully sequential. That makes it a genuine rollout control for updating a production set in waves, and it makes deployment time a function of the slowest instance in each batch rather than the slowest instance overall.
Two recent additions worth knowing
Secure module outputs, from Bicep 0.35.1: the @secure() decorator can be applied to a module output, so a generated key or connection string can be returned to the parent without appearing in logs or deployment history. That is a genuine improvement on the position post #25 described, and it is worth being precise about what it changes — it protects the value inside one compiled template, where the parent and the module are parts of a single deployment. It is not a mechanism for passing a secret between separately deployed templates, which remains a Key Vault job.
Module identity, from Bicep 0.36.1: a user-assigned managed identity can be assigned to a module, available within it for things like reaching a Key Vault. The documentation carries an unusually candid caveat — backend services don't yet support this capability — which makes it something to know exists and not something to build on this quarter.
Why This Architecture Holds Up
Module boundaries become deployment boundaries
Because each module compiles to a nested deployment, module structure is not only a code-organisation choice — it decides how the deployment appears in the deployment history, what fails independently, and what can be redeployed on its own. A file split into eight modules produces eight nested deployments to look at when something goes wrong; a single flat file produces one.
Neither is automatically better, but the trade is real. Modules are worth their boundary when they correspond to something with its own lifecycle or its own scope — and post #24's rule makes the second case compulsory, since a module is the only way to target a different scope. Splitting purely for tidiness produces deployment history that is harder to read than the file was.
Registry or repository is a versioning decision
A local module is versioned by the repository it sits in; a registry module is versioned by its tag. That difference is the whole argument. A local path always resolves to whatever the working tree contains, which is right for modules that evolve with their caller and wrong for shared platform components, where a consumer needs to adopt a change deliberately.
Azure Verified Modules make this easy to start with, since they are pinned by tag and maintained by Microsoft. The design decision is not whether to use them but whether your own shared modules should live in a registry with the same discipline — and the answer is usually yes as soon as more than one team consumes them.
Loops are a decision about blast radius
A loop turns one declaration into up to 800 resources, deployed in parallel, in an order that is not guaranteed. For creation that is exactly what is wanted. For updates to a running estate it is a change affecting every instance simultaneously, which is why @batchSize exists and why choosing a batch size deliberately is worth the thought — it is the difference between a change that fails everywhere at once and one that fails in the first wave.
Key Architecture Decisions
The shape that works
| Decision | What to do | Why |
|---|---|---|
Module name |
Omit it, or derive it from deployment().name |
A static name deployed concurrently to one scope can return the wrong output, and the no-module-name linter rule now flags it. |
| When to make a module | When it has its own lifecycle, or when it must target a different scope | Scope crossing requires one; tidiness alone buys a nested deployment nobody wanted to read. |
| Module dependencies | Let them come from passing outputs into parameters | Dependencies are determined implicitly; an explicit dependsOn usually means the data flow is not expressing the real relationship. |
| Shared modules | A registry with tagged versions, not a shared path | A tag lets a consumer adopt a change deliberately; a path gives them whatever is on disk. |
| Registry hostnames | Native azurecr.io and friends only |
Custom domains are blocked since CLI v0.43.1 with BCP446, so a vanity domain is a migration waiting to happen. |
| Loop naming | Get the index or an array value into the name |
Each instance must have a unique name; this is the only way to guarantee one. |
| Child resources in a loop | Promote them to top-level with parent |
A resource cannot loop with nested child resources, and the promotion makes the child's lifecycle explicit. |
| Updating a looped set | Choose a batchSize deliberately for anything in production |
It creates a dependency between batches, so a bad change fails in the first wave rather than everywhere at once. |
| Secrets from a module | @secure() on the module output within one deployment; Key Vault across deployments |
The decorator keeps the value out of logs and history; it does not make it retrievable by a separate template. |
Closing Thought
The best argument for Bicep is not that it is terser than JSON, though it is. It is that the abstractions are thin enough to see through. A module is a nested deployment. A loop is a copy block. An existing reference is a lookup rather than a deployment. Nothing is hidden, and the compiled output is available whenever a behaviour does not make sense.
That thinness is what makes the surprises tractable. The module-name collision is not a Bicep bug — it is what happens when two deployments claim the same name in one scope, which was always true of nested deployments. The 800-iteration ceiling is not a loop limit — it is the template limit. batchSize behaving like a dependency rather than a throttle is not a misnomer once you know it compiles to dependencies.
The practical habit that follows is small and worth having: when a Bicep file behaves in a way you cannot explain, build it and read the JSON. It takes a minute, and the answer is nearly always sitting there in a construct you already understand.
#27 leaves Microsoft's own tooling for the one most estates actually use: Terraform on Azure — the AzureRM provider, what state is and where it should live, and the failure modes that come from having two systems that both believe they know what exists.
Comments