Cloud & DevOps

Azure Bicep Infrastructure as Code: From Your First Resource to Safe Production Deployments

Afzal AhmedFaz Ahmed
·28 July 2026·43 min read
Azure BicepInfrastructure as CodeAzure Resource ManagerAzure CLIAzure PowerShellGitHub ActionsAzure DevOpsManaged IdentityAzure Verified Modules

Why This Matters

My book-backed study notes and practical exercises for understanding Azure Bicep, from first resources through modules, managed identity, what-if, CI/CD and production governance.

Azure Bicep Infrastructure as Code: From Your First Resource to Safe Production Deployments

Imagine that you have built a .NET application and somebody asks, “What does it need in Azure?” You answer: an App Service, a hosting plan, Application Insights, Key Vault, a storage account, SQL Database, networking, permissions and perhaps a queue. The next question is more difficult: can another developer create the same environment reliably without following a twenty-page manual and clicking through the Azure portal?

That is the problem Infrastructure as Code solves. Azure Bicep lets us describe Azure infrastructure in readable text files, place those files in source control, review changes and deploy the same design repeatedly. This article will teach that journey from first principles. You do not need to know ARM templates, DevOps or Bicep already. We will build the mental model before the syntax, then progress from one storage account to reusable modules and guarded delivery pipelines.

The learning path is inspired by Yaser Adel Mehraban’s *Infrastructure as Code with Azure Bicep*. The book provides the foundation: why Bicep exists, tooling, resources, parameters, expressions, modules, outputs, local deployment, Azure DevOps, GitHub Actions and maintainability. Because Azure evolves quickly, current commands and newer capabilities have also been checked against Microsoft’s current Bicep documentation.

The objective is not to memorise keywords. By the end, you should understand what Azure Resource Manager does, how Bicep describes desired state, how dependencies are inferred, how modules create boundaries, how a pipeline validates change, and how to reason safely about production infrastructure.

1. The problem with building infrastructure by hand

Creating a resource in the Azure portal can be useful while learning. You choose a subscription, resource group, region and settings, then press Create. The problem appears when you need a second environment.

Can you remember every option you chose for development? Did production receive the same TLS settings? Was public network access disabled? Which tags were applied? Who changed the configuration three months later? If the environment is damaged, how long would rebuilding it take?

Manual provisioning creates several risks:

  • inconsistency - environments that should match slowly drift apart;
  • poor repeatability - rebuilding depends on somebody’s memory;
  • weak review - a portal change may never pass through peer review;
  • limited history - the team cannot easily see why configuration changed;
  • slow delivery - people repeat the same sequence for every environment;
  • recovery risk - infrastructure cannot be recreated confidently after failure.
Infrastructure as Code, normally shortened to IaC, treats infrastructure definitions like application code. You write files, keep them in Git, open pull requests, validate them automatically and deploy them through controlled pipelines.

IaC does not make infrastructure simple. Networks, identity and databases still require careful design. It makes the design visible and repeatable.

Imperative and declarative thinking

An imperative script says how to perform a task:

Create a resource group.
Create a storage account.
If creation fails, retry.
Find the storage account ID.
Apply a diagnostic setting.

A declarative file describes the result you want:

There should be a storage account with these properties.
It should send diagnostics to this workspace.

Bicep is declarative. Azure Resource Manager compares your declaration with Azure’s current state and performs the operations needed to move toward the desired state. You concentrate on what should exist, while the platform handles much of the orchestration.

This distinction is central. Bicep is not a general-purpose programming language for writing applications. It is a domain-specific language for defining Azure resources.

2. Azure Resource Manager and ARM templates

Before Bicep, we need to meet the engine beneath it: Azure Resource Manager, or ARM.

When you ask Azure to create a resource, the request passes through ARM. ARM handles authentication, authorization, policy, locks, tags, deployment history and resource organisation. A virtual network, web app or Key Vault is provided by an Azure resource provider, while ARM coordinates the request.

Azure supports JSON-based ARM templates. A template declares resources, parameters, variables, expressions and outputs. ARM templates are powerful and remain the deployment format understood by Azure. However, large JSON templates can become noisy. JSON needs quotation marks, commas and verbose expression syntax. It is easy for the business intent to disappear inside ceremony.

Bicep is a more concise authoring language. The Bicep compiler converts a .bicep file into an ARM JSON template. Azure still receives the ARM representation.

main.bicep
   ↓ Bicep build/CLI
ARM JSON template
   ↓ deployment request
Azure Resource Manager
   ↓
Azure resource providers

This has useful consequences:

  • Bicep supports Azure resource types and API versions through ARM;
  • there is no separate state file to maintain;
  • the output can be inspected as normal ARM JSON;
  • existing governance through Azure Policy and role-based access still applies;
  • Bicep is an authoring improvement, not a replacement control plane.

Bicep compared with Terraform

Beginners often ask which tool is “better.” That is the wrong first question. Ask which problem and operating model you have.

Bicep is Azure-native, understands Azure resource schemas quickly, integrates closely with ARM and needs no external state file. Terraform is multi-cloud and has a broad provider ecosystem, but it maintains state that teams must protect and coordinate. Pulumi lets teams use general-purpose languages. Each choice has trade-offs in platform reach, team knowledge, state, governance and tooling.

If your infrastructure is predominantly Azure and your team wants native integration, Bicep is a strong default. If you require a consistent abstraction across several clouds, another tool may suit the organisational problem better.

3. Install a safe authoring environment

The easiest current setup is Visual Studio Code with Microsoft’s Bicep extension and the Azure CLI. The extension provides syntax highlighting, IntelliSense, type information, navigation, diagnostics, formatting and visualisation. Azure CLI automatically manages its own Bicep CLI instance when a Bicep command needs it.

Verify the tools:

az --version
az bicep version

Upgrade Azure CLI’s Bicep installation when appropriate:

az bicep upgrade

If you install the standalone Bicep CLI, commands use bicep build, bicep lint and similar forms. Through Azure CLI, they use az bicep build, az bicep version and so on. Azure PowerShell does not automatically reuse Azure CLI’s private Bicep installation, so install the standalone CLI when your PowerShell workflow requires it. Microsoft’s installation guide documents the current options.

Sign in for local experiments:

az login
az account show
az account set --subscription "My Development Subscription"

Always confirm the selected subscription before deploying. Many costly mistakes are not syntax errors; they are correct deployments sent to the wrong subscription.

Create a learning folder

infrastructure/
  main.bicep
  main.dev.bicepparam
  main.prod.bicepparam
  bicepconfig.json
  modules/

Do not commit passwords, connection strings, private keys or client secrets. Parameter files are convenient, but they are still source files. Use managed identity and a secret store such as Key Vault instead of placing secrets in Git.

4. Your first Bicep resource

Create main.bicep:

targetScope = 'resourceGroup'

param location string = resourceGroup().location
param storageAccountName string

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    accessTier: 'Hot'
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
  }
}

output storageAccountId string = storage.id

Let us slow down and read every part.

targetScope says this file is deployed at resource-group scope. param declares information supplied from outside the template. A default location uses the current resource group’s region. The storage name has no default because Azure storage account names must be globally unique.

The resource declaration contains three identifiers:

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01'
  • storage is the symbolic name used inside this Bicep file;
  • Microsoft.Storage/storageAccounts is the Azure resource type;
  • 2023-05-01 is the resource API version.
