Most people learn Bicep by writing one file that deploys into one resource group.
That works right up until the day you need the template to create the resource
group too. Then you add targetScope = 'subscription' at the top, and every
resource in the file starts throwing errors that have nothing obvious to do with
the change you made.
The confusion is almost always the same thing: Bicep has two scopes in play at once, and they are set in two different files. Once you can name both of them, the error messages start reading like instructions instead of noise.
The problem this solves
Every Bicep file declares one target scope. It is the kind of thing the file
deploys into: a resource group, a subscription, a management group, or a tenant.
If you do not declare one, it is resourceGroup.
// No targetScope line, so this is targetScope = 'resourceGroup'
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'stexample001'
location: 'eastus2'
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
}
A resource group cannot contain another resource group, so the moment you want a template that creates its own resource groups, the whole file has to move up to subscription scope. And a subscription cannot directly contain a storage account. So the storage account has to move somewhere else.
That somewhere else is a module. A module is a separate Bicep file with its own
target scope, invoked from the parent with an explicit scope. It is the only
mechanism Bicep has for deploying across scope boundaries, which is why modules
and scopes are really one topic rather than two.
Minimum working example
Two files. The parent runs at subscription scope and creates a resource group. The module runs at resource group scope and creates a storage account inside it.
main.bicep:
targetScope = 'subscription'
param location string = 'eastus2'
param environment string
resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
name: 'rg-app-${environment}'
location: location
}
module storage 'modules/storage.bicep' = {
name: 'storage-${environment}'
scope: rg
params: {
location: location
environment: environment
}
}
output storageAccountName string = storage.outputs.storageAccountName
modules/storage.bicep:
// No targetScope line, so this file is resourceGroup scoped.
param location string
param environment string
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'stapp${environment}${uniqueString(resourceGroup().id)}'
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
allowBlobPublicAccess: false
}
}
output storageAccountName string = sa.name
Deploy it at subscription scope:
az deployment sub create \
--name app-infra \
--location eastus2 \
--template-file main.bicep \
--parameters environment=dev
Note the command: az deployment sub create, not az deployment group create.
The CLI subcommand has to match the file’s target scope. If they disagree you get
an error about the template not being valid for the deployment scope, which reads
like a template problem and is actually a command problem.
How it actually behaves
There are two scopes, and keeping them separate is most of the battle.
The declared scope is what a file says about itself, through targetScope.
It controls which resource types the file is allowed to declare and which scope
functions are available inside it.
The invoked scope is where the parent asks for that module to be deployed,
through the scope property on the module declaration. It has to be compatible
with the module’s declared scope.
When those two agree, the deployment works. When they disagree, Bicep rejects it at compile time, before anything reaches Azure.
scope accepts a resource symbol, or one of the scope functions:
targetScope = 'subscription'
// Into a resource group this same file creates
module a 'modules/storage.bicep' = {
name: 'into-new-rg'
scope: rg
params: { location: location, environment: environment }
}
// Into a resource group that already exists
module b 'modules/storage.bicep' = {
name: 'into-existing-rg'
scope: resourceGroup('rg-shared-prod')
params: { location: location, environment: environment }
}
// Into a different subscription entirely, at resource group scope
module c 'modules/storage.bicep' = {
name: 'into-other-sub'
scope: resourceGroup('00000000-0000-0000-0000-000000000000', 'rg-shared-prod')
params: { location: location, environment: environment }
}
Omitting scope means the module inherits the parent’s scope. That is fine when
the scopes match and a bug when they do not, which is the first trip-up below.
Module outputs are available on the module symbol under outputs, and they
create an implicit dependency. Referencing storage.outputs.storageAccountName
tells ARM that whatever consumes it has to wait for the storage module. You
almost never need dependsOn if you are passing outputs around.
One thing outputs cannot do is carry secrets. Deployment outputs are readable by
anyone with read access to the deployment history, so a module cannot mark an
output @secure(). Pass a Key Vault reference instead of the value itself.
Three things that trip people up
1. A module with no targetScope is a resource group module
This is the one that produces the most confusing error, because the file that is wrong is not the file the error points at.
A module file with no targetScope line is resourceGroup scoped. If a
subscription-scoped parent invokes it without a scope, the module inherits
subscription scope, which does not match what the module declared, and Bicep
refuses to compile.
targetScope = 'subscription'
// Broken: storage.bicep is resourceGroup scoped, but with no scope property
// this module inherits the parent's subscription scope.
module storage 'modules/storage.bicep' = {
name: 'storage'
params: { location: location, environment: environment }
}
The fix is one line, either scope: rg at the call site, or an explicit
targetScope in the module if it really was meant to run at subscription scope.
The habit worth building is putting targetScope at the top of every Bicep file
you write, including the resource group scoped ones where it is the default.
It costs one line and it makes the mismatch obvious in review.
2. resourceGroup() inside a module is the module’s resource group
Scope functions resolve against the scope the file is running at, not the scope
of the file that invoked it. So resourceGroup() inside a module returns the
resource group that module was deployed into.
That sounds obvious until a common shortcut meets it:
// Inside a module. Convenient, and quietly coupled to the call site.
param location string = resourceGroup().location
That default is not the location of the resource group you were thinking about
when you wrote the module. It is the location of whichever resource group the
parent happens to point the module at, which can be a different region than the
rest of the deployment. Change scope: in the parent six months later and the
module silently deploys to a new region.
Pass location in as an explicit parameter. The extra line at each call site is the point: it makes the region a decision the caller states out loud.
The related gotcha is that resourceGroup() is not available at all in a
subscription-scoped file. There is no resource group in context, so calls to it
fail to compile. Use subscription() there. Both functions exist at resource
group scope, which is why the mistake usually surfaces only after a file is
promoted to subscription scope.
3. Module names are deployment names, and they collide in loops
The name property on a module is the name of the nested ARM deployment it
creates. Deployment names are unique per scope, so two deployments with the same
name in the same scope means the second overwrites the first.
Inside a loop with a static name, every iteration writes to the same deployment:
// Broken: every iteration creates a deployment called 'storage'.
module storage 'modules/storage.bicep' = [for env in environments: {
name: 'storage'
scope: rg
params: { location: location, environment: env }
}]
What makes this expensive to debug is that it often appears to work. The
resources get created, because each iteration still submits its own template. It
is the deployment history that gets clobbered, so you lose the record of what ran
and the outputs from every iteration but the last, and what-if output stops
matching reality.
Include the loop variable:
module storage 'modules/storage.bicep' = [for env in environments: {
name: 'storage-${env}'
scope: rg
params: { location: location, environment: env }
}]
output firstAccountName string = storage[0].outputs.storageAccountName
Two limits worth knowing while you are in here. Deployment names cap at 64 characters, so a generated name built from several interpolated parameters can get truncated into a collision. And a resource group keeps 800 deployments in history, after which the oldest are deleted automatically, so a pipeline that deploys per-commit with unique names will roll its own history off faster than you expect.
Exercise
Take the two files from the minimum working example and extend them.
- Add a second resource group,
rg-data-${environment}, tomain.bicep. - Deploy the storage module into both resource groups using a single loop over an array of the two resource group symbols.
- Output an array of both storage account names.
Then run az deployment sub what-if with the same arguments as the create
command and read the output before deploying anything. Two things to check: the
nested deployment names are distinct, and the storage account names are distinct.
If they are not, you have reproduced trip-up three on purpose, which is a
cheaper way to learn it than finding it in a pipeline.
For step 2 you will need to know that a module loop can iterate over resource
symbols directly, and that scope accepts the loop variable.