Skip to content

Module 3.12: ARM & Bicep Basics

This [MEDIUM]-complexity module takes about 1.5 hours and builds on Module 3.1 (Entra ID) plus basic Azure CLI fluency, because Bicep deployments assume you already know how to target resource groups and authenticate with az login. When you finish the theory sections, tool comparisons, and hands-on lab below, you will be able to accomplish the following outcomes.

  • Deploy Azure resources using Bicep templates with parameters, modules, and conditional logic
  • Implement Bicep modules and template specs for reusable infrastructure components across teams
  • Configure deployment stacks and what-if operations to preview and protect Bicep deployments
  • Compare Bicep with ARM templates and Terraform to evaluate the right IaC tool for Azure environments

Hypothetical scenario: a platform team must rebuild a staging environment after a subscription migration. Portal clicks and wiki notes disagree on SKU choices, subnet sizes, and tag conventions. Engineers spend days reconciling drift before the environment is usable again. That failure mode is common when infrastructure lives outside version control.

Once an environment is described in Bicep, reprovisioning becomes faster and more repeatable. You rerun a deployment with different parameters instead of rediscovering settings from portal history. This is the fundamental promise of Infrastructure as Code (IaC): your infrastructure is defined in version-controlled files, not in wiki pages, portal clicks, or tribal knowledge. ARM templates have been Azure’s native IaC format since the beginning. Bicep is the modern, human-friendly language that compiles down to ARM. In this module, you will learn ARM template structure, Bicep syntax, modules, deployment scopes, and the what-if feature that previews changes before they are applied. By the end, you will refactor a CLI-based deployment script into a reusable Bicep template.


Azure Resource Manager (ARM) is the deployment and management layer for Azure. Every Azure operation---whether from the portal, CLI, PowerShell, or SDK---goes through ARM. ARM templates are JSON files that define the resources you want to deploy.

{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"environment": {
"type": "string",
"allowedValues": ["dev", "staging", "prod"],
"defaultValue": "dev"
},
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]"
}
},
"variables": {
"storageName": "[format('kubedojo{0}{1}', parameters('environment'), uniqueString(resourceGroup().id))]"
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[variables('storageName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2",
"properties": {
"minimumTlsVersion": "TLS1_2",
"allowBlobPublicAccess": false
}
}
],
"outputs": {
"storageAccountName": {
"type": "string",
"value": "[variables('storageName')]"
},
"storageEndpoint": {
"type": "string",
"value": "[reference(variables('storageName')).primaryEndpoints.blob]"
}
}
}
classDiagram
class ARMTemplate {
+$schema: Template format version
+contentVersion: Your version of the template
+parameters: Values provided at deployment time
+variables: Computed values (internal)
+resources: Azure resources to deploy
+outputs: Values returned after deployment
}

The template above follows the canonical ARM shape. parameters accept deployment-time values. variables hold computed strings. resources declare what Azure should create. outputs return useful values to callers or pipelines. Every property is explicit JSON, so you can trace exactly what will deploy. The syntax still makes modest templates feel heavy compared with imperative CLI scripts.

Stop and think: If an ARM template dynamically generates a storage account name using [concat(parameters('env'), uniqueString(resourceGroup().id))], and the deployment fails because the name exceeds Azure’s 24-character limit, how do you debug this? Since ARM templates do not support print() statements and the generation happens server-side, what steps must you take to discover the exact string that the ARM engine attempted to provision?

ARM templates work, but they carry significant drawbacks that show up quickly on real teams. They are verbose (simple deployments require dozens of lines of JSON). They are hard to read when nested functions pile up. JSON does not support inline comments, so intent disappears. Modularization via linked templates requires externally hosted URIs and extra deployment orchestration. Microsoft introduced Bicep to preserve ARM’s deployment engine while removing these ergonomics gaps. You still deploy through Resource Manager. You author in a language designed for infrastructure engineers rather than JSON editors.

ARM functions and expressions (why Bicep feels lighter)

Section titled “ARM functions and expressions (why Bicep feels lighter)”

ARM JSON uses a string-based expression language inside [...] brackets. Common functions include resourceGroup(), subscription(), uniqueString(), format(), and reference(). They are powerful but dense:

"name": "[format('kubedojo{0}{1}', parameters('environment'), uniqueString(resourceGroup().id))]"

Bicep replaces many of these with native string interpolation: 'kubedojo${environment}${uniqueString(resourceGroup().id)}'. The compiled output still uses ARM functions under the hood. Authors simply stop fighting JSON escaping. When you debug a failed deployment, az deployment group show and the portal deployment blade still expose ARM-level errors. Learning one expression style in Bicep is enough for Essentials; reach for ARM JSON function docs when decompiling legacy templates.

Linked templates vs Bicep modules (historical context)

Section titled “Linked templates vs Bicep modules (historical context)”

Before Bicep modules, large ARM solutions split into linked templates stored in storage accounts or template spec URIs. The parent template called children with Microsoft.Resources/deployments resources. That pattern works but adds moving parts: SAS tokens, URI versioning, and failure modes when a child URI is unreachable. Bicep’s module keyword compiles to the same deployment resource type. The difference is developer ergonomics: relative paths, compile-time validation, and registry publishing. If you inherit linked-template estates, az bicep decompile and incremental module extraction are typical migration steps.


Bicep is a domain-specific language (DSL) that compiles to ARM JSON. It provides the same deployment capabilities with better readability and tooling. The Bicep CLI transpiles your source file into standard ARM JSON before Resource Manager sees it. That transparency matters in practice. You can mix Bicep modules with raw ARM JSON modules in one deployment. You can check compiled output into source control when a scanner only accepts JSON. Day-to-day authoring stays in the cleaner Bicep syntax.

The storage account from the ARM example above shrinks to roughly half the line count when expressed in Bicep, and the readability gap widens once you add parameters, conditions, and modules.

main.bicep
@allowed(['dev', 'staging', 'prod'])
param environment string = 'dev'
param location string = resourceGroup().location
var storageName = 'kubedojo${environment}${uniqueString(resourceGroup().id)}'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
}
output storageAccountName string = storageAccount.name
output storageEndpoint string = storageAccount.properties.primaryEndpoints.blob

Side by side, the language differences are stark enough that teams usually standardize on Bicep for new Azure-only work while keeping ARM JSON only where legacy pipelines or third-party tools require it.

FeatureARM JSONBicep
Lines of code~40 lines~20 lines
CommentsNot supported// and /* */
String interpolation[format('a{0}b', param)]'a${param}b'
ReadabilityLow (nested JSON)High (clean syntax)
IntelliSenseLimitedFull VS Code support
ModulesLinked templates (external URLs)Native module keyword
CompilationN/A (direct JSON)Compiles to ARM JSON
DecompilationN/ACan decompile ARM to Bicep

In day-to-day use, the biggest wins are comments, string interpolation, and first-class modules. You can explain non-obvious naming logic inline. You compose ${environment} strings naturally. You import ./modules/storage.bicep without a template hosting endpoint. When you bootstrap from an existing environment, az bicep decompile gives you a starting Bicep file from exported ARM JSON. That is often faster than rewriting portal-created resources by hand.

Some regulated environments store only JSON in Git. The workflow is: author in Bicep locally, az bicep build, commit main.json, deploy JSON in CI. Other teams store Bicep only and compile in the pipeline. Both are valid. Pick one policy per repository and enforce it in code review. Mixing hand-edited JSON with Bicep sources in the same folder without ownership rules creates drift within the IaC layer itself.

While Bicep is the native choice for Azure, Terraform remains a common option for multi-cloud IaC. Teams already managing Datadog, GitHub, or AWS with Terraform often keep Azure resources in the same state backend for operational consistency. The decision is less about syntax beauty and more about where state should live. It is also about how many clouds one pipeline must orchestrate.

Terraform plans resemble Bicep what-if previews, but the mechanics differ. Terraform compares desired state in HCL against its state file. Bicep what-if compares template against live Azure without a separate state artifact. Hybrid organizations sometimes use Terraform for multi-cloud and Bicep for Azure-only landing zones. Document the boundary so engineers do not duplicate networking baselines in two languages.

FeatureBicepTerraform
State ManagementNo state file (state is in Azure)Requires state file management (backend)
Cloud SupportAzure onlyMulti-cloud (AWS, GCP, Azure, etc.)
Day 0 SupportImmediate support for new Azure featuresSlight delay for provider updates
IntegrationNative to Azure CLI and PortalRequires separate Terraform CLI