The symbolic name is not the deployed resource name. It is a compile-time handle. storage.id asks Bicep for the deployed resource’s Azure ID without manually constructing a long string.

The body defines common properties such as name and location, then resource-specific properties. IntelliSense helps because Bicep knows the schema for the selected API version.

Finally, an output exposes the storage account ID after deployment. Outputs are useful for pipelines or parent modules, but should not expose secrets.

Compile without deploying

az bicep build --file main.bicep

This creates ARM JSON and catches syntax/type problems. Inspect the JSON once. You do not normally maintain it, but seeing the compilation result strengthens your mental model.

5. Names, types and interpolation

Bicep supports strings, integers, booleans, arrays and objects. Modern Bicep also supports richer type declarations, but the core types are enough to begin.

param environmentName string
param enableZoneRedundancy bool = false
param retentionDays int = 30

var commonTags = {
  environment: environmentName
  managedBy: 'bicep'
  system: 'customer-portal'
}

var appName = 'customer-${environmentName}'

${...} interpolates an expression into a string. Variables calculate internal values; parameters are part of the template’s external contract.

A healthy rule is:

  • use a parameter when the caller should choose;
  • use a variable when the template should derive;
  • use a literal when the value is an intentional standard.
Do not parameterise every property. If every caller must understand every Azure switch, your module provides little abstraction. A platform module might deliberately enforce TLS, disable public blob access and apply standard tags while allowing callers to choose only name, region and capacity.

Decorators make contracts clearer

@description('Deployment environment used for naming and tags.')
@allowed([
  'dev'
  'test'
  'prod'
])
param environmentName string

@minValue(1)
@maxValue(100)
param instanceCount int = 1

@secure()
param legacySecret string

Decorators add metadata or constraints. @secure() prevents a value being recorded in normal deployment history outputs, but it does not make committing a secret safe. Prefer secret references and managed identities.

6. Parameter files and environment differences

Development and production often share architecture but use different capacity, names and retention. Do not copy the entire template for each environment. Copying creates drift because a later security improvement may reach one file but not another.

A .bicepparam file connects environment values to one template:

using './main.bicep'

param environmentName = 'dev'
param storageAccountName = 'stcustomerdev001'
param retentionDays = 7

Production can use another file:

using './main.bicep'

param environmentName = 'prod'
param storageAccountName = 'stcustomerprod001'
param retentionDays = 90

The compiler checks names and types against main.bicep. This is stronger than an unrelated JSON parameter file.

Keep genuine environmental differences in parameter files. Keep organisation-wide standards in modules or policy. Keep secrets outside both.

7. Dependencies: let references tell the story

Resources depend on one another. A web app needs its hosting plan ID. A diagnostic setting needs the workspace ID. Bicep usually infers deployment order from symbolic references.

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: 'plan-${environmentName}'
  location: location
  sku: {
    name: 'P0v3'
  }
}

resource app 'Microsoft.Web/sites@2023-12-01' = {
  name: 'app-${environmentName}-${uniqueString(resourceGroup().id)}'
  location: location
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
  }
}

Because app references plan.id, Bicep knows the plan must exist first. Independent resources may deploy in parallel.

There is an explicit dependsOn property, but use it only when a real dependency is invisible in expressions. Unnecessary dependencies slow deployment and make the graph harder to understand.

Visual Studio Code can visualise the dependency graph. Use it when a template becomes complex. A surprising graph often reveals hidden coupling.

Reference existing resources

Not every resource should be created by the current template. A central platform team may already manage a Log Analytics workspace:

param monitoringResourceGroup string
param workspaceName string

resource monitoringRg 'Microsoft.Resources/resourceGroups@2024-03-01' existing = {
  name: monitoringResourceGroup
}

resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
  scope: monitoringRg
  name: workspaceName
}

existing tells Bicep to reference rather than deploy. Deployment fails if the resource cannot be found at the stated scope, which is preferable to silently targeting the wrong object.

8. Understand deployment scopes

Azure organises resources in a hierarchy:

Tenant
  └─ Management groups
       └─ Subscriptions
            └─ Resource groups
                 └─ Resources

Bicep deployments can target resource-group, subscription, management-group or tenant scope. Most application resources live in a resource group, but creating resource groups, assigning policy or configuring management groups requires a higher scope.

targetScope = 'subscription'

param location string
param resourceGroupName string

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
  name: resourceGroupName
  location: location
}

module application './modules/application.bicep' = {
  name: 'applicationDeployment'
  scope: rg
  params: {
    location: location
  }
}

The subscription-scope file creates a resource group, then deploys a module into it. The identity running the deployment needs permission at the relevant scope.

Choose the narrowest sensible deployment scope. A pipeline that only manages one resource group should not automatically receive Owner rights across a subscription.

9. Expressions and functions

Bicep expressions calculate values. Common functions expose deployment context or transform data:

var suffix = uniqueString(subscription().id, resourceGroup().id)
var safeEnvironment = toLower(environmentName)
var fullName = take('st${safeEnvironment}${suffix}', 24)

resourceGroup(), subscription() and tenant() return context. uniqueString() creates a deterministic hash from inputs. Deterministic means the same inputs return the same value; it does not mean globally unique under every imaginable combination.

Functions exist for strings, arrays, objects, dates, resources and deployments. Use them to express derivation, not to hide an unreadable program inside one line. If an expression requires prolonged decoding, assign intermediate variables with meaningful names.

Be careful with runtime values

Some values are known during compilation. Others exist only after Azure evaluates or creates a resource. This affects conditions, loops and outputs. The editor often warns when a value cannot be used in the requested phase.

The practical lesson is to rely on symbolic references and type checking instead of manually building resource IDs wherever possible.

10. Conditions: optional infrastructure

You may need a feature only in production. Bicep’s if expression can conditionally deploy a resource:

param environmentName string
param deployPrivateEndpoint bool = environmentName == 'prod'

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = if (deployPrivateEndpoint) {
  name: 'pe-storage-${environmentName}'
  location: location
  properties: {
    // subnet and private-link configuration
  }
}

Conditions are useful, but a template with dozens of switches becomes difficult to reason about and test. If two environments have fundamentally different architectures, separate compositions may be clearer than one universal file.

Also remember that a condition does not automatically cascade to child or dependent resources. Ensure every conditional dependency is handled safely.

11. Loops: repeat without copying

Suppose an application needs several storage containers. Copying a resource block creates maintenance risk. Use a loop:

param containers array = [
  'documents'
  'exports'
  'imports'
]

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
  parent: storage
  name: 'default'
}

resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = [for containerName in containers: {
  parent: blobService
  name: containerName
  properties: {
    publicAccess: 'None'
  }
}]

The parent relationship makes nesting explicit. Loops can use an item, an index, objects and conditions. They can also control batch size when Azure should not deploy every item concurrently.

Avoid using loops to hide unrelated resources inside one clever structure. Repetition with a shared shape is a good loop candidate; different business responsibilities deserve separate declarations or modules.

12. Modules: build understandable boundaries

A growing main.bicep can become as unmaintainable as a giant application class. A module is a Bicep file deployed by another Bicep file. It has parameters as inputs and outputs as results.

Create modules/storage.bicep:

param name string
param location string
param tags object = {}

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

output id string = storage.id
output name string = storage.name

