Every change to an Azure resource, whether it comes from the portal, the CLI, Terraform or an SDK, ends up as a request to Azure Resource Manager (ARM), the control plane that authenticates the call, checks policy and RBAC, and forwards it to a resource provider such as Microsoft.Storage. ARM templates are the declarative JSON format that ARM executes natively: you describe the resources you want, and ARM works out ordering and applies them. Bicep is a domain-specific language that compiles to that JSON. It is shorter, typed and far easier to read, but the service never sees Bicep, only the ARM template it produces.
This article explains the whole path from a .bicep file to running resources: what the compiler generates, how ARM orders and executes a deployment, why incremental mode surprises people, how to preview changes with what-if and where it is blind, how deployment stacks add lifecycle management, and how to put it all into a pipeline that will not delete production on a typo.
ARM, templates and where the state lives
An ARM template is a JSON document with parameters, variables, resources, and outputs sections. Submitting one creates a deployment, itself a resource of type Microsoft.Resources/deployments at a resource group, subscription, management group or tenant scope. ARM expands the template, validates it, builds a dependency graph and issues PUT requests to resource providers, recording every operation in the deployment history for that scope.
The key design difference from Terraform is that there is no state file. The desired state is the template; the actual state is Azure itself, queried live. That removes state locking, drift in a stale state file and the risk of leaking secrets through state, but it also means ARM has no memory of what you deployed last time, unless you use a deployment stack. Remove a resource from the template and, by default, nothing happens to the real resource.
Anatomy of a Bicep file
A Bicep file declares its target scope, parameters with decorators, variables, resources with symbolic names, modules and outputs. The example below creates a hardened storage account with blob soft delete and calls an application module, pulling a secret from an existing Key Vault:
targetScope = 'resourceGroup'
@allowed(['dev', 'prod'])
param env string
param location string = resourceGroup().location
@minLength(3)
@maxLength(11)
param prefix string
param keyVaultName string
var storageName = toLower('${prefix}${env}${uniqueString(resourceGroup().id)}')
resource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
name: keyVaultName
}
resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: take(storageName, 24)
location: location
sku: { name: env == 'prod' ? 'Standard_ZRS' : 'Standard_LRS' }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
supportsHttpsTrafficOnly: true
}
}
resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
parent: sa
name: 'default'
properties: { deleteRetentionPolicy: { enabled: true, days: 7 } }
}
module app 'modules/app.bicep' = {
name: 'app-${env}'
params: {
location: location
storageAccountId: sa.id // implicit dependency on sa
sqlAdminPassword: kv.getSecret('sql-admin') // resolved at deploy time
}
}
output storageAccountName string = sa.nameSeveral features do real work here. Decorators such as @allowed and @maxLength fail bad inputs at validation instead of halfway through a deployment. existing gets a typed reference to a resource the file does not manage. parent declares the child relationship instead of building the name by string concatenation. uniqueString(resourceGroup().id) yields a stable, deterministic suffix per resource group, so storage names are unique but redeployments are idempotent. And kv.getSecret() passes a Key Vault reference to a module parameter marked @secure(), so the secret value is resolved by ARM at deploy time and never appears in your pipeline.
Parameters for each environment live in a .bicepparam file, which is itself type-checked against the template it names:
// main.prod.bicepparam
using 'main.bicep'
param env = 'prod'
param prefix = 'procure'
param keyVaultName = 'kv-procure-prod'
How a deployment actually executes
Compilation turns every symbolic reference into ARM expressions and, crucially, into dependsOn entries. Because the module reads sa.id, the generated JSON makes the module deployment depend on the storage account. You rarely write dependsOn by hand in Bicep; if you find yourself doing it, you are usually hiding a real data dependency the compiler could have inferred.
On submission, ARM validates the template syntax and runs preflight checks against the resource providers, which catches invalid SKUs, name conflicts and quota problems before anything changes. It then walks the dependency graph and deploys every resource whose dependencies are satisfied in parallel. Each resource is an idempotent PUT of its full desired definition. Modules become nested deployments with their own entries in the deployment history, which is where you look first when something fails: the failing operation, its status code and the provider's message are recorded per resource.
Incremental mode, complete mode and the missing-property trap
Resource group deployments default to incremental mode: resources in the template are created or updated, and resources in the group that are not in the template are left alone. That makes templates safe to compose, but it also means orphans accumulate. Complete mode deletes resources in the group that the template does not list, which is powerful and dangerous, because a template that forgets a resource deletes it.
The subtler trap is within a resource. A PUT replaces the resource's definition, so a property you leave out is not "unchanged"; the provider may reset it to its default. The classic case is a virtual network that also has subnets managed elsewhere: redeploying the VNet without its subnets in the subnets array tries to remove them, and fails or does damage depending on what is attached. Define every property you care about, and define child resources in one place only.
Previewing with what-if, and where it is blind
az deployment group what-if asks ARM to predict the changes a deployment would make, without applying them. It reports each resource as Create, Modify, NoChange, NoEffect, Ignore, Delete (only for complete-mode deployments), or Deploy when you ask only for resource IDs, and shows property-level diffs. Adding --confirm-with-what-if, or -c, to a create command shows the preview and asks before deploying; --no-pretty-print returns JSON that a pipeline can parse. Recent CLI versions add --validation-level with Provider, ProviderNoRbac and Template levels.
Treat what-if as strong evidence, not proof. It cannot evaluate utcNow(), newGuid(), listKeys(), secure parameter values or the reference() function, so properties built from them show as changing on every run. It does not expand templateLink nested deployments, including template spec references, so their resources are invisible. It reports Ignore once expansion limits are hit. And properties the provider fills with defaults can show as deletions that will not happen. Learn your templates' normal noise, and make the pipeline flag anything outside it.
Deployment stacks: lifecycle and deny settings
A deployment stack is a resource, Microsoft.Resources/deploymentStacks, that wraps a deployment and remembers the set of resources it manages. When a later update drops a resource from the template, --action-on-unmanage decides what happens: detachAll leaves it in place but unmanaged, deleteResources deletes managed resources but keeps resource groups, and deleteAll deletes both. That gives Bicep the delete semantics of Terraform without a state file, and it works across scopes where complete mode does not.
Stacks can also apply deny settings, denyDelete or denyWriteAndDelete, which create deny assignments so that nobody outside an exclusion list can change managed resources by hand, even with Owner rights. Three limits matter. Deny settings cover control-plane operations only, so blobs, secrets and other data-plane children are not protected. They apply to resources defined in the template, not resources a service creates implicitly, such as the virtual machines behind an AKS node pool. And you can exclude at most five principals; per the documentation, passing more does not return an error, so put your pipeline identity and break-glass admins into groups and exclude the groups.
# Compile and lint locally or in CI
az bicep build --file main.bicep # emits main.json
az bicep lint --file main.bicep
# Preview the stack update, including what it would detach or delete
az stack-whatif group create --name procure-prod-preview -g rg-procure-prod \
--stack-id <stack-resource-id> \
--template-file main.bicep --parameters main.prod.bicepparam \
--action-on-unmanage deleteResources --deny-settings-mode denyDelete \
--retention-interval PT3H --output json > stack-whatif.json
# Apply as a stack that deletes resources removed from the template
az stack group create --name procure-prod -g rg-procure-prod \
--template-file main.bicep --parameters main.prod.bicepparam \
--action-on-unmanage deleteResources --deny-settings-mode denyDelete \
--deny-settings-excluded-principals <pipeline-identity-object-id>
Modules, registries and reuse
Modules are just Bicep files with parameters and outputs. Share them through a private Bicep registry in Azure Container Registry, referenced as br:<registry>.azurecr.io/bicep/<path>:<tag>, or as template specs, referenced with ts:. Microsoft's Azure Verified Modules publish reviewed modules to the public registry under br/public:avm/... paths; pin an explicit version from the AVM index rather than tracking the latest, since a module version bump is an infrastructure change.
Keep module interfaces narrow: take IDs and settings in, return IDs and names out, and let callers use existing to look up other properties instead of piping them through outputs. Outputs are stored in deployment history and readable by anyone with read access to it, so mark sensitive outputs @secure() or, better, do not output secrets at all. For how modules fit a platform-wide layout of subscriptions and policies, see cloud landing zone architecture.
A pipeline with a what-if gate
A safe pipeline has four stages: compile and lint on every commit, a preview against each target environment, an automated gate on the preview, and deployment with the same parameters. Pick the preview that matches the apply. An ordinary az deployment group what-if runs in incremental mode, so it never reports the deletions a stack update with deleteResources will perform. For stacks, use az stack-whatif, shown above, which creates a stored what-if result resource and reports Create, Modify, NoChange, Delete, Detach and Unsupported per resource.
The gate below fails on any deletion and routes detaches, unsupported resources and modifications to manual approval. It walks the JSON for change-type fields instead of hard-coding a schema, so check it once against a real result from your CLI version:
import json, sys
# Shape-agnostic on purpose: walk the stack what-if JSON and flag any change-type value,
# so the gate does not break if the result schema gains or renames nesting levels.
STOP = {"delete"} # never delete without a human
REVIEW = {"detach", "unsupported", "modify"}
def walk(node, path="$"):
if isinstance(node, dict):
for k, v in node.items():
yield from walk(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
yield from walk(v, f"{path}[{i}]")
else:
yield path, node
result = json.load(open("stack-whatif.json", encoding="utf-8"))
hits = [(p, str(v).lower()) for p, v in walk(result)
if "changetype" in p.lower().rsplit(".", 1)[-1] and isinstance(v, str)]
stop = [p for p, v in hits if v in STOP]
review = [p for p, v in hits if v in REVIEW]
if stop:
sys.exit("blocked deletes at:\n" + "\n".join(stop))
if review:
print("needs manual approval:", *review, sep="\n")
sys.exit(2) # pipeline routes exit 2 to an approval stageRun the pipeline under a workload identity with only the roles it needs at the target scope, and use federated credentials rather than stored secrets. Deploy to dev and prod from the same compiled template so that what you tested is what you ship.
Failure modes you will meet
- Name collisions. Globally unique names, such as storage accounts and Key Vaults, fail preflight if taken, and a soft-deleted Key Vault still holds its name. Use prefix plus
uniqueStringand plan purge policy. - API version drift. Old API versions lack properties, and new ones change defaults. Update API versions deliberately and read the what-if diff when you do.
- Partial failure. A failed deployment leaves successfully created resources in place; by default ARM does not roll back. Re-running the same template is the recovery path, which is why idempotence matters.
- Out-of-band edits. Portal changes are overwritten by the next deployment, or silently kept if the template does not mention the property. Deny settings or policy stop this more reliably than convention.
- Secrets in history. Non-secure parameters and outputs are visible in deployment history. Mark them secure or pass Key Vault references.
- Stack out of sync. If a stack's resource list may be inaccurate, updates stop with an out-of-sync error. Redeploy the current template to resync; bypass only after reviewing the list.
Bicep or Terraform?
Bicep supports new Azure resource types and API versions from day one because it generates calls against the ARM schema directly, needs no state storage, and integrates with policy, template specs and stacks. Terraform covers many clouds and SaaS providers with one workflow, has a mature plan and module ecosystem, and its state gives exact drift detection. Azure-only platform teams often choose Bicep; organisations standardising across clouds, as discussed in multi-cloud architecture, usually choose Terraform and accept a short lag on new Azure features. Whichever you pick, the operational disciplines are the same ones the Well-Architected framework asks for: reviewed changes, previewed diffs and least-privilege deployment identities.
What to do next
- Run
az bicep buildandaz bicep lintin CI on every commit, and fail the build on errors. - Move per-environment values into .bicepparam files and secrets into Key Vault references with secure parameters.
- Add a preview stage that matches how you apply:
az deployment group what-if --no-pretty-printfor plain deployments,az stack-whatiffor stacks. Learn its normal noise and gate on deletes and detaches. - Adopt deployment stacks for environments you own end to end, starting with
detachAlluntil you trust the resource list, then move todeleteResources. - Apply
denyDeleteto production stacks and exclude a pipeline group and a break-glass group, never more than five principals. - Publish shared modules to a private registry with pinned versions, and audit outputs for secrets.