The comparison tables above are a starting point. The full Decision Framework section later in this module walks through Bicep versus ARM JSON versus Terraform, incremental versus complete deployment mode, and local modules versus registry modules with explicit tradeoffs.


Bicep is a transparent abstraction over ARM JSON. There is no separate Bicep runtime in Azure. The Bicep CLI transpiles your .bicep file into a standard ARM deployment template, and Resource Manager deploys that JSON. Every resource block maps one-to-one to an entry in the compiled resources array. That transparency matters when security scanners only accept JSON, when you need to diff compiled output in CI, or when you mix Bicep modules with legacy ARM JSON modules in one deployment.

File anatomy: params, vars, resources, modules, outputs

Section titled “File anatomy: params, vars, resources, modules, outputs”

A Bicep file is declarative. Element order does not change deployment behavior. The canonical sections are:

SectionPurposeTypical use
targetScopeWhere the deployment runsDefault resourceGroup; lift to subscription to create RGs
paramInputs at deploy timeEnvironment name, SKU, secrets (@secure())
varInternal computed valuesNaming prefixes, tag objects, derived CIDRs
resourceAzure resources to createOne symbolic name per resource; pin @api-version
moduleReusable nested deploymentsLocal path, registry br:, or template spec
outputValues returned to callersEndpoints, resource IDs, connection hints

Metadata, user-defined type blocks, and experimental func definitions extend the model for larger platforms. For most Essentials modules, parameters, variables, resources, modules, and outputs cover daily work.

Resource dependencies: implicit vs explicit

Section titled “Resource dependencies: implicit vs explicit”

Resource Manager orders deployments using dependencies. In Bicep, referencing another resource’s symbolic name creates an implicit dependency. If webApp uses appServicePlan.id, ARM deploys the plan before the web app without a manual dependsOn array.

You add explicit dependsOn when the link is not visible from property references alone. Examples include a deployment script that must run after a storage account exists, or extension resources that need ordering the compiler cannot infer. Prefer implicit dependencies because they stay readable and survive refactors.

// Implicit: webApp waits for plan because of serverFarmId reference
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: 'kubedojo-plan'
location: resourceGroup().location
sku: { name: 'B1' }
}
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: 'kubedojo-web'
location: resourceGroup().location
properties: {
serverFarmId: appServicePlan.id
}
}
// Explicit dependsOn when no property reference exists
resource diagnosticSetting 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
name: 'send-to-log-analytics'
scope: webApp
properties: {
workspaceId: logAnalyticsWorkspace.id
}
dependsOn: [
webApp
logAnalyticsWorkspace
]
}

Iterative loops use [for item in collection: { ... }]. You can loop resources, modules, variables, properties, and outputs. Conditional deployments use if (expression) on a resource or module. Skipped resources are absent from the deployment, which affects outputs that reference them.

Existing resource references let a template read resources deployed elsewhere without recreating them. You declare existing and optionally set scope to another resource group or subscription. Platform templates often reference a shared Key Vault, hub VNet, or Log Analytics workspace this way.

Small module example: network + compute wiring

Section titled “Small module example: network + compute wiring”

The snippet below is a realistic slice of a service template. It shows params, vars, implicit dependencies, a conditional dev-only storage account, and outputs for a pipeline.

@description('Environment name')
@allowed(['dev', 'staging', 'prod'])
param environment string
param location string = resourceGroup().location
var prefix = 'kubedojo-${environment}'
var tags = {
environment: environment
costCenter: 'platform-eng'
managedBy: 'bicep'
}
resource vnet 'Microsoft.Network/virtualNetworks@2023-04-01' = {
name: '${prefix}-vnet'
location: location
tags: tags
properties: {
addressSpace: { addressPrefixes: ['10.0.0.0/16'] }
subnets: [
{ name: 'app', properties: { addressPrefix: '10.0.1.0/24' } }
]
}
}
resource devStorage 'Microsoft.Storage/storageAccounts@2023-01-01' = if (environment == 'dev') {
name: '${replace(prefix, '-', '')}devstore'
location: location
tags: tags
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
}
output vnetResourceId string = vnet.id
output devStorageName string = environment == 'dev' ? devStorage.name : ''

Compile locally before every PR: az bicep build --file main.bicep writes main.json so reviewers can inspect the ARM Resource Manager will execute.

Transpilation mental model (1:1 with ARM resources)

Section titled “Transpilation mental model (1:1 with ARM resources)”

When you declare resource stg 'Microsoft.Storage/storageAccounts@2023-01-01' = { ... }, the compiler emits one entry in the resources array of the ARM template. Module blocks emit nested Microsoft.Resources/deployments resources. Parameters become ARM parameters; variables become ARM variables. There is no hidden magic layer that changes SKU semantics. If a Bicep deployment creates an unexpected resource type, the compiled JSON is the ground truth artifact to inspect.

Decompilation (az bicep decompile --file exported.json) is lossy for human readability but excellent for bootstrapping. Exported portal templates include noise and sometimes outdated API versions. Treat decompile output as a draft. Refactor into modules, parameters, and naming variables before merging to main.

Most production templates combine parameters for environment variance, variables for derived names and tags, resource declarations with explicit API versions, optional existing references for out-of-scope dependencies, conditional and loop constructs for branching deployments, and outputs that downstream modules or pipelines consume. The example below shows each pattern in one file so you can see how they interact before splitting the file into modules.

// Parameters: Values provided at deployment time
@description('The environment name')
@allowed(['dev', 'staging', 'prod'])
param environment string
@description('Azure region for resources')
param location string = resourceGroup().location
@minValue(1)
@maxValue(10)
param instanceCount int = 2
@secure()
param adminPassword string // Marked secure: not logged, not shown in outputs
// Variables: Computed values
var prefix = 'kubedojo-${environment}'
var tags = {
environment: environment
project: 'kubedojo'
managedBy: 'bicep'
}
// Resources: Azure resources to deploy
resource vnet 'Microsoft.Network/virtualNetworks@2023-04-01' = {
name: '${prefix}-vnet'
location: location
tags: tags
properties: {
addressSpace: {
addressPrefixes: ['10.0.0.0/16']
}
subnets: [
{
name: 'app-subnet'
properties: {
addressPrefix: '10.0.1.0/24'
}
}
{
name: 'data-subnet'
properties: {
addressPrefix: '10.0.2.0/24'
}
}
]
}
}
// Reference existing resources (not deployed by this template)
resource existingKeyVault 'Microsoft.KeyVault/vaults@2023-02-01' existing = {
name: 'my-existing-vault'
scope: resourceGroup('other-rg') // Can reference resources in other RGs
}
// Conditional deployment
resource devStorage 'Microsoft.Storage/storageAccounts@2023-01-01' = if (environment == 'dev') {
name: '${prefix}devstorage'
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {}
}
// Loops
resource nsgRules 'Microsoft.Network/networkSecurityGroups@2023-04-01' = [for i in range(0, instanceCount): {
name: '${prefix}-nsg-${i}'
location: location
properties: {}
}]
// Outputs
output vnetId string = vnet.id
output subnetIds array = [for subnet in vnet.properties.subnets: subnet.id]

Pause and predict: You define a resource using if (environment == 'dev') and subsequently expose its id as a template output. If you deploy this template to the ‘prod’ environment, the resource is skipped. What exactly happens at runtime when the ARM engine attempts to construct that output? How does Bicep handle conditional outputs when the underlying resource does not exist?

Pay attention to API versions in each resource declaration. Pinning Microsoft.Storage/storageAccounts@2023-01-01 makes upgrades deliberate. Drifting to “latest” during a routine edit can change property schemas and break CI unexpectedly. Secure parameters (@secure()) keep secrets out of deployment logs. That matters the moment you parameterize admin passwords or connection strings.

Conditional outputs and symbolic references

Section titled “Conditional outputs and symbolic references”

When a resource is deployed with if (environment == 'dev'), outputs that reference it must handle absence in non-dev environments. Patterns include conditional outputs with ternary expressions (environment == 'dev' ? devStorage.name : '') or separate outputs per environment tier. Quiz question 5 in this module probes the failure mode when outputs assume a resource always exists.

Symbolic names are case-sensitive and cannot collide with parameter or variable names. Reference resources with their symbolic name (storageAccount.name), not the Azure resource name string, so renames propagate automatically.