Consume it from main.bicep:

module storage './modules/storage.bicep' = {
  name: 'storageDeployment'
  params: {
    name: storageAccountName
    location: location
    tags: commonTags
  }
}

Read the module like a function call. The parent supplies a small contract; the module owns secure defaults and resource details.

What makes a good module?

A good module represents a stable capability, has a focused contract, enforces sensible standards, hides accidental complexity and produces useful non-secret outputs. It should not expose every resource property without thought.

Module boundaries can follow platform capabilities: web hosting, monitoring, storage, private networking or a complete application workload. Choose a level that supports ownership and reuse.

Registries and Azure Verified Modules

Local modules work well inside one repository. Organisations can publish versioned modules to a private registry backed by Azure Container Registry. Bicep also supports public Azure Verified Modules, which are prebuilt modules aligned with Microsoft practices.

module storage 'br/public:avm/res/storage/storage-account:0.18.0' = {
  name: 'storageDeployment'
  params: {
    name: storageAccountName
  }
}

Pin an explicit version. A shared module is a dependency and should not change unexpectedly. Evaluate a module’s contract, permissions and generated resources rather than assuming “verified” means “correct for every organisation.”

13. Outputs connect deployments carefully

Outputs expose information after deployment:

output appServiceName string = app.name
output appServicePrincipalId string = app.identity.principalId
output applicationUrl string = 'https://${app.properties.defaultHostName}'

A parent can use a module output:

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(storage.id, web.outputs.principalId, 'blob-reader')
  scope: storage
  properties: {
    principalId: web.outputs.principalId
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      blobReaderRoleId)
  }
}

This reference creates a dependency from the role assignment to the web module.

Do not output storage keys, database passwords or secret values. Deployment outputs can be recorded in logs or history. Output identifiers and endpoints; let applications retrieve secrets through managed identity.

14. Managed identity: remove credentials from configuration

A common beginner design stores a connection secret in application settings. A stronger design gives the application a managed identity and grants that identity the minimum required role.

resource app 'Microsoft.Web/sites@2023-12-01' = {
  name: appName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: plan.id
    httpsOnly: true
  }
}

Azure creates an identity tied to the web app. A role assignment authorises it to read blobs, access Key Vault secrets or perform another narrow capability. The application uses Azure Identity libraries to obtain tokens without a stored password.

Separate identity from authorization:

  • managed identity establishes who the workload is;
  • role assignments establish what it may do;
  • resource firewall/network rules establish where access may originate.
Least privilege means granting only what is necessary at the narrowest useful scope. “Contributor on the subscription” is convenient and dangerous.

15. Compile, lint and format before deployment

Fast local feedback protects both developers and subscriptions.

az bicep format --file main.bicep
az bicep lint --file main.bicep
az bicep build --file main.bicep

Formatting produces consistent layout. The linter finds syntax problems and selected best-practice violations. Building confirms that Bicep can compile the template.

Configure the linter in bicepconfig.json:

{
  "analyzers": {
    "core": {
      "enabled": true,
      "rules": {
        "no-hardcoded-env-urls": { "level": "warning" },
        "outputs-should-not-contain-secrets": { "level": "error" },
        "use-secure-value-for-secure-inputs": { "level": "error" }
      }
    }
  }
}

Treat diagnostics deliberately. Do not disable a rule merely to make a pipeline green. Document why the exception is safe, scope it narrowly and revisit it.

Current Bicep also provides a local bicep snapshot capability for comparing a normalised representation of infrastructure logic. Snapshot testing and Azure what-if solve related but different problems: snapshot is local and detects template logic changes; what-if asks Azure to predict environmental changes.

16. Preview with what-if

Never make the first view of a production change the deployment itself. Azure Resource Manager’s what-if operation predicts additions, modifications, deletions and unchanged resources without applying them.

At resource-group scope:

az deployment group what-if \
  --resource-group rg-customer-prod \
  --template-file main.bicep \
  --parameters main.prod.bicepparam

Then deploy:

az deployment group create \
  --name customer-platform-20260728 \
  --resource-group rg-customer-prod \
  --template-file main.bicep \
  --parameters main.prod.bicepparam

The command family changes with scope: az deployment sub, mg or tenant for subscription, management group or tenant deployments.

Read what-if output critically. Some provider behaviour cannot be predicted perfectly, and defaults or unresolved expressions can create noise. What-if is a safety aid, not a proof. Combine it with review, testing, policy and controlled rollout.

Validation levels

A good delivery path progressively increases confidence:

format → lint → compile → static/security checks
       → ARM validation → what-if → approval → deployment
       → smoke test → monitoring

Each stage catches a different category of problem. Compilation cannot prove your account has permission. ARM validation cannot prove the application works after deployment. A successful deployment cannot prove users can complete their journey.

17. Deployment modes, idempotency and deletion

Idempotency means applying the same desired definition repeatedly should converge on the same result rather than creating random duplicates. Declarative resource names and ARM deployments support this model.

However, you must understand deletion semantics. Standard ARM deployment commonly uses incremental behaviour: resources in the template are created or updated, but removing a declaration does not necessarily delete the existing resource. This protects against surprising deletion but allows unmanaged resources to remain.

Historically, complete deployment mode could remove resources absent from a template, but broad deletion is risky. Azure Deployment Stacks provide a newer lifecycle-management model: a stack knows which resources it manages and can detach or delete resources that become unmanaged, with optional deny settings.

Do not enable deletion because it sounds tidy. Ask:

  • Is the resource stateful?
  • Is data backed up and restorable?
  • Could another workload share it?
  • Does the deployment identity have overly broad scope?
  • Is deletion visible in what-if and approval?
  • What does rollback mean after destructive change?
Infrastructure code deserves the same caution as a database migration.

18. Build a realistic .NET application environment

Let us compose a small production-minded platform:

Resource group
├─ Log Analytics workspace
├─ Application Insights
├─ App Service plan
├─ Linux web app with managed identity
├─ Storage account
├─ Key Vault
└─ Role assignments

The root file should describe composition rather than every low-level property:

targetScope = 'resourceGroup'

param environmentName string
param location string = resourceGroup().location

var suffix = uniqueString(subscription().id, resourceGroup().id)
var tags = {
  environment: environmentName
  workload: 'customer-api'
  managedBy: 'bicep'
}

module monitoring './modules/monitoring.bicep' = {
  name: 'monitoring'
  params: {
    location: location
    environmentName: environmentName
    tags: tags
  }
}

module storage './modules/storage.bicep' = {
  name: 'storage'
  params: {
    name: take('stcustomer${environmentName}${suffix}', 24)
    location: location
    tags: tags
  }
}

module web './modules/web-app.bicep' = {
  name: 'web'
  params: {
    appName: 'app-customer-${environmentName}-${suffix}'
    location: location
    environmentName: environmentName
    applicationInsightsConnectionString:
      monitoring.outputs.connectionString
    tags: tags
  }
}

Notice the story: create monitoring and storage, then web hosting that consumes monitoring configuration. A later role assignment connects the web identity to storage. Modules keep service detail out of the composition.

Configuration is not secret management

Values such as environment name, telemetry sampling percentage and feature flags may be normal configuration. Credentials and keys are secrets. Prefer identity-based connections. Where a secret remains unavoidable, store it in Key Vault and pass a secret URI or create a Key Vault reference rather than returning the value as an output.

