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 attached 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.

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 →