Once a template compiles cleanly, deployment is a Resource Manager operation. You invoke it through Azure CLI, PowerShell, Azure DevOps, or GitHub Actions. The CLI commands below show the usual progression. Deploy with inline parameters when experimenting. Deploy with a parameter file when environments differ. Preview with what-if before shared environments. Validate without mutating resources in CI. Inspect history when debugging partial failures. Decompile exported JSON when migrating legacy ARM from portal exports.

Understanding failure messages is part of safe deployment practice. Common classes include:

  • AuthorizationAuthorizationFailed means the deploying identity lacks RBAC on the RG or resource type.
  • QuotaQuotaExceeded means subscription limits for cores, public IPs, or storage accounts.
  • Invalid template — Compile-time Bicep errors should be caught by az bicep build; ARM validation errors appear in az deployment group validate.
  • Conflict — Name collisions on globally unique resources such as storage accounts.

When a nested module fails, open the child deployment in the portal or with az deployment operation group list. The parent deployment shows success while a child failed; do not assume the whole stack is healthy from the parent status alone.

Terminal window
# Deploy a Bicep template to a resource group
az deployment group create \
--resource-group myRG \
--template-file main.bicep \
--parameters environment=staging instanceCount=3
# Deploy with a parameters file
az deployment group create \
--resource-group myRG \
--template-file main.bicep \
--parameters @parameters.staging.json
# Preview changes (what-if) before deploying
az deployment group what-if \
--resource-group myRG \
--template-file main.bicep \
--parameters environment=staging
# Validate without deploying
az deployment group validate \
--resource-group myRG \
--template-file main.bicep \
--parameters environment=staging
# View deployment history
az deployment group list --resource-group myRG -o table
# Export an existing resource group to Bicep (decompile)
az bicep decompile --file exported-template.json