Add diagnostics intentionally

Deploying Application Insights is not the same as observable software. Decide which resources emit diagnostic logs, where logs are retained, what metrics indicate health, and which alerts require action. Avoid enabling every category forever without cost review; observability also needs an information and retention design.

19. CI/CD with GitHub Actions

A deployment from a developer laptop is useful for learning but weak as a team production process. A pipeline provides repeatability, protected environments, logs and approval controls.

A simplified GitHub Actions workflow is:

name: infrastructure

on:
  pull_request:
    paths: ['infrastructure/**']
  push:
    branches: [main]
    paths: ['infrastructure/**']

permissions:
  id-token: write
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Build Bicep
        run: az bicep build --file infrastructure/main.bicep
      - name: Preview
        run: >-
          az deployment group what-if
          --resource-group rg-customer-prod
          --template-file infrastructure/main.bicep
          --parameters infrastructure/main.prod.bicepparam

For deployment, add a job triggered only on the protected branch, target a protected GitHub environment and run az deployment group create after review.

The identity configuration should use OpenID Connect/workload identity federation rather than a long-lived client secret. GitHub receives a short-lived token for the approved repository, branch or environment. This reduces secret rotation and leakage risk.

Pin actions according to your organisation’s supply-chain policy, keep permissions minimal and separate validation from production deployment. Microsoft maintains a current GitHub Actions Bicep guide.

Pull request behaviour

On a pull request, format, lint, compile, scan and generate a what-if result. Reviewers should see both the Bicep diff and predicted Azure impact. Do not deploy untrusted pull-request code using a privileged production identity.

On merge, require the protected environment’s approval, deploy the reviewed commit, run smoke tests and record the deployment name/commit SHA.

20. CI/CD with Azure DevOps

The same principles apply in Azure Pipelines:

trigger:
  branches:
    include: [main]
  paths:
    include: [infrastructure]

stages:
- stage: Validate
  jobs:
  - job: ValidateBicep
    pool:
      vmImage: ubuntu-latest
    steps:
    - checkout: self
    - task: AzureCLI@2
      inputs:
        azureSubscription: service-connection-production
        scriptType: bash
        scriptLocation: inlineScript
        inlineScript: |
          az bicep build --file infrastructure/main.bicep
          az deployment group what-if \
            --resource-group rg-customer-prod \
            --template-file infrastructure/main.bicep \
            --parameters infrastructure/main.prod.bicepparam

Azure DevOps environments can provide approvals and checks. Service connections should use workload identity federation where supported and should receive minimum RBAC scope.

Do not hide all logic inside YAML. Keep infrastructure meaning in Bicep, and let the pipeline orchestrate validation, approval and deployment.

21. Test infrastructure at several levels

Infrastructure tests answer different questions.

Static checks

  • Does Bicep compile?
  • Does the linter find unsafe patterns?
  • Are file names and module versions governed?
  • Does a security scanner flag public access or weak TLS?

Deployment validation

  • Does ARM accept resource types and properties?
  • Are policies satisfied?
  • Does the deployment identity have permission?
  • What changes does what-if predict?

Post-deployment tests

  • Does the application endpoint respond?
  • Is HTTPS enforced?
  • Can the managed identity access exactly the required resource?
  • Are public endpoints disabled where intended?
  • Do logs and alerts arrive?

Resilience and recovery tests

  • Can the environment be recreated in an empty resource group?
  • Can stateful data be restored?
  • Can a failed deployment be diagnosed and resumed?
  • Does a module upgrade preserve required data?
Testing a template only by reading it is like testing C# only through code review. Reading is valuable, but execution finds environmental truth.

22. Common beginner mistakes and how to think through them

Mistake: hard-coding names and regions everywhere

Derive consistent names centrally and parameterise genuine variation. Respect service naming constraints and global uniqueness.

Mistake: passing secrets as ordinary parameters

Use managed identity first, Key Vault references second, secure parameters only when unavoidable. Never output secrets.

Mistake: using dependsOn for every resource

Let symbolic references infer dependencies. Add an explicit dependency only when no reference expresses the real ordering.

Mistake: one enormous template

Split around stable capabilities and ownership. Keep a readable root composition.

Mistake: a tiny module for every resource

Excessive fragmentation makes navigation and versioning harder. A module should provide meaningful abstraction, not merely move five lines elsewhere.

Mistake: deploying without what-if

Compile success says syntax is valid. It says nothing about a destructive environmental change. Preview and review.

Mistake: assuming successful deployment means successful service

Run health and integration checks. An App Service can exist while the application cannot start.

Mistake: broad pipeline permissions

Scope identities narrowly. Separate platform-level pipelines from application resource-group pipelines.

Mistake: using one mutable module version

Pin versions and upgrade through review. Consumers need stability.

Mistake: copying production for development without cost awareness

Keep the same architecture where useful, but parameterise capacity, retention and redundancy deliberately. Security should not disappear merely because an environment is called dev.

Mistake: treating generated ARM JSON as source

Maintain Bicep. Generated files are build artefacts unless a specific downstream process requires them.

23. Maintainability and team governance

Infrastructure lives for years. The first deployment is a small part of its cost.

A maintainable repository might look like:

infrastructure/
├─ main.bicep
├─ main.dev.bicepparam
├─ main.test.bicepparam
├─ main.prod.bicepparam
├─ bicepconfig.json
├─ modules/
│  ├─ monitoring.bicep
│  ├─ storage.bicep
│  ├─ key-vault.bicep
│  └─ web-app.bicep
├─ tests/
└─ README.md

The README should explain prerequisites, target scopes, naming, deployment commands, expected outputs, module ownership, recovery concerns and links to runbooks.

Version control everything relevant

Review Bicep, parameter files, policy definitions, pipeline code, alert definitions and dashboards. Emergency portal changes sometimes happen, but reconcile them back into code promptly or the next deployment may reverse them.

Define ownership

Who approves network modules? Who can publish shared module versions? Who owns production parameter values? Who responds to alerts? Technology without ownership becomes abandoned machinery.

Upgrade API and module versions carefully

New Azure API versions can add capabilities or change defaults. Do not perform blind repository-wide replacement. Read the resource documentation, inspect the diff, run what-if in a lower environment and validate behaviour.

Use policy as a guardrail

Bicep expresses intended resources; Azure Policy can deny or audit non-compliant configuration regardless of how it was created. Policies can require tags, allowed regions, private networking or diagnostic settings. Combine reusable modules for the paved road with policy for the safety boundary.

24. A production mentoring checklist

Before approving an infrastructure change, ask these questions.

Purpose

  • What user or operational outcome needs this resource?
  • Is an existing platform capability available?
  • Who owns and pays for it?

Security

  • Is authentication identity-based?
  • Are role assignments minimal in privilege and scope?
  • Is public network access necessary?
  • Are secrets absent from code, outputs and logs?
  • Do policy and locks protect critical resources?

Reliability

  • What are the service-level and recovery requirements?
  • Is zone or region redundancy needed?
  • What happens if deployment stops halfway?
  • Is stateful data backed up and restoration tested?

Performance and cost

  • Does the SKU match measured needs?
  • Can non-production capacity be lower safely?
  • Are log retention and data transfer costs understood?
  • Is automatic scaling bounded?

Delivery

  • Do format, lint and compile pass?
  • Has what-if been reviewed?
  • Are destructive operations explicit?
  • Is the production environment protected by approval?
  • Does the pipeline use federated identity and least privilege?

Operations

  • Are health signals, logs, metrics and alerts defined?
  • Is there a runbook and owner?
  • Can the team correlate a deployment with a commit?
  • Has the post-deployment smoke test passed?
The checklist is not paperwork for its own sake. Each question exposes a class of failure before users do.

25. A seven-stage practice project

The fastest way to learn Bicep is to build in safe increments.

Stage 1: one resource

Create a development resource group manually, then deploy a locked-down storage account with Bicep. Compile and inspect ARM JSON. Delete and recreate it.

Stage 2: parameters and outputs

Add a .bicepparam file, tags, allowed environments and a safe output. Try an invalid value and read the diagnostic.

Stage 3: dependencies

Add a Log Analytics workspace and diagnostic settings. Visualise the dependency graph. Remove unnecessary dependsOn entries.

Stage 4: modules

Move storage and monitoring into focused modules. Keep main.bicep as composition. Add another environment without copying modules.

Stage 5: a .NET workload

Deploy an App Service with managed identity and Application Insights. Grant the application access to storage without a key. Publish a tiny ASP.NET Core API and verify telemetry.

Stage 6: safe automation

Create a pull-request pipeline that formats, lints, compiles and runs what-if. Use workload identity federation. Protect production with an environment approval.

Stage 7: operational proof

Add smoke tests, alerts, budgets and restoration notes. Recreate a non-production environment from an empty resource group. Record weaknesses and improve the modules.

At every stage, explain aloud what ARM will do before running the command. If your prediction differs from the result, that gap is the learning opportunity.

26. How to read a Bicep file confidently

When reviewing unfamiliar infrastructure, do not start at every property. Use this order:

  1. Read targetScope to learn the blast radius.
  2. Read parameters to understand the external contract.
  3. Find secure inputs and verify secrets are handled safely.
  4. Read modules to see the architectural composition.
  5. Identify stateful and public resources.
  6. Trace symbolic references and role assignments.
  7. Read conditions and loops for environmental variation.
  8. Read outputs for information leaving the deployment.
  9. Inspect parameter files and pipeline identity.
  10. Review what-if before approving.
This is similar to reading application architecture before method bodies. Context makes details meaningful.

27. The deeper lessons

Azure Bicep teaches ideas that extend beyond its syntax.

Desired state is a contract. Your repository says what the platform should be. Drift is a mismatch to investigate.

Dependencies are architecture. A reference from a web module to monitoring or identity reveals how the system is connected.

Modules are team boundaries. Good modules encode secure defaults and allow consumers to move quickly without becoming experts in every Azure property.

Automation amplifies both quality and mistakes. A pipeline can reproduce a secure environment or delete the wrong resource consistently. Review, scope and preview determine which.

Security works best without secrets. Managed identity and federation reduce the burden of storing, distributing and rotating credentials.

A deployment is not an outcome. Users need a working, observable and recoverable service. Test beyond resource creation.

Infrastructure changes continuously. API versions, service capabilities, security guidance and costs evolve. Pin versions, review updates and maintain the code as a product.

28. Troubleshooting without guessing

When a deployment fails, resist changing several things at once. First identify which layer rejected the work.

A red underline in Visual Studio Code or a failed bicep build is usually an authoring problem: invalid syntax, an unknown property, a type mismatch or an unavailable resource schema. Read the complete diagnostic, including its BCP code. Check that the API version supports the property you used. IntelliSense can show the shape expected by that version.

An ARM validation failure occurs after compilation. The Bicep may be syntactically correct while Azure rejects its meaning. Common causes include an invalid name, an unavailable region/SKU combination, a missing required property or an Azure Policy denial. The deployment operation details normally identify the resource and provider message:

az deployment group show \
  --resource-group rg-customer-dev \
  --name my-deployment

az deployment operation group list \
  --resource-group rg-customer-dev \
  --name my-deployment \
  --output table

An authorization failure means the deployment identity lacks an action at the requested scope. Do not immediately grant Owner. Determine which resource operation failed, which identity the pipeline actually used, and the narrowest role/scope that supplies the required action. Remember that creating a role assignment itself requires special authorization permission.

A naming conflict often appears with globally named services such as storage accounts and web apps. Use deterministic suffixes, obey length and character restrictions, and do not solve collision by introducing a random value that changes on every deployment. Stable names are important for idempotency.

An unregistered provider can block a resource type in a subscription. Confirm provider status and register it through the organisation’s approved process:

az provider show --namespace Microsoft.Web --query registrationState

A deployment can be partially successful. ARM may create independent resources before another resource fails. This is not automatically corruption; declarative deployment is designed to be run again after the fault is corrected. Inspect deployment operations and current resources, fix the cause, run what-if again and redeploy. Avoid manually deleting successful stateful resources unless the recovery plan requires it.

If what-if repeatedly reports changes that do not appear meaningful, investigate provider defaults and properties Azure normalises after deployment. Do not simply stop reviewing the output. Reduce noise by declaring stable values where appropriate, using current tool versions and understanding documented what-if limitations.

For application failures after successful infrastructure deployment, move outward through the chain: resource health, application startup logs, configuration references, managed-identity role assignment, network/DNS reachability and dependency health. Infrastructure success proves that Azure accepted resources; it does not prove the deployed workload can use them.

Keep a short incident note when troubleshooting teaches a reusable lesson. Update the module, validation rule, test or runbook so the same failure becomes easier—or impossible—the next time. This is how an IaC platform improves: not by never failing, but by converting failures into durable guardrails.

29. Mentoring session: take a .NET API from an empty subscription to a reviewable environment

Let us connect the individual Bicep features through one realistic exercise. A junior developer has an ASP.NET Core API and has been asked to create a development environment containing an App Service plan, Linux web app, managed identity, storage account, Application Insights and Log Analytics workspace.

Junior: “I can create those resources in the portal and export the template. Would that be the quickest start?”

Senior: “It may help you discover provider properties, but exported templates often contain defaults and incidental details. Begin with the outcome and build the smallest intentional template you can explain.”

Establish scope and ownership

Before writing a resource declaration, decide:

  • which subscription and resource group own the workload;
  • whether the resource group already exists or belongs in a subscription-scope deployment;
  • which team owns cost, alerts and operational response;
  • which resources contain durable state;
  • which environments use separate subscriptions, resource groups or both;
  • which organisational policies constrain regions, SKUs, networking and tags;
  • which identity will preview and deploy the change.
For this exercise, assume the platform pipeline creates the resource group separately and the application deployment targets that resource group. That keeps the first template's blast radius narrow:
targetScope = 'resourceGroup'

@description('Short environment name used in resource naming and tags.')
@allowed([
  'dev'
  'test'
  'prod'
])
param environmentName string

@description('Azure region approved for this workload.')
param location string = resourceGroup().location

@description('Stable workload name without environment-specific suffixes.')
@minLength(3)
@maxLength(20)
param workloadName string

@description('Tags required by the organisation.')
param tags object

Parameters are the public API of the template. Do not expose every resource property “for flexibility.” Each parameter transfers a decision to callers and creates more combinations to support. Expose choices that genuinely vary by environment or consumer; encode secure defaults and organisational standards inside modules.