Modules are the killer feature of Bicep. They let you break large templates into reusable components with explicit input parameters and typed outputs. Think of them like functions in a programming language, but fully declarative. A main.bicep file orchestrates the environment. ./modules/*.bicep files encapsulate networking, storage, compute, and observability. Environment-specific .bicepparam files supply the only values that should change between dev, staging, and prod.

Module contracts: inputs, outputs, and deployment names

Section titled “Module contracts: inputs, outputs, and deployment names”

Each module invocation creates a nested deployment with the name you provide. That name appears in deployment history and must be unique within the parent deployment. Use descriptive names (storage-${environment}) so operators correlate portal blades with Git modules.

Module params are the public API. Treat breaking param renames like application API changes: semver your registry modules and document migrations. Module outputs are the integration surface for main.bicep. Export only what parents need (IDs, endpoints, names). Avoid leaking internal symbolic names.

When a module fails, nested deployment errors bubble up with the child deployment name. Splitting modules by failure domain (network vs compute) shortens incident triage because you know which nested deployment to open first.

Three patterns appear repeatedly in Azure platform engineering:

  • Layered mainmain.bicep calls networking, then compute, then observability modules. Pass subnet IDs from network outputs into compute params. Dependencies stay implicit through output references.
  • Environment router — One main.bicep, many .bicepparam files. CI selects the parameter file by branch or pipeline stage. No template forks per environment.
  • Platform base + product overlay — Subscription template creates RG and policies. Product main.bicep deploys into the RG. Keeps guardrails centralized and application velocity high.

Anti-pattern: circular output dependencies between modules A and B. Bicep compile fails early, but design reviews should catch logical cycles before merge.

graph TD
subgraph Bicep Project Structure
main[main.bicep]
subgraph modules[modules/]
net[network.bicep<br/>VNet, subnets, NSGs]
stor[storage.bicep<br/>Storage account]
comp[compute.bicep<br/>App Service Plan + App]
mon[monitoring.bicep<br/>Log Analytics, alerts]
end
subgraph parameters[parameters/]
dev[dev.bicepparam]
stg[staging.bicepparam]
prod[prod.bicepparam]
end
end
main --> net
main --> stor
main --> comp
main --> mon
dev -.-> main
stg -.-> main
prod -.-> main

In the layout above, main.bicep stays thin: it wires modules together, passes shared tags and naming prefixes, and exposes top-level outputs for pipelines. Each module file owns one bounded slice of infrastructure so reviewers can reason about storage SKUs separately from App Service Plan sizing.

modules/storage.bicep
@description('Storage account name')
param name string
@description('Azure region')
param location string
@description('Storage account SKU')
@allowed(['Standard_LRS', 'Standard_ZRS', 'Standard_GRS'])
param skuName string = 'Standard_LRS'
param tags object = {}
resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: name
location: location
tags: tags
sku: { name: skuName }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
supportsHttpsTrafficOnly: true
}
}
output id string = storage.id
output name string = storage.name
output primaryEndpoint string = storage.properties.primaryEndpoints.blob
modules/appServicePlan.bicep
param name string
param location string
param skuName string = 'B1'
param tags object = {}
resource plan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: name
location: location
tags: tags
sku: {
name: skuName
}
kind: 'linux'
properties: {
reserved: true // Required for Linux
}
}
output id string = plan.id
output name string = plan.name
// main.bicep - Using modules
@allowed(['dev', 'staging', 'prod'])
param environment string
param location string = resourceGroup().location
var prefix = 'kubedojo-${environment}'
var tags = {
environment: environment
project: 'kubedojo'
managedBy: 'bicep'
}
// Deploy storage using the module
module storage 'modules/storage.bicep' = {
name: 'storage-deployment'
params: {
name: '${replace(prefix, '-', '')}store'
location: location
skuName: environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS'
tags: tags
}
}
// Deploy App Service Plan using the module
module appPlan 'modules/appServicePlan.bicep' = {
name: 'app-plan-deployment'
params: {
name: '${prefix}-plan'
location: location
skuName: environment == 'prod' ? 'P1v3' : 'B1'
tags: tags
}
}
// Reference module outputs
output storageEndpoint string = storage.outputs.primaryEndpoint
output appPlanId string = appPlan.outputs.id

Notice how main.bicep references storage.outputs.primaryEndpoint and appPlan.outputs.id without re-declaring those resources. That is the compositional payoff. Modules remain independently testable with az bicep build per file. The root template describes the full environment graph. In code review, challenge modules that export ten outputs but only three are used. Unused outputs often signal unclear module boundaries.

Testing strategy for modules: compile each module in isolation, run az deployment group validate with mock parameters in a lab RG, and only then integrate into main.bicep. For registry modules, add a consumer template in the module repository that pins the version under test.

Parameter files keep secrets and environment-specific values out of the shared template. A .bicepparam file binds to exactly one Bicep entrypoint via using, so CI can deploy the same main.bicep everywhere while swapping only the parameter file path.

parameters/staging.bicepparam
using '../main.bicep'
param environment = 'staging'
param location = 'eastus2'
Terminal window
# Deploy with a .bicepparam file
az deployment group create \
--resource-group myRG \
--template-file main.bicep \
--parameters parameters/staging.bicepparam

User-defined types and secure parameters (advanced authoring)

Section titled “User-defined types and secure parameters (advanced authoring)”

Bicep user-defined types let you group related parameters into one structured object with compile-time validation. Instead of eight separate VM parameters, you define a vmConfig type and pass one object. That pattern scales when platform teams publish opinionated configs (backup policy bundles, monitoring baselines).

Combine types with @secure() on secrets. Secure parameters are not logged in deployment history the same way plain strings are. They still exist in parameter files on disk, so protect .bicepparam with pipeline secret stores for passwords and keys. Prefer managed identities at runtime over embedding secrets in templates when the service supports it.

Sharing Modules: Registries and Template Specs

Section titled “Sharing Modules: Registries and Template Specs”

When modules graduate from a single repository to an organization-wide standard, you need a distribution mechanism that enforces versioning and access control. Azure provides two native ways to share IaC components without asking every team to clone the same Git repository.

  1. Private Bicep Registries (ACR): You can publish Bicep modules to an Azure Container Registry (ACR). Teams reference them using the br: scheme (e.g., module redis 'br:myacr.azurecr.io/bicep/modules/redis:v1').
  2. Template Specs: Template specs are native Azure resources that store an ARM template for later deployment. You can compile a Bicep file and save it as a Template Spec, complete with RBAC access control and versioning, allowing non-developers to deploy standardized infrastructure directly from the Azure Portal.
Terminal window
# Publish a Bicep module to a Template Spec
az ts create \
--name standard-storage-spec \
--version "1.0.0" \
--resource-group myRG \
--location eastus2 \
--template-file modules/storage.bicep

Bicep can deploy resources at four scopes. Each scope defines what resource types you may declare and which Azure CLI command invokes the deployment. Choosing the wrong scope is a common review failure: a resource group template cannot create a subscription policy without changing targetScope.

ScopetargetScope valueTypical resourcesCLI command
Resource groupresourceGroup (default)Storage, VNets, AKS, Web Appsaz deployment group create
SubscriptionsubscriptionResource groups, policies, budgetsaz deployment sub create
Management groupmanagementGroupMG policies, RBAC at MG levelaz deployment mg create
TenanttenantTenant-wide policies, Entra artifactsaz deployment tenant create

Most application templates stay at resource group scope. Platform teams lift to subscription or management group when the template must create the resource group itself, assign Azure Policy, or wire role assignments before application resources land.

graph TD
T["Tenant Scope<br/>(Management groups, policies)<br/>targetScope = 'tenant'"] --> MG["Management Group Scope<br/>(Policies, RBAC at MG level)<br/>targetScope = 'managementGroup'"]
MG --> S["Subscription Scope<br/>(Resource groups, policies, budgets)<br/>targetScope = 'subscription'"]
S --> RG["Resource Group Scope (default)<br/>(All Azure resources)<br/>targetScope = 'resourceGroup'"]
// Subscription-scoped deployment: create a resource group + deploy into it
targetScope = 'subscription'
param location string = 'eastus2'
param environment string
resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = {
name: 'kubedojo-${environment}'
location: location
}
// Deploy resources into the newly created resource group
module resources 'main.bicep' = {
name: 'resources-deployment'
scope: rg // Deploy into the RG we just created
params: {
environment: environment
location: location
}
}

Subscription-scoped deployments are a common pattern for platform teams. The outer template creates the resource group. A nested module uses scope: rg so application templates stay resource-group-local while the subscription template owns naming and placement policy.

Terminal window
# Resource group scope (default)
az deployment group create -g myRG -f main.bicep
# Subscription scope
az deployment sub create --location eastus2 -f subscription.bicep
# Management group scope
az deployment mg create --management-group-id myMG --location eastus2 -f mg.bicep
# Tenant scope
az deployment tenant create --location eastus2 -f tenant.bicep

Local modules vs registry modules vs template specs

Section titled “Local modules vs registry modules vs template specs”

Three distribution patterns cover most enterprise Bicep reuse:

  1. Local modules (module net './modules/network.bicep' = { ... }) — fastest for a single repo; paths are relative; ideal for tightly coupled stacks reviewed together in one PR.
  2. Private Bicep registry on ACR — publish versioned modules; consumers reference br:myregistry.azurecr.io/bicep/modules/redis:v1. Scales when many subscriptions need the same hardened baseline without copying Git subtrees.
  3. Template specs — ARM templates stored as Azure resources with RBAC and versioning; portal-friendly for operators who do not edit Git daily.

Registry modules trade local simplicity for governance. Template specs trade Bicep-native ergonomics for portal deploy buttons. Local modules trade enterprise scale for speed during early product development.

// Registry module (requires prior publish to ACR)
module redisCache 'br:contoso.azurecr.io/bicep/redis:v1' = {
name: 'redis-${environment}'
params: {
name: '${prefix}-cache'
location: location
sku: environment == 'prod' ? 'Premium' : 'Basic'
}
}

Publish registry artifacts in CI after az bicep build succeeds. Pin versions in consumer templates so a floating :latest tag cannot change production SKUs overnight.


Deployment Stacks: Managing Resource Lifecycles

Section titled “Deployment Stacks: Managing Resource Lifecycles”

While Bicep modules organize your code, deployment stacks organize deployed resources. A stack is a native Azure resource that tracks a set of resources deployed from a template. Stacks can span resource groups and subscriptions when platform boundaries require it.

Stacks address two production gaps that modules alone do not solve:

  • Lifecycle cleanup — When you remove a resource from the template and redeploy the stack, actionOnUnmanage can delete or detach resources that are no longer managed. That is closer to “template is source of truth” than incremental mode alone.
  • Drift protectionDeny settings can block portal or CLI deletes and modifications on managed resources. Use this when regulatory or SRE policy requires infrastructure changes to flow through Git, not break-glass portal edits.

Deny settings are powerful. A misconfigured deny rule can block legitimate incident response. Pilot stacks on non-production scopes first. Document an escalation path before enabling denyDelete on data-plane resources.

Terminal window
# Create a deployment stack with DenySettings to protect against drift
az stack group create \
--name my-production-stack \
--resource-group myRG \
--template-file main.bicep \
--action-on-unmanage detachAll \
--deny-settings-mode denyDelete \
--yes

Stacks complement incremental deployments; they do not replace Git review. A stack tracks which resources belong to the managed set. Removing a resource from the template without updating the stack action can leave orphans or surprise deletes depending on actionOnUnmanage. Document whether your organization uses deleteResources or detachResources for non-production stacks. Detach is safer when experimenting. Delete enforces strict parity at the cost of data loss risk.


Shared subscriptions multiply the impact of template mistakes. A staging deployment that targets the wrong resource group name can overwrite production if parameters are swapped. Guardrails that reduce that risk are cheap compared to incident hours.

Resource group targeting — Always pass -g or --resource-group explicitly in scripts. Never rely on az configure defaults in CI agents. Pipeline variables should include subscription ID and resource group name per environment.

Parameter file review.bicepparam diffs should be as visible as .bicep diffs in pull requests. A one-line change from eastus2 to westeurope can move data across regions with egress cost implications.

Deployment correlation — Tag deployments with Git SHA in the deployment name or via metadata in Bicep. When finance asks why storage spend doubled, you need the deployment name that introduced Standard_GRS.

Cross-subscription modules — Modules can set scope to resource groups in other subscriptions when RBAC allows. Hub-spoke networks often reference hub VNets with existing and peering resources in spoke templates. Draw scope diagrams in design docs so reviewers see subscription boundaries.

Kubernetes note for this curriculum — When Bicep deploys AKS, cluster version should align with the KubeDojo target (Kubernetes 1.35). Pin kubernetesVersion in the AKS resource block rather than accepting portal defaults. Upgrades belong in planned template changes with what-if, not ad hoc portal bumps.

Incremental mode does not delete portal-created resources missing from the template. Teams run scheduled what-if jobs comparing Git main to live Azure. Any unexpected + or ~ triggers a ticket to backfill Bicep or remove manual resources. This pattern avoids complete mode while still surfacing drift within days instead of months.

az group export and portal export generate ARM JSON for brownfield imports. Expect manual cleanup: exported templates include default properties and sometimes resources you no longer want. Workflow: export → decompile to Bicep → refactor modules → validate in lab RG → what-if against production RG read-only (if RBAC permits preview) → deploy incrementally.


Production Bicep work is not only syntax. It is a pipeline of validate, lint, what-if, and controlled apply. What-if is your safety net before any az deployment group create touches shared environments. It predicts create, modify, and delete operations without changing resources. Bicep has no separate state file; what-if is how you see blast radius against live Azure.

Pause and predict: A colleague manually added a test subnet to your VNet via the Azure Portal. Your Bicep template defines the VNet but does not include this new subnet in its subnets array property. When you run a what-if deployment in Incremental mode, will the manual subnet be marked for deletion (-), modification (~), or ignored completely? What does this reveal about how Azure handles arrays during declarative updates?

Incremental deployments often do not remove array elements that exist in Azure but are absent from the template definition. The manual subnet may survive with no delete marker in what-if. That behavior is why drift detection must include periodic template backfill, not only deployment-time previews. Complete mode is the mode that can remove resources not listed in the template; it is also the mode that deletes databases when misused.

Array properties are not the only subtle case. Renaming a resource in Bicep usually creates a destroy-and-recreate plan because Azure resource names are often immutable. What-if shows delete plus create. Treat name changes as migration projects with data copy plans, not as casual refactors.

Terminal window
# Preview changes
az deployment group what-if \
--resource-group myRG \
--template-file main.bicep \
--parameters environment=staging

The what-if output uses color-coded symbols so you can scan a large deployment quickly. Treat anything marked modify or delete as a prompt to read the underlying property path, not as noise to scroll past.

What-If Results:
+ Create (new resource will be created)
~ Modify (existing resource properties will change)
- Delete (resource will be removed — complete mode or omitted from template)
= NoChange (resource exists and matches template; may redeploy without property changes)
* Ignore (resource exists but is not in the template, or what-if could not expand it)
! Deploy (ResourceIdOnly format — resource will redeploy; property changes unknown)
Example output:
Resource and property changes are indicated with these symbols:
- Delete
+ Create
~ Modify
The deployment will update the following scope:
Scope: /subscriptions/xxx/resourceGroups/myRG
~ Microsoft.Storage/storageAccounts/kubedojostorage [2023-01-01]
~ properties.minimumTlsVersion: "TLS1_0" => "TLS1_2"
+ Microsoft.Web/serverfarms/kubedojo-staging-plan [2023-01-01]

Running production deployments in complete mode can delete resources omitted from the template. Incremental mode is the default and recommended path for most workloads. Complete mode exists when the template must represent the entire resource group and prune drift. Always pair complete mode with what-if and an explicit change ticket.

What-if output uses symbols documented on Learn. Train reviewers to treat ~ Modify on SKU or redundancy properties as a cost signal, not only a technical diff.

SymbolMeaningReviewer question
+CreateIs this resource net-new spend?
~ModifyDoes SKU, redundancy, or retention change monthly cost?
-DeleteIs data loss possible (storage, SQL, DNS)?
=NoChangeConfirms template matches live for that resource
*IgnoreResource not in template or what-if could not expand it
!DeployResourceIdOnly format — redeploy expected; property diff unknown

Array properties on resources such as subnets deserve extra care. Incremental deployments may not delete portal-added array elements that the template omits. What-if helps surface that class of drift before you assume parity.

CI integration: validate, build, what-if, deploy

Section titled “CI integration: validate, build, what-if, deploy”

A minimal safe pipeline for pull requests:

# Excerpt: GitHub Actions style — adapt to your runner auth (OIDC/federated credential)
steps:
- name: Install Bicep CLI
run: az bicep install
- name: Build and lint
run: az bicep build --file infra/main.bicep
- name: Validate deployment
run: |
az deployment group validate \
--resource-group ${{ vars.RG_NAME }} \
--template-file infra/main.bicep \
--parameters @infra/parameters/ci.bicepparam
- name: What-if (required gate)
run: |
az deployment group what-if \
--resource-group ${{ vars.RG_NAME }} \
--template-file infra/main.bicep \
--parameters @infra/parameters/ci.bicepparam \
--result-format ResourceIdOnly

Promotion to production should require a human or break-glass approver on what-if output when the diff shows deletes or SKU changes on data services. Store deployment names in pipelines so az deployment group list correlates Git commits with Azure history.

Terminal window
# Deployment modes
# Incremental (default): Add/modify resources, never delete
az deployment group create -g myRG -f main.bicep --mode Incremental
# Complete: Add/modify AND delete resources not in template (DANGEROUS)
az deployment group create -g myRG -f main.bicep --mode Complete
# ALWAYS use what-if before complete mode
az deployment group what-if -g myRG -f main.bicep --mode Complete

Bicep and ARM template deployments have no per-deployment metered charge from Microsoft for the deployment operation itself. The cost lever is what resources your template creates and how you operate deployments over time. Framing IaC as a cost-control discipline is accurate: templates make spend visible, reviewable, and repeatable.

Cost riskHow Bicep/ARM practice reduces it
Accidental SKU upgradeWhat-if shows ~ on sku before apply; PR review catches P series in staging
Complete-mode data lossDeletes trigger re-create bills and downtime; incremental default avoids surprise -
Untagged resourcesvar tags applied in template feeds Cost Management allocation
Environment sprawlSame main.bicep + different .bicepparam keeps dev cheap and prod right-sized
Drift + emergency rebuildRedeploy from Git is faster than forensic portal archaeology

Knobs that change monthly spend include App Service Plan SKU, storage redundancy (LRS vs ZRS vs GRS), AKS node pool VM size, and SQL tier. Encode those as parameters with @allowed sets so a typo cannot jump from B1 to P3v3 silently. Use environment maps (envConfig[environment].appSku) like the lab template so prod and dev diverge deliberately, not accidentally.

What makes cost spike unexpectedly includes deploying to the wrong subscription (production parameters against a sandbox subscription that later bills centrally), enabling geo-redundant storage for dev workloads, and complete-mode deletes that force full reprovision with higher SKUs. Tag enforcement in Bicep is cheap insurance: a missing costCenter tag today becomes a finance escalation next quarter.

Finance and engineering should agree on which parameters are cost knobs in each template. Document them in module README files or metadata descriptions. During review, diff those parameters first. A change from Standard_LRS to Standard_GRS is rarely “just a one-liner” for monthly storage bills.

Hypothetical scenario: a pipeline promotes the production parameter file to a subscription that uses enterprise agreement discounts differently per region. The template deploys successfully. Cost Management shows a 40% month-over-month increase. Root cause is region and redundancy parameters, not a deployment failure. IaC did its job; parameter governance failed.


PatternWhen to use itWhy it worksScaling note
Parameter files per environmentSame architecture, different SKUs and regionsTemplate stays single source; CI swaps only .bicepparamAdd a fourth environment by adding one file, not cloning templates
Thin main.bicep + domain modulesNetworking, data, and compute change at different ratesReviewers scope diffs; modules compile independentlyPublish mature modules to br: registry when 3+ teams consume them
Mandatory what-if in CIEvery PR touching *.bicepSurfaces blast radius before mergeCache nothing that skips what-if on production paths
existing for shared platform resourcesHub VNet, central Key Vault, Log AnalyticsAvoids redeploying shared dependenciesDocument scope and RBAC; cross-RG references need reader rights
Pin API versionsLong-lived platformsPrevents surprise schema breaks on “latest” driftSchedule quarterly API version upgrades with test what-if
Deployment stacks with deny on prodStrict IaC-only estatesBlocks portal drift on managed resourcesPilot deny settings; keep break-glass runbook
Anti-patternWhat goes wrongWhy teams fall into itBetter alternative
Monolithic 2,000-line main.bicepReview fatigue; accidental cross-resource edits”It worked in the demo”Split modules by bounded context
Floating registry tag (:latest)Unreviewed SKU or security defaults change overnightFastest path to consume shared modulePin br:.../module:v1.4.2
Complete mode as “cleanup”Deletes databases not listed in templateMisunderstanding incremental vs completeIncremental + stack deleteResources policy
Skipping what-if on “tiny” changesStorage networking rules wiped with app SKU tweakLate-night incident deployWhat-if even for one-property diffs
Hardcoded production SKUs in templateStaging bills like prod; dev cannot shrink spendCopy-paste from prod exportenvConfig map or parameters
Portal hotfix without backfill to GitNext deployment overwrites or fights manual fixSpeed during incidentBackfill Bicep, then redeploy incrementally

Use these matrices when choosing tools and deployment modes. They complement the Bicep-vs-Terraform table earlier; they do not replace architecture review for multi-cloud estates.

IaC language: Bicep vs ARM JSON vs Terraform

Section titled “IaC language: Bicep vs ARM JSON vs Terraform”
CriterionBicepARM JSONTerraform (AzureRM)
Azure-only greenfieldPreferredLegacy maintenanceViable if org standard
Readability & modulesNative module, commentsVerbose linked templatesHCL modules + registry
State managementAzure holds deployed graphSameRemote state required
Day-0 new Azure APIsTypically immediate via BicepSame underlying ARMProvider lag possible
Multi-cloudNoNoYes
Security scan JSON-onlyaz bicep build → scan JSONDirectterraform plan JSON export

Choose Bicep when the team is Azure-native and wants transpiled ARM without maintaining JSON by hand. Choose ARM JSON when a regulated pipeline forbids .bicep in artifacts but allows compiled output. Choose Terraform when one pipeline must own AWS, Azure, and SaaS providers with shared state practices.

QuestionIf “yes” →If “no” →
Template lists every resource that must survive in the RG?Consider complete with what-ifStay incremental
Drift from portal must be pruned automatically?Stack + managed lifecycle, not ad hoc completeIncremental + backfill Git
Data services exist in the RG?Avoid complete unless template owns them explicitlyIncremental
Greenfield empty RG?Either mode works; still run what-if
flowchart TD
A[Need to deploy Bicep?] --> B{Template represents ALL resources in RG?}
B -->|No| C[Incremental mode + what-if]
B -->|Yes| D{Data services present?}
D -->|Yes| E[Complete only with what-if + approved change]
D -->|No| F[Complete acceptable with what-if]
C --> G{Need drift protection?}
G -->|Yes| H[Consider deployment stack + deny settings]
G -->|No| I[Modules + CI validate]

Module distribution: local vs registry vs template spec

Section titled “Module distribution: local vs registry vs template spec”
FactorLocal pathBicep registry (br:)Template spec
Team count1–2 teams in one repoMany teams / subscriptionsMixed dev + operator consumers
Version disciplineGit tags / foldersSemver in registryTemplate spec versions
Portal deployNoNoYes
CI complexityLowestPublish step to ACRaz ts create + RBAC

Good Bicep reads like a contract. Names are predictable. Lint rules catch unused parameters before CI. Validation runs on every pull request. The snippets below show habits that prevent common template regressions: centralized naming maps and analyzer rules enforced through bicepconfig.json.

Deployment identity, RBAC, and pipeline auth

Section titled “Deployment identity, RBAC, and pipeline auth”

Templates do not replace authorization. The identity running az deployment group create must have rights to create the resources declared. Common choices:

  • Human operatoraz login with a user that has Contributor on the RG (acceptable for labs only).
  • Pipeline service principal or federated workload identity — Scoped Contributor or custom roles on target RGs; subscription Owner is rarely justified.
  • Managed deployment scripts — Deployment scripts run PowerShell or CLI inside Azure; they need their own managed identity and role assignments created in template or prerequisite.

Role assignment in Azure CLI uses:

Terminal window
az role assignment create \
--assignee <principal-id-or-app-id> \
--role "Contributor" \
--scope /subscriptions/<sub-id>/resourceGroups/myRG

User-assigned managed identity creation is separate from system-assigned on resources:

Terminal window
az identity create -g myRG -n myPipelineIdentity -l eastus2

System-assigned identities are declared in the resource identity block (see AKS and Web App examples in certification modules). Match identity type to rotation and audit requirements.

Set explicit deployment names in CI so reruns are traceable:

Terminal window
az deployment group create \
--resource-group myRG \
--name "main-$(Build.BuildId)" \
--template-file main.bicep \
--parameters @parameters/staging.bicepparam

Resource Manager deployments are idempotent for unchanged templates. Re-running the same parameters often yields NoChange on many resources in what-if. That behavior is useful for drift detection jobs that only alert when diffs appear.

Consistent naming makes cross-module references and operations dashboards easier to navigate. Storage accounts, Key Vaults, and virtual networks should share an environment prefix. Azure naming rules still apply: storage account names are globally unique and lowercase; many resources have length limits. Centralize naming in a var naming = { ... } object so modules receive computed names instead of inventing their own patterns.

// Use consistent, descriptive resource names
var naming = {
storageAccount: 'st${replace(prefix, '-', '')}${uniqueString(resourceGroup().id)}'
appServicePlan: '${prefix}-asp'
appService: '${prefix}-app'
keyVault: '${prefix}-kv'
logAnalytics: '${prefix}-log'
vnet: '${prefix}-vnet'
}

Compile and lint before deploy. az bicep build catches syntax errors locally. Analyzer rules in bicepconfig.json flag unused parameters and insecure defaults. az deployment group validate confirms Resource Manager accepts the compiled template against your target subscription without creating resources.

Extend validation in mature pipelines with:

  • PSRule for Azure or organizational policy-as-code on compiled JSON
  • Resource Graph queries post-deploy to assert tags and SKUs match expectations
  • Cost estimation workflows (manual spreadsheet or FinOps tooling) when SKUs change in what-if

Linter rule secure-parameter-default blocking default values on secure params prevents accidentally committing param password string = 'changeme'. Treat linter warnings as merge blockers for platform repos.

Terminal window
# Build (compile) Bicep to ARM JSON
az bicep build --file main.bicep
# Lint check (via bicepconfig.json)
# Create bicepconfig.json for linting rules:
cat > bicepconfig.json << 'EOF'
{
"analyzers": {
"core": {
"rules": {
"no-unused-params": { "level": "warning" },
"no-unused-vars": { "level": "warning" },
"prefer-interpolation": { "level": "warning" },
"secure-parameter-default": { "level": "error" },
"explicit-values-for-loc-params": { "level": "warning" }
}
}
}
}
EOF
# Validate without deploying
az deployment group validate -g myRG -f main.bicep -p environment=staging

You started this module with ARM JSON as the foundation and Bicep as the authoring layer teams prefer for new Azure work. The through-line is simple: Resource Manager executes templates; your job is to make templates readable, modular, reviewable, and safe to apply.

Authoring — Parameters express environment variance. Variables centralize naming and tags. Resources declare desired state with pinned API versions. Modules encapsulate domains. Outputs integrate layers without copy-paste. Loops and conditions remove template duplication. existing references connect to shared platform resources without redeploying them.

Scope and reuse — Resource group scope covers most application resources. Subscription and management group scopes carry policies and guardrails. Local modules suit single-repo products. Registry modules scale security baselines. Template specs help operators who deploy from the portal under RBAC.

Safe apply — Validate and build in CI. Require what-if on shared environments. Default to incremental mode. Treat complete mode and deployment stack delete actions as high-risk operations with approvals. Correlate deployment names to Git commits for audits.

Cost — Deployments are not billed per template run; resources are. What-if catches SKU and redundancy changes. Tags in templates feed allocation. Parameter files prevent prod SKUs from landing in dev subscriptions by mistake.

Operations — Drift happens in incremental mode when portal changes are not backfilled to Git. Scheduled what-if jobs surface drift early. Nested module failures require child deployment investigation. Export/decompile bootstraps brownfield; refactor before production apply.

When you complete the hands-on lab, you will have practiced the same refactor sequence platform teams use: imperative CLI → modular Bicep → parameter files → what-if → incremental deploy → iterative what-if on child resource additions. That sequence is more valuable than memorizing every Bicep function. Functions are documented on Learn; workflow discipline wins interviews and on-call scenarios.

Cross-family reviewers will ask whether your templates teach why a scope or mode was chosen, not only how to run CLI commands. Carry that standard into Module 3.13 networking modules, where Application Gateway and WAF resources also belong in version-controlled templates rather than portal-only configuration.


  1. Bicep is a transparent abstraction over ARM templates. Every Bicep file compiles to a standard ARM JSON template. There is no “Bicep runtime” or “Bicep API”---Azure only sees ARM JSON. This means the important deployment artifact remains an ARM template, which reduces dependence on Bicep-specific tooling during deployment. You can even mix Bicep and ARM JSON in the same deployment using modules.

  2. .bicepparam files separate environment values from template logic. You declare using '../main.bicep' at the top of a parameter file and assign values there, so the same main.bicep deploys to dev, staging, and prod while CI swaps only the parameter file path.

  3. Before deploying a template, what-if gives you a preview of the changes Azure Resource Manager expects to make, which makes it a useful safety check similar in spirit to Terraform plan.

  4. Bicep supports user-defined types (since Bicep v0.12), allowing you to define structured parameter types. Instead of passing 8 separate parameters for a VM configuration, you define a vmConfig type with all properties, getting compile-time validation and IntelliSense. This moves Bicep closer to a full programming language while remaining declarative.

The Bicep linter runs during az bicep build and in the VS Code extension. Rules such as no-unused-params and adminusername-should-not-be-literal catch mistakes before deployment. Teams commit bicepconfig.json so every author shares the same severity levels.


Review Checklist (self-assessment before Module 3.13)

Section titled “Review Checklist (self-assessment before Module 3.13)”

Use this checklist after the lab. It mirrors what cross-family reviewers verify on Azure Essentials expansions.

  • Can you draw the four deployment scopes and name one resource type each scope commonly deploys?
  • Can you explain implicit versus explicit dependsOn with an example from your lab main.bicep?
  • Can you describe when incremental mode leaves portal drift in place versus when complete mode or stacks remove it?
  • Did you run what-if before both deploy steps in the lab and interpret at least one ~ or + line?
  • Are cost knobs (SKU, redundancy, region) parameterized rather than hardcoded in your modules?
  • Can you articulate when you would choose registry modules over local paths for a shared Redis baseline?

If any item is shaky, rerun Task 4 what-if against your lab resource group and read the deployment operations log for the nested module deployments. That five-minute investment prevents repeating the same debugging path in production.

Optional stretch goal: publish your lab storage module to a private registry in a non-production ACR and consume it with a br: reference in a second main.bicep. You will feel the version-pinning and RBAC differences immediately. Most enterprises adopt registry modules only after local modules stabilize, but practicing both paths clarifies the Decision Framework tables above.

Keep a personal cheat sheet of the CLI verbs you used: group create, deployment group what-if, deployment group create, deployment group validate, bicep build, and bicep decompile. Those six commands cover most Essentials labs and early production workflows. Add stack group create when you experiment with deployment stacks in a sandbox subscription. Record the resource group name and deployment name every time. Future you will need that correlation during incident review. The portal deployment history blade searches by deployment name, not by Git commit unless you encoded the commit in that name. This small habit separates ad hoc deploys from pipeline-driven deploys during audits and postmortems. Treat deployment names as operational metadata, not disposable labels, in every Azure environment.


MistakeWhy It HappensHow to Fix It
Using the portal to create resources instead of IaCThe portal feels faster for “quick” resourcesEvery resource should be in a Bicep template, even “temporary” ones. Portal-created resources are undocumented and unreproducible.
Hardcoding resource names and IDsIt works for the first environmentUse parameters for anything that changes between environments. Use uniqueString() for globally unique names.
Using Complete deployment mode in productionSomeone thought it would “clean up” unused resourcesPrefer Incremental mode (the default) in most production cases. Complete mode deletes resources not in the template, which can destroy databases and storage accounts.
Not running what-if before deploying”The template worked last time”Make what-if a mandatory CI/CD step. A 30-second preview prevents hours of incident response.
Writing one massive Bicep fileThe template “works” at firstBreak large templates into modules. Each module should represent a logical unit (networking, compute, storage).
Not using parameter files for different environmentsDevelopers hardcode environment-specific valuesCreate parameter files per environment (dev.bicepparam, staging.bicepparam, prod.bicepparam). The template stays the same; only parameters change.
Not tagging resources in templatesTags feel like busyworkDefine tags as a variable and apply them to all resources. Tags are essential for cost allocation, ownership tracking, and automation.
Ignoring the API version in resource definitions”Latest” seems like the safest choicePin API versions explicitly. Newer API versions can change resource property formats, breaking existing templates. Update API versions deliberately, not accidentally.

1. Your security team uses a static analysis tool that only natively supports scanning JSON files. They mandate that Bicep cannot be used because their tool cannot parse `.bicep` files. How do you integrate Bicep into this pipeline constraint without abandoning the language?

Bicep is not a separate runtime or engine; it is a domain-specific language that transpiles directly into standard ARM JSON templates. Because Bicep acts as a transparent abstraction, you can simply add a build step to your CI pipeline (az bicep build) that compiles the .bicep files into .json before the security scan runs. The security team’s tool will scan the transpiled JSON exactly as if you had written the ARM templates by hand. This completely satisfies the JSON-only constraint while still allowing developers to benefit from Bicep’s readable syntax, modularity, and strong compile-time validation.

2. A junior engineer deploys a Bicep template containing only an App Service to a resource group that currently hosts your production SQL database. They accidentally append `--mode Complete` to the deployment command. What exactly happens to the SQL database, and why does this mode exist if it is so dangerous?

The production SQL database will be permanently deleted. Complete mode instructs the Azure Resource Manager to make the resource group match the template exactly, meaning it will create the App Service and actively destroy any existing resources within the group that are not defined in the template. This mode exists because it provides a mechanism to enforce strict infrastructure-as-code parity, ensuring that no “drift” or manually created resources accumulate in a resource group over time. However, because of its highly destructive nature, Complete mode should usually be reserved for tightly controlled, automated pipelines where the template is intended to represent the entire environment, and ideally preceded by a what-if preview.

3. Your organization has three product teams (Frontend, Backend, Data) that all need to deploy standard Redis caches. Currently, they each copy-paste Bicep code, leading to wildly inconsistent TLS and firewall settings. How would you redesign this using Bicep modules to enforce security standards while letting teams deploy on their own?

You would author a centralized redis.bicep module that hardcodes the mandatory security configurations, such as enforcing minimum TLS versions and disabling public network access, while exposing configurable properties (like name, SKU, or location) via explicit parameters. This module would be hosted in a shared Bicep registry (like an Azure Container Registry) accessible to all teams across the organization. The individual teams would then write their own main.bicep files and invoke your centralized module using the module keyword, passing in only their specific business parameters. This approach enforces enterprise-wide security compliance seamlessly because teams are forced to consume a hardened, approved infrastructure block rather than maintaining their own potentially flawed implementations.

4. During a late-night incident, a developer modifies a Bicep template to scale up an App Service Plan. They bypass the CI pipeline and run `az deployment group create` directly from their laptop. The deployment succeeds, but a completely unrelated Storage Account defined in the exact same template loses all its custom networking rules. How would running a `what-if` command have prevented this specific outage?

Running the what-if operation before deployment would have output a visual preview showing the exact modifications Resource Manager was about to apply across the entire target scope. It would have highlighted the Storage Account’s networking rules with a modification (~) or deletion (-) symbol in the output console. This immediate visual feedback would have alerted the developer that their supposedly isolated App Service change was going to inadvertently wipe out the storage networking configuration due to template drift on their local machine. By exposing the unintended blast radius of the deployment, what-if transforms infrastructure changes from a blind execution into a verifiable plan, acting as a critical safety check to catch destructive side-effects.

5. You need to deploy the exact same Virtual Network architecture to Dev, Staging, and Production environments. However, Dev requires cheaper B-series VMs while Production needs P-series VMs with availability zone redundancy. How do you structure your Bicep project to achieve this without maintaining three completely separate template files?

You construct a single, unified main.bicep template that utilizes parameters to represent all the variables that change between environments, such as vmSku and redundancyMode. You then create three distinct parameter files (dev.bicepparam, staging.bicepparam, prod.bicepparam) that supply the environment-specific values to the main template. When deploying, your CI/CD pipeline references the exact same main.bicep file but injects the corresponding .bicepparam file based on the target deployment stage. This guarantees that all environments share an identical architectural foundation while remaining appropriately sized and cost-optimized, significantly reducing maintenance overhead and eliminating structural configuration drift.

6. You are tasked with deploying an Azure Policy that restricts resource creation exclusively to the `eastus` region. You want this policy to automatically apply to all current and future resource groups within your project's subscription. Which deployment scope must you target in your Bicep file, and why would targeting the default Resource Group scope fail to achieve your goal?

You must explicitly define targetScope = 'subscription' at the top of your Bicep file to deploy the policy at the Subscription scope. Targeting the default Resource Group scope would fail because a policy applied at the resource group level only restricts resources in that group. Any new resource group created elsewhere would be unaffected. By targeting the subscription, the policy cascades downward and enforces the region constraint across current and future resource groups. Hierarchical inheritance is the primary reason Bicep supports distinct deployment scopes.

7. Your pipeline compiles Bicep to JSON for a security scanner, then deploys with `az deployment group create` using the `.bicep` file directly. A reviewer asks why you do not deploy the JSON artifact. What is the correct explanation, and does Azure ever execute Bicep natively?

Azure Resource Manager always executes ARM JSON templates. Bicep is an authoring layer that transpiles to that JSON. Deploying with --template-file main.bicep is valid because the CLI compiles Bicep before submission. Checking in compiled JSON satisfies scanners without changing the runtime engine. There is no separate Bicep runtime in Azure. The deployment history still records an ARM template deployment. Keeping Bicep as the source of truth while emitting JSON for policy gates is a common enterprise pattern.

8. Scenario: a platform team publishes a hardened storage module to a private registry. A product team references `br:contoso.azurecr.io/bicep/storage:latest` and production storage suddenly enables geo-redundant SKU after an unpinned registry publish. What two practices prevent this class of cost and compliance surprise?

Pin an explicit semantic version in the br: reference instead of a floating tag. Treat registry publishes like application releases with changelog review and CI what-if on consumer templates. Require module consumers to run az deployment group what-if when module version bumps merge. Registry modules are powerful because they centralize security defaults. They are risky when versions float because every team inherits changes without a PR in their own repository.


Hands-On Exercise: Refactor CLI Script to Bicep Template

Section titled “Hands-On Exercise: Refactor CLI Script to Bicep Template”

In this exercise, you will take a deployment that was done via Azure CLI commands and convert it into a reusable Bicep template with modules. You will run what-if to preview changes before applying them incrementally. The narrative mirrors how platform teams migrate click-ops or shell scripts: capture imperative steps, factor shared resources into modules, parameterize environment variance, then gate production on what-if output.

Pay attention to why each task exists. Task 1 isolates storage so SKU and TLS policies are reviewable without App Service noise. Task 2 isolates compute so runtime and Always On rules stay in one module. Task 3 composes modules and shows environment maps instead of copy-pasted SKUs. Task 4 trains the what-if habit before spend. Task 5 applies incrementally. Task 6 shows how nested child resources (blob containers) change diffs.

Prerequisites: Azure CLI with the Bicep extension installed (az bicep install) and permission to create a throwaway resource group in a subscription you use for labs. Run az group create before deployments; most resource operations assume an existing resource group per Azure Resource Manager deployment flow.

The Original CLI Script (What We Are Replacing)

Section titled “The Original CLI Script (What We Are Replacing)”
Terminal window
# This is the "click-ops" approach we are replacing:
az group create -n myapp-staging -l eastus2
az storage account create -n myappstagingstore -g myapp-staging -l eastus2 --sku Standard_LRS --kind StorageV2
az appservice plan create -n myapp-staging-plan -g myapp-staging -l eastus2 --sku B1 --is-linux
az webapp create -n myapp-staging-web -g myapp-staging -p myapp-staging-plan --runtime "NODE:20-lts"

Task 1: Create the Bicep Module for Storage

Section titled “Task 1: Create the Bicep Module for Storage”
Terminal window
mkdir -p /tmp/bicep-lab/modules
Terminal window
cat > /tmp/bicep-lab/modules/storage.bicep << 'BICEP'
@description('Storage account name (must be globally unique)')
param name string
@description('Azure region')
param location string
@description('Storage SKU')
@allowed(['Standard_LRS', 'Standard_ZRS', 'Standard_GRS'])
param skuName string = 'Standard_LRS'
@description('Resource tags')
param tags object = {}
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: name
location: location
tags: tags
sku: {
name: skuName
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
supportsHttpsTrafficOnly: true
}
}
output id string = storageAccount.id
output name string = storageAccount.name
output primaryEndpoint string = storageAccount.properties.primaryEndpoints.blob
BICEP
Verify Task 1
Terminal window
az bicep build --file /tmp/bicep-lab/modules/storage.bicep
echo "Build successful if no errors above"

Task 2: Create the Bicep Module for App Service

Section titled “Task 2: Create the Bicep Module for App Service”
Terminal window
cat > /tmp/bicep-lab/modules/appservice.bicep << 'BICEP'
@description('App Service Plan name')
param planName string
@description('Web App name')
param appName string
@description('Azure region')
param location string
@description('App Service Plan SKU')
@allowed(['B1', 'B2', 'S1', 'P1v3', 'P2v3'])
param skuName string = 'B1'
@description('Runtime stack')
param runtimeStack string = 'NODE:20-lts'
@description('Resource tags')
param tags object = {}
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: planName
location: location
tags: tags
sku: {
name: skuName
}
kind: 'linux'
properties: {
reserved: true
}
}
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: appName
location: location
tags: tags
properties: {
serverFarmId: appServicePlan.id
siteConfig: {
linuxFxVersion: runtimeStack
alwaysOn: skuName != 'B1' // AlwaysOn not available on Basic
ftpsState: 'Disabled'
minTlsVersion: '1.2'
}
httpsOnly: true
}
}
output appServicePlanId string = appServicePlan.id
output webAppName string = webApp.name
output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
BICEP
Verify Task 2
Terminal window
az bicep build --file /tmp/bicep-lab/modules/appservice.bicep
echo "Build successful if no errors above"
Terminal window
cat > /tmp/bicep-lab/main.bicep << 'BICEP'
// main.bicep - Complete environment deployment
@description('Environment name')
@allowed(['dev', 'staging', 'prod'])
param environment string
@description('Azure region')
param location string = resourceGroup().location
@description('Base name for resources')
param baseName string = 'kubedojo'
@description('Deployment timestamp tag')
param deployedAt string = utcNow('yyyy-MM-dd')
// Computed values
var prefix = '${baseName}-${environment}'
var tags = {
environment: environment
project: baseName
managedBy: 'bicep'
deployedAt: deployedAt
}
// Environment-specific configuration
var envConfig = {
dev: {
storageSku: 'Standard_LRS'
appSku: 'B1'
}
staging: {
storageSku: 'Standard_LRS'
appSku: 'B1'
}
prod: {
storageSku: 'Standard_ZRS'
appSku: 'P1v3'
}
}
// Deploy storage account
module storage 'modules/storage.bicep' = {
name: 'storage-${environment}'
params: {
name: take('${replace(prefix, '-', '')}st${uniqueString(resourceGroup().id)}', 24)
location: location
skuName: envConfig[environment].storageSku
tags: tags
}
}
// Deploy App Service
module appService 'modules/appservice.bicep' = {
name: 'appservice-${environment}'
params: {
planName: '${prefix}-plan-${uniqueString(resourceGroup().id)}'
appName: '${prefix}-web-${uniqueString(resourceGroup().id)}'
location: location
skuName: envConfig[environment].appSku
tags: tags
}
}
// Outputs
output storageAccountName string = storage.outputs.name
output storageEndpoint string = storage.outputs.primaryEndpoint
output webAppUrl string = appService.outputs.webAppUrl
output environment string = environment
BICEP
Verify Task 3
Terminal window
az bicep build --file /tmp/bicep-lab/main.bicep
echo "Build successful if no errors above"
Terminal window
RG="kubedojo-bicep-lab"
az group create --name "$RG" --location eastus2
# Preview what will be created
az deployment group what-if \
--resource-group "$RG" \
--template-file /tmp/bicep-lab/main.bicep \
--parameters environment=staging
Verify Task 4

You should see green + symbols indicating resources that will be created: a storage account, an App Service Plan, and a Web App. No resources should show as modified or deleted since this is a fresh deployment.

Terminal window
az deployment group create \
--resource-group "$RG" \
--template-file /tmp/bicep-lab/main.bicep \
--parameters environment=staging \
--query '{Outputs: properties.outputs, State: properties.provisioningState}' -o json
Verify Task 5
Terminal window
# Verify all resources were created
az resource list -g "$RG" --query '[].{Name:name, Type:type}' -o table
# Test the web app
WEB_URL=$(az deployment group show -g "$RG" -n main \
--query 'properties.outputs.webAppUrl.value' -o tsv 2>/dev/null)
echo "Web App URL: $WEB_URL"
curl -s "$WEB_URL" | head -5

You should see a storage account, an App Service Plan, and a Web App.

Task 6: Modify and Redeploy (See What-If in Action)

Section titled “Task 6: Modify and Redeploy (See What-If in Action)”
Terminal window
# Change the App Service SKU from B1 to B2
# Instead of editing the template, just pass a different parameter
# (In real life, you'd update the envConfig or add a parameter)
# Let's add a blob container to the storage account by updating the module
cat >> /tmp/bicep-lab/modules/storage.bicep << 'BICEP'
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = {
parent: storageAccount
name: 'default'
}
resource uploadsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = {
parent: blobService
name: 'uploads'
properties: {
publicAccess: 'None'
}
}
BICEP
# Preview the change
az deployment group what-if \
--resource-group "$RG" \
--template-file /tmp/bicep-lab/main.bicep \
--parameters environment=staging
# Deploy the change
az deployment group create \
--resource-group "$RG" \
--template-file /tmp/bicep-lab/main.bicep \
--parameters environment=staging
Verify Task 6

The what-if should show the storage account as unchanged (*) and the blob service + container as new (+). After deployment, verify:

Terminal window
az storage container list \
--account-name "$(az storage account list -g "$RG" --query '[0].name' -o tsv)" \
--auth-mode login --query '[].name' -o tsv

You should see the uploads container.

Terminal window
az group delete --name "$RG" --yes --no-wait
rm -rf /tmp/bicep-lab
  • Storage module created and compiles successfully
  • App Service module created and compiles successfully
  • Main template uses modules with environment-specific configuration
  • What-if previewed changes before first deployment
  • Initial deployment created storage account, App Service Plan, and Web App
  • Template modification (adding blob container) previewed and deployed incrementally

Module 3.13: Azure Application Gateway — Operator Path --- Extend your Azure networking skills with WAF policies, TLS termination, AKS ingress integration, and diagnostics for application-layer load balancing.

The skills you have built across the Azure Essentials modules—identity, networking, compute, storage, DNS, containers, serverless, secrets, monitoring, CI/CD, and infrastructure as code—are the foundation of every production Azure environment. The difference between a junior and senior engineer is not knowing more services. It is knowing how to combine these fundamentals into reliable, secure, and cost-effective architectures. Bicep is the glue that keeps those combinations reproducible when subscriptions, regions, or teams change.

Before moving on, confirm you can explain incremental versus complete mode to a teammate without looking at notes. Confirm you can run what-if and interpret + and ~ lines on a real lab resource group. Those two habits prevent more production pain than any single Bicep language feature.