Junior: “Should httpsOnly be a Boolean parameter in case a developer needs HTTP?”

Senior: “No. If the platform requires HTTPS, making insecurity configurable weakens the contract. Parameters represent supported variation, not every property Azure accepts.”

Separate deterministic naming from hidden magic

Resource names should be stable across deployments and valid for each provider. Some resources require globally unique names, while others only need uniqueness inside a resource group. A shared naming module or function can encode constraints, but the generated name must remain predictable.

var compactWorkload = toLower(replace(workloadName, '-', ''))
var suffix = uniqueString(subscription().subscriptionId, resourceGroup().id, workloadName, environmentName)
var storageName = take('${compactWorkload}${environmentName}${suffix}', 24)
var webAppName = '${workloadName}-${environmentName}-${take(suffix, 6)}'

uniqueString is deterministic for the same inputs; it is not a random secret. Choose inputs that keep names stable when redeploying but distinct where required. Do not include a deployment timestamp unless you intentionally want a new resource each time.

Name generation also affects recovery. If deleting and recreating a non-production resource should produce the same name, deterministic inputs help. For globally named resources, a recently deleted name may remain unavailable for a period, so the recovery plan should acknowledge provider behaviour rather than assuming immediate reuse.

Build modules around capability

Avoid one module per Azure resource merely because modules exist. A module should provide a coherent capability or enforce a standard that consumers should not repeat. For example:

  • observability.bicep creates a workspace, Application Insights and diagnostic conventions;
  • storage.bicep creates a secured storage account with supported containers and logging;
  • web-api.bicep creates the plan, web app, identity, settings and health configuration.
The main file reads as composition:
module observability './modules/observability.bicep' = {
  params: {
    location: location
    workloadName: workloadName
    environmentName: environmentName
    tags: tags
  }
}

module storage './modules/storage.bicep' = {
  params: {
    name: storageName
    location: location
    tags: tags
  }
}

module api './modules/web-api.bicep' = {
  params: {
    name: webAppName
    location: location
    environmentName: environmentName
    applicationInsightsConnectionString: observability.outputs.connectionString
    storageAccountName: storage.outputs.name
    tags: tags
  }
}

Notice that the module declarations omit an explicit deployment name. Current Bicep guidance allows the nested deployment name to be generated, avoiding collisions when the same statically named module deployment runs concurrently at one scope. If your organisation names module deployments explicitly for operational reasons, ensure uniqueness and understand the concurrent-deployment risk rather than copying a fixed name everywhere.

Junior: “Should the storage module output the account key so the API can use it?”

Senior: “Prefer identity-based access. A key is a powerful reusable secret; an identity plus a narrow role gives us revocation, scope and auditability.”

The storage module can output its resource ID and name—neither is secret. The web app exposes a system-assigned identity. The composition layer creates the required role assignment.

Treat role assignment as part of the dependency graph

var storageBlobDataContributorRoleId = subscriptionResourceId(
  'Microsoft.Authorization/roleDefinitions',
  'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
)

resource blobRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(storage.outputs.id, api.outputs.principalId, storageBlobDataContributorRoleId)
  scope: resourceGroup(storage.outputs.resourceGroupName)
  properties: {
    principalId: api.outputs.principalId
    principalType: 'ServicePrincipal'
    roleDefinitionId: storageBlobDataContributorRoleId
  }
}

The exact scope expression should match where the storage resource is declared; if it is in the current resource group, a resource-scoped assignment is often narrower and clearer. The deterministic guid makes redeployment idempotent for the same principal, role and scope.

Role assignment can succeed before permissions have propagated everywhere. Application startup should not assume a newly assigned role is instantly usable. Deployment smoke tests may need bounded retry for known propagation delay, while still failing clearly if access never becomes available.

Do not respond to an authorization failure by granting Owner to the application or pipeline. Identify which identity made the call, which action was denied and the smallest suitable role at the narrowest practical scope. The identity that deploys role assignments also needs permission to create them; that is different from the role given to the workload.

Keep secrets out of outputs and ordinary parameters

App configuration can refer to Key Vault through managed identity or receive non-secret resource names. A @secure() parameter prevents its value appearing in normal deployment logs, but it is still an input that must come from somewhere. Prefer federation and managed identity to moving secrets through the pipeline.

Never output access keys, connection-string secrets or passwords. Outputs can be recorded in deployment history and pipeline logs. Even a secure source becomes exposed if it crosses an insecure output boundary.

Junior: “Application Insights gives us a connection string. Is every connection string a secret?”

Senior: “No. Judge the capability carried by the value. An Application Insights connection string identifies ingestion configuration but is not equivalent to a storage account key. Still handle configuration intentionally and follow current service guidance.”

Validate more than successful resource creation

After deployment, test the workload outcome:

  1. the web app starts with the expected runtime and deployment artifact;
  2. HTTPS responds on the health endpoint;
  3. the API authenticates to storage without an account key;
  4. telemetry reaches the intended Application Insights resource;
  5. logs contain the environment and deployment version;
  6. alerts and diagnostic destinations exist;
  7. the deployment identity cannot perform unrelated subscription administration;
  8. the application identity cannot access storage outside its intended scope.
A green ARM deployment proves Azure accepted the desired resources. It does not prove the application can start, DNS resolves, role assignment has propagated, private networking works or monitoring is useful.

30. Mentoring session: design the pull-request pipeline as a safety argument

Infrastructure pipeline stages should answer progressively stronger questions.

Stage A: can the repository be understood?

Run formatting checks, restore external modules, lint and compile. Compilation confirms that Bicep can translate the source; it does not contact Azure to prove provider behaviour.

az bicep restore --file infra/main.bicep
az bicep format --file infra/main.bicep
az bicep lint --file infra/main.bicep
az bicep build --file infra/main.bicep --stdout > compiled.json

In CI, use a check mode or verify that formatting creates no diff rather than silently modifying source and continuing. Pin or control the tool version so a developer and pipeline do not receive surprising diagnostics from unrelated upgrades. Review upgrades deliberately because linter defaults, schemas and language capabilities evolve.

The Bicep linter's default rules are intentionally not a complete organisational policy. Configure bicepconfig.json with rules that reflect your standards, then use Azure Policy for controls that must be enforced at the platform boundary.

Stage B: did template logic change unexpectedly?

Snapshot comparison can detect changes in a normalised compiled representation without an Azure connection. It is useful for module refactoring and generated resource logic. A reviewed snapshot says “this compiled intent changed as expected,” not “Azure will apply exactly this environmental change.”

Keep snapshot updates visible in the pull request. If every change regenerates a huge unreadable file that reviewers approve mechanically, redesign the snapshot scope rather than claiming protection.

Stage C: will Azure accept the deployment at this scope?

ARM validation checks more than local compilation because it runs against Azure with a real scope, policies and provider rules. It requires an identity and therefore raises security questions for untrusted pull requests.

Do not expose a privileged production identity to arbitrary code from a fork. Use protected workflow patterns, limited validation subscriptions or trusted branches according to the repository's threat model. Infrastructure code can request deployment scripts, role assignments and other powerful operations; treat pull-request execution as code execution.

Stage D: what environmental change does Azure predict?

Run what-if against the exact scope and parameter set intended for the environment:

az deployment group what-if \
  --resource-group rg-orders-test \
  --template-file infra/main.bicep \
  --parameters infra/environments/test.bicepparam \
  --no-pretty-print

Use a current Azure CLI or Az PowerShell version so recent what-if diagnostics and analysis behaviour are available. What-if does not mutate resources, but it requires read access plus deployment validation capabilities. Store its result as a review artifact and summarise meaningful additions, modifications and deletions.

Junior: “The what-if output contains lots of noise. Can we skip it until production?”

Senior: “Noise is a signal that our review process needs refinement. Understand provider defaults, unresolved expressions and declared values. Skipping the preview removes the chance to see a real deletion.”

What-if has documented limitations. It can report changes caused by provider-normalised defaults or values it cannot fully evaluate. Do not describe it as a guarantee. Combine it with module tests, policy, review, lower-environment deployment and post-deployment verification.

Stage E: is the change authorised to proceed?

Protect production with environment controls, reviewer ownership and separation of duties appropriate to the organisation. Approval should show:

  • source commit and immutable artifact;
  • target tenant, subscription, scope and environment;
  • parameter set;
  • what-if summary, especially deletions and replacements;
  • validation and test results;
  • rollout, monitoring and recovery notes;
  • identity being used.
An approval button without this context is theatre. Reviewers need enough information to make a risk decision.

Stage F: did deployment produce a healthy service?

After deployment, capture outputs that are safe, run smoke tests and observe health. Correlate the deployment name or ID with the source commit. For a staged release, deploy infrastructure compatibility before an application version depends on it. Avoid combining a destructive schema-like infrastructure change with an application release that cannot roll back independently.

Junior: “Bicep is declarative. Why do we need a rollback plan? Can't we deploy the previous file?”

Senior: “Sometimes, but stateful and provider operations are not source-control undo. Deleting a database from the file and then restoring the old declaration does not restore its data.”

Recovery might mean redeploying a previous template, restoring data, reattaching a resource, disabling a new route, rotating a credential or completing a forward fix. Name the strategy per resource class.

31. Deletion, deployment stacks and resource lifecycle

Incremental ARM deployments do not automatically delete every resource removed from a template. This can leave intentional external resources—or unmanaged drift. Azure deployment stacks provide a managed resource set and an actionOnUnmanage policy that can detach or delete resources removed from the stack.

That power deserves explicit review. Decide whether unmanaged resources should be:

  • detached and left in Azure;
  • deleted while retaining data-bearing resource groups;
  • deleted completely;
  • protected through deny settings.
The correct policy differs between an ephemeral test environment and a production data platform. Do not adopt stacks merely to obtain “automatic cleanup” without understanding resource ownership and data recovery.

Junior: “If a developer removes a storage module, should the stack delete the storage account?”

Senior: “Only if the ownership and lifecycle contract says removal from code authorises data deletion. For production state, that decision normally needs stronger protection.”

Use locks, policy, approvals, backups and stack behaviour as complementary controls. A resource lock can prevent a deployment from deleting something, but it can also block legitimate automation and emergency changes. Document who can remove it and how the action is audited.

For every stateful resource, record:

  1. source-of-truth owner;
  2. deletion behaviour when code changes;
  3. backup and retention policy;
  4. restore test evidence;
  5. regional recovery expectation;
  6. data migration and replacement procedure;
  7. approval required for destructive change.
Infrastructure as Code manages resource configuration; it does not remove the need for data lifecycle engineering.

32. Module publishing and supply-chain thinking

Once several teams reuse the same secure storage or web-app pattern, a private Bicep module registry can provide versioned modules through Azure Container Registry. Consumers receive a compiled module and can reference a tag through a registry alias.

Publishing should be a release process:

  • lint and compile the module;
  • test expected outputs and policy compliance;
  • document parameters, outputs and behaviour;
  • use an immutable versioning convention;
  • publish from a protected pipeline identity;
  • retain provenance from source commit to registry artifact;
  • announce breaking changes and migration guidance.
Current tooling can publish Bicep source alongside the compiled template, improving “Go to Definition” and review for consumers. Access to push and pull modules should use narrow ACR permissions. Private registry does not automatically mean private network access; configure networking according to the organisation's requirements.

Azure Verified Modules can reduce the need to build common resource modules from scratch. They are still dependencies. Pin a version, inspect the module contract and generated change, verify it fits policy, and plan upgrades. “Verified” does not mean “correct for every workload.”

Junior: “Why not reference latest so every deployment receives security improvements?”

Senior: “Because the same source commit could then deploy different infrastructure tomorrow. Deliberate version upgrades make change reviewable and reproducible.”

A shared module team should resist adding dozens of escape-hatch parameters. When every property is exposed, the module stops expressing a standard and becomes a thin, difficult-to-version copy of the provider schema. Support a coherent use case; allow advanced workloads to own a specialised module when their requirements genuinely differ.

33. Production troubleshooting clinic

Consider four failures and practise locating the responsible boundary.

Failure 1: the pipeline cannot restore a private module

Check the registry hostname, tag, alias resolution and the identity used by Bicep restore. Confirm the module exists and the identity can pull it. A developer may have a cached copy locally while the clean pipeline reveals a missing publication or permission. Force restore when testing a corrected remote module reference; understand that the local cache otherwise preserves a downloaded version.

Failure 2: role assignment deploys but the application receives 403

Verify the application's principal ID, assigned role ID and scope. Check token audience and whether the code is still using a connection string or developer credential. Allow bounded time for propagation, but do not mask a permanently wrong scope with indefinite retry. Use Azure activity and service diagnostics to distinguish assignment creation from data-plane authorization.

Failure 3: what-if predicts replacement or deletion of a stateful resource

Stop. Identify which property or name changed, whether the provider treats it as immutable, and what data is at risk. Compare compiled templates and parameters. Plan migration or a parallel resource if necessary. Do not approve on the assumption that declarative deployment will preserve data automatically.

Failure 4: the deployment succeeds but telemetry is empty

Check application startup, environment settings, connection configuration, sampling, network reachability and the time range being queried. Confirm that the smoke test executed a request and that the correct telemetry resource is open. Infrastructure existence, SDK configuration and observable application behaviour are three separate facts.

For each failure, finish with a durable improvement: a linter rule, module default, integration test, pipeline check, alert or runbook step. Troubleshooting becomes platform maturity when the next engineer encounters a clearer system.

34. Review questions and practical exercises

Explain to a junior developer

  1. Why does a Bicep symbolic reference create a dependency without dependsOn?
  2. Why are parameters a supported contract rather than a list of every property?
  3. What is the difference between local compilation, ARM validation and what-if?
  4. Why can a successful deployment still produce a broken application?
  5. How does managed identity reduce secret-management risk?
  6. Why must role-assignment scope be reviewed as carefully as the role name?
  7. Why can module deployment names cause trouble during concurrency?
  8. What does a deployment stack manage that an ordinary incremental deployment may not?
  9. Why should module versions be pinned?
  10. Which stateful changes cannot be safely “rolled back” by restoring yesterday's Bicep file?

Hands-on exercise

Build the API environment described above in a disposable subscription or sandbox approved for learning. Before every command, write your prediction. Then:

  1. compile and inspect the ARM JSON;
  2. introduce a linter failure and configure its severity;
  3. create a snapshot and review an intentional change;
  4. run validation and what-if;
  5. deploy through a least-privilege identity;
  6. verify managed-identity access to storage;
  7. remove a harmless resource and compare ordinary deployment behaviour with a documented stack experiment;
  8. simulate a failed secondary dependency and use deployment operations to diagnose it;
  9. restore the environment from source and parameter files;
  10. write a short recovery guide for the stateful resource.
The objective is not a screenshot of resources in the portal. It is evidence that you can predict, deploy, verify, diagnose and recover the environment through a controlled process.

35. Mentoring decision: should this be a deployment script?

Bicep can declare Azure resources through Resource Manager. Occasionally a required operation is not available declaratively, or a deployment needs to query or prepare something outside the normal resource graph. Azure deployment scripts can run Azure CLI or Azure PowerShell during deployment, but they should be an explicit exception rather than the first escape hatch.

Junior: “The portal performs an extra configuration step after creating the resource. Should I copy that command into a deployment script?”

Senior: “First ask whether the configuration has a resource-provider API, child resource, extension resource or newer API version that Bicep can declare. A script creates another imperative system we must secure, retry and observe.”

Use this decision order:

  1. Can a current resource or child-resource declaration express the desired state?
  2. Can an existing Bicep module encapsulate it?
  3. Can the application configure it safely at startup or through a separate operational process?
  4. Is a one-time migration more honest than pretending the step is desired-state deployment?
  5. If a deployment script remains appropriate, can it be idempotent, narrowly authorised and independently tested?
A script resource creates supporting execution infrastructure. Depending on configuration, that includes a storage account and container instance managed for the script. Network rules, identity permissions, retention and cost therefore matter. Do not regard scriptContent as a harmless inline helper.

Make the script safe to repeat

ARM may retry work, a failed deployment may be rerun, or an operator may intentionally force execution. The script should inspect current state and converge rather than blindly append or recreate.

resource maintenanceIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = {
  name: maintenanceIdentityName
}

resource configureExternalSetting 'Microsoft.Resources/deploymentScripts@2023-08-01' = {
  name: 'configure-external-setting'
  location: location
  kind: 'AzureCLI'
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${maintenanceIdentity.id}': {}
    }
  }
  properties: {
    azCliVersion: '2.75.0'
    timeout: 'PT15M'
    retentionInterval: 'P1D'
    cleanupPreference: 'OnSuccess'
    environmentVariables: [
      {
        name: 'TARGET_RESOURCE_ID'
        value: targetResourceId
      }
    ]
    scriptContent: '''
      set -euo pipefail

      current=$(az resource show --ids "$TARGET_RESOURCE_ID" --query "tags.configurationState" -o tsv)
      if [ "$current" = "configured" ]; then
        echo "Configuration already present; no change required."
        exit 0
      fi

      # Perform the narrowly scoped, repeatable operation here.
    '''
  }
}

The versions shown are examples to review and pin deliberately; do not copy them forever. The script avoids echoing credentials, fails on command errors and checks current state before acting. In a real implementation, quote inputs carefully and prefer environment variables over constructing shell commands from untrusted text.

Current deployment-script support can use a user-assigned managed identity for Azure operations. The deployment principal needs permission to assign or operate that identity as required, while the script identity itself needs only the actions performed by the script. Microsoft Graph permissions, when needed, require their own deliberate setup and cannot be assumed from Azure RBAC.

Junior: “Could we pass a service-principal secret as a normal environment variable?”

Senior: “Deployment scripts support secure environment values, but first challenge why a secret is required. Managed identity is preferable when the target supports it. If a secret is unavoidable, source it securely, prevent logging and define rotation and revocation.”

Understand new dependencies introduced by private networking

A script that must operate inside a private network needs additional configuration. Current guidance supports private-network execution with the appropriate deployment-script API and an existing storage account configured for that network. The user-assigned identity needs the required storage data permission, and DNS and subnet configuration become part of the execution path.

This means a “small command” can depend on identity propagation, storage file access, container execution, private DNS and target-service connectivity. Document those dependencies and provide diagnostics. If the operation can live in a controlled application migration job instead, compare the operational models honestly.

Decide what output may leave the script

Script outputs become deployment data. Return only values needed by later declarations, and never return credentials or sensitive query results. Validate the output shape before consumers use it.

output configuredResourceId string =
  configureExternalSetting.properties.outputs.resourceId

If later resources depend on the script output, failure correctly blocks them. If the script performs optional enrichment, decide whether coupling the main deployment to it is desirable. Sometimes a separate post-deployment job with its own retry and alerting is the clearer boundary.

Review and test deployment scripts like application code

A reviewer should ask:

  • Why can this not be declarative?
  • Is the operation safe to execute twice?
  • Which identity runs it and at what scope?
  • Could input be interpreted as shell code?
  • Are secrets or sensitive output exposed?
  • What is the timeout and failure behaviour?
  • Which temporary resources are created and retained?
  • Does it work with the target network controls?
  • How is a partial external change recovered?
  • Which test or sandbox deployment proves it?
Extract substantial script logic into a separately testable file where packaging and deployment requirements allow it. Run shell linting and unit tests, then exercise the real identity and network in a non-production scope. Inline scripts are still production code.

Troubleshoot by boundary

If a deployment script fails, inspect the deployment-script resource status and its documented logs before editing Bicep blindly. Determine whether the problem is:

  • resource-provider registration;
  • creation of supporting storage or container resources;
  • managed-identity assignment or propagation;
  • network or DNS access;
  • CLI or PowerShell version compatibility;
  • authentication to the target;
  • script syntax or input quoting;
  • target-operation authorization;
  • output formatting;
  • timeout or cleanup behaviour.
Junior: “The script worked after I reran the deployment. Can we close the issue?”

Senior: “Only after we know why the first run failed. A propagation delay may require bounded retry; a non-idempotent partial change may now be hidden; an intermittent network problem may need monitoring.”

Record the first failure and the second execution under the same investigation. Check whether the operation ran twice and whether current state is correct. Then improve the identity sequencing, retry policy, idempotency check or runbook.

The principle to remember

Deployment scripts are valuable because they bridge gaps. That is also their danger: they can turn a reviewable desired-state deployment into an opaque sequence of commands. Use them when the gap is real, keep their authority narrow, make reruns safe, expose useful failure evidence and continue searching for a declarative or better-owned boundary as the platform evolves.

Conclusion

You began with a simple problem: reliably recreate the Azure environment needed by an application. Along the way, you learned that Bicep is a declarative Azure-specific language compiled to ARM JSON; symbolic references express dependencies; parameters and variables separate caller choice from template decisions; conditions and loops handle controlled variation; modules create reusable boundaries; outputs connect components; and managed identity replaces many dangerous secrets.

You also learned that professional IaC extends beyond the .bicep file. Formatting, linting, compilation, snapshot checks, ARM validation, what-if, peer review, protected environments, federated identity, smoke tests, policy, monitoring and recovery all contribute to safe delivery.

Do not try to master every Azure resource type. Master the reasoning process: identify the desired outcome, choose a narrow scope, express the design clearly, minimise permissions, preview change, deploy through a controlled identity, verify behaviour and preserve what you learned.

Your first storage account is not the final goal. The goal is confidence that your team can understand, review, reproduce and safely evolve the environment. That is the real promise of Infrastructure as Code.

Continue learning

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Continuous Learning →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →