Infrastructure as Code

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Examples on this page target Azure CLI 2.90.0, Bicep 0.47.16 (bundled with the CLI, invoked as az bicep) and azd (the Azure Developer CLI) 1.34.2, checked 2026-09-26 — re-verify version numbers against az version and azd version before relying on exact behaviour.

Declarative vs. imperative management

Azure infrastructure can be managed imperatively, by running a sequence of commands such as az vm create --resource-group "$RG" --name vm-bookshelf …​, or declaratively, by submitting a template that describes the desired end state of a resource group and letting Azure Resource Manager (ARM) figure out which create, update or no-op operations get there. The imperative script only ever performs the specific action it was written for — rerunning it against a resource group that has drifted, or that was created by someone else, either fails or silently does the wrong thing. A declarative template is idempotent: ARM diffs the template against the current state of the resource group and applies only the difference, which is what makes ARM/Bicep deployments, Deployment Stacks and --what-if previews (both covered below) possible in the first place. Both approaches call the same ARM control plane underneath — az vm create is itself a wrapper that assembles a PUT against the ARM Microsoft.Compute/virtualMachines resource type — so the real choice is not "CLI vs. templates" but whether the desired state is expressed once, as data, or reconstructed from a sequence of steps every time.

ARM templates

An Azure Resource Manager (ARM) template is a JSON document that describes one or more resources, their properties, and the parameters and outputs of a deployment. It is the format ARM itself understands and the one every deployment eventually compiles down to, but it is verbose and easy to get wrong by hand: nested dependsOn arrays, repeated resourceId() function calls, and no native support for loops or conditionals without contorted JSON expressions.

Bicep is a domain-specific language that transpiles to ARM JSON. Every .bicep file the CLI deploys is first compiled (az bicep build) into the equivalent ARM template, submitted to ARM, and it is that generated JSON — not the source Bicep — that ARM actually evaluates. This matters mainly for debugging: when a deployment fails with an ARM-level error referencing a JSON path, az bicep build --file main.bicep --stdout reproduces the exact JSON ARM saw. In practice, almost nobody hand-writes ARM JSON directly any more; the rest of this page uses Bicep throughout.

Bicep in depth

Modules

A module block lets one Bicep file deploy another as a nested, independently scoped deployment — the usual way to split a large environment into reusable, composable pieces (a network module, an identity module, an application module) instead of one monolithic file.

// main.bicep
module logging 'modules/log-analytics.bicep' = {
  name: 'logAnalyticsDeployment'
  params: {
    workspaceName: 'log-bookshelf'
    location: location
  }
}

// reference a module published to a Bicep registry instead of a local file
module registry 'br:acrbookshelf.azurecr.io/bicep/modules/naming:1.0.0' = {
  name: 'namingDeployment'
  params: {
    projectName: 'bookshelf'
  }
}

A local module path ('modules/log-analytics.bicep') resolves relative to the file that references it; a registry module path ('br:<registry>/<repository>:<tag>') resolves to an OCI artifact pushed with az bicep publish, which is how teams share modules across repositories without copy-pasting .bicep files.

Parameters and parameter files

Every module and every top-level template declares its inputs with param, optionally with a default value, an @allowed list, or @secure() for values that must never be logged. Parameter values are supplied separately from the template, either in a .bicepparam file (Bicep’s own typed parameter format) or in a plain JSON parameters file:

// main.bicepparam
using 'main.bicep'

param location = 'eastus2'
param acrName = 'acrbookshelf'
param environmentName = 'cae-bookshelf'

Keeping parameter values in a .bicepparam file (checked into source control, one file per environment — dev.bicepparam, prod.bicepparam) means the template itself never changes between environments, only the values fed into it.

With the Azure CLI

  1. Compile a template on its own, without deploying it, to catch syntax errors early:

    az bicep build --file main.bicep
  2. Deploy a template and a parameter file to a resource group:

    RG="rg-bookshelf"
    
    az deployment group create \
      --resource-group "$RG" \
      --template-file main.bicep \
      --parameters main.bicepparam
  3. Preview what a deployment would change before applying it, with --what-if:

    az deployment group create \
      --resource-group "$RG" \
      --template-file main.bicep \
      --parameters main.bicepparam \
      --what-if

    The command prints a colour-coded, resource-by-resource diff instead of deploying anything: a + prefix marks a resource that would be created, ~ a property that would be modified in place (with the old and new value shown side by side), - a resource that would be deleted (only when the template uses complete mode), and = a resource that is already in the desired state and would be left alone. Reviewing this output before every deployment to a shared environment is the cheapest way to catch an unintended change — for example a parameter default that silently resizes a SKU — before it actually happens.

In the Azure portal

  1. Open the target resource group, then Settings > Deployments to see the history of every deployment made to that resource group, each with its status, duration, and the template/parameters that were submitted.

  2. Open a specific deployment and select Template to view the ARM JSON that was actually submitted (useful when diagnosing a Bicep-to-JSON compilation surprise).

  3. From Settings > Deployments, selecting Redeploy on a prior deployment reissues the exact same template and parameters — a portal-only shortcut for rolling forward or back to a known-good state.

Deployment Stacks and Template Specs

Deployment Stacks

A deployment stack (az stack group create) wraps a normal deployment with lifecycle management: it tracks every resource the stack created as a single managed unit, so deleting the stack can delete everything it provisioned in one operation, and — the more distinctive feature — it supports --deny-settings-mode, which locks the resources it manages against out-of-band changes (portal edits, other CLI sessions, other pipelines) that would otherwise cause the drift a purely declarative template cannot detect on its own.

RG="rg-bookshelf"

az stack group create \
  --name stack-bookshelf \
  --resource-group "$RG" \
  --template-file main.bicep \
  --parameters main.bicepparam \
  --deny-settings-mode denyWriteAndDelete \
  --action-on-unmanage deleteResources

--deny-settings-mode denyWriteAndDelete denies any write or delete against the stack’s managed resources from outside the stack’s own deployment operation, which is the mechanism teams reach for once Azure Blueprints stops being an option (see below).

Template Specs

A template spec packages a Bicep/ARM template as a versioned, named ARM resource of its own (Microsoft.Resources/templateSpecs), so it can be shared across an organisation and referenced by name and version instead of by file path — useful when a platform team wants to publish a "golden path" template that application teams consume without needing the source file at all.

RG="rg-bookshelf"

az ts create \
  --name ts-bookshelf-baseline \
  --version "1.0" \
  --resource-group "$RG" \
  --location eastus2 \
  --template-file main.bicep

az deployment group create \
  --resource-group "$RG" \
  --template-spec "/subscriptions/<subscription-id>/resourceGroups/$RG/providers/Microsoft.Resources/templateSpecs/ts-bookshelf-baseline/versions/1.0"

In the Azure portal

  1. Search for Template specs in the portal, then Create to publish a new one, or open an existing spec to see its version history.

  2. From a template spec’s Versions tab, selecting Deploy against a specific version opens the standard parameter-entry deployment blade, pre-populated from that version’s template.

Azure Verified Modules (AVM)

Azure Verified Modules (AVM) are a curated catalogue of Bicep and Terraform modules, maintained and versioned by Microsoft and the community, that encode current best practice (naming, tagging, diagnostic settings, private networking) for a given resource type so teams do not have to re-derive it from scratch. Referencing one from the public Bicep registry looks like referencing any other registry module:

module registryModule 'br/public:avm/res/container-registry/registry:0.9.3' = {
  name: 'acrBookshelfDeployment'
  params: {
    name: 'acrbookshelf'
    location: location
    acrSku: 'Basic'
  }
}

Pin the version tag deliberately (as above) rather than tracking a moving alias, since an AVM module’s defaults can change between releases.

Azure Blueprints retirement

Azure Blueprints is being retired. Microsoft’s own guidance describes a phased retirement beginning 2026-07-31, full retirement on 2027-01-31, with the blueprint Azure CLI extension planned for removal around CLI version 2.92.0 (roughly November 2026) — re-verify these dates against the official retirement notice before relying on them, since a retirement timeline is exactly the kind of detail that can shift. The replacement path Microsoft recommends is exactly the pair of features covered above: Deployment Stacks for the "manage a set of resources as one unit and prevent drift" capability Blueprints provided, and Template Specs for the "publish a versioned, shareable template" capability. Any Blueprint assignment still in place should be migrated to a Deployment Stack (or a plain deployment plus a Template Spec) well before the phased-retirement date, since new Blueprint assignments will stop being supported first, before existing ones stop working entirely.

Terraform

The Terraform azurerm provider talks to the same ARM control plane as Bicep, so any resource expressible in Bicep is also expressible in Terraform’s HashiCorp Configuration Language (HCL):

resource "azurerm_resource_group" "bookshelf" {
  name     = "rg-bookshelf"
  location = "eastus2"
}

resource "azurerm_storage_account" "bookshelf" {
  name                     = "stbookshelfxxxxx" # must be globally unique, lowercase, no dashes
  resource_group_name      = azurerm_resource_group.bookshelf.name
  location                 = azurerm_resource_group.bookshelf.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

In-depth Terraform coverage for Azure (the azurerm provider) is tracked separately in issue #171; this page shows only one short example to establish where Terraform fits next to Bicep.

azd templates

The Azure Developer CLI (azd) ties an application’s infrastructure and its deployable code together into a single command. An azd template repository has an infra/ folder (typically Bicep, following the module pattern above) and an azure.yaml file at the root that declares the application’s services and which host each one deploys to (Container Apps, App Service, AKS, Functions, …​). azd init scaffolds or adopts that layout in an existing repository, and azd up provisions everything under infra/ and then deploys the services azure.yaml declares, in one step — the same underlying Bicep deployment covered above, wrapped so a single command takes a repository from nothing to a running application.

Bookshelf baseline: a runnable Bicep example

The following template provisions the shared Bookshelf baseline used across this section: an resource-group-scoped Log Analytics workspace, an Azure Container Registry, and a Container Apps environment wired to that workspace for logging. It deploys into the rg-bookshelf resource group, with eastus2 as a placeholder region — substitute the region that fits the reader’s own subscription.

// main.bicep
@description('Azure region for all resources. eastus2 is a placeholder -- pick the region that fits your subscription.')
param location string = 'eastus2'

@description('Name of the Azure Container Registry. ACR names must be globally unique across all of Azure, so the default below will need to be changed (e.g. append a random suffix) before deployment.')
param acrName string = 'acrbookshelf'

@description('Name of the Log Analytics workspace backing the Container Apps environment.')
param logAnalyticsName string = 'log-bookshelf'

@description('Name of the Container Apps environment.')
param environmentName string = 'cae-bookshelf'

resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: logAnalyticsName
  location: location
  properties: {
    sku: {
      name: 'PerGB2018'
    }
    retentionInDays: 30
  }
}

resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
  name: acrName
  location: location
  sku: {
    name: 'Basic'
  }
  properties: {
    adminUserEnabled: false
  }
}

resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = {
  name: environmentName
  location: location
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalytics.properties.customerId
        sharedKey: logAnalytics.listKeys().primarySharedKey
      }
    }
  }
}

output acrLoginServer string = acr.properties.loginServer
output environmentId string = containerAppsEnvironment.id
output logAnalyticsWorkspaceId string = logAnalytics.id

The Container Apps environment’s logAnalyticsConfiguration reads the workspace’s customerId directly off the logAnalytics symbolic resource, and its sharedKey from logAnalytics.listKeys().primarySharedKey — a template-time function call that resolves the workspace’s primary key at deployment time so no key is ever written into the template or a parameter file.

With the Azure CLI

  1. Deploy the template above (saved as main.bicep) into rg-bookshelf, creating the resource group first if it does not already exist:

    RG="rg-bookshelf"
    LOCATION="eastus2"
    
    az group create --name "$RG" --location "$LOCATION"
    
    az deployment group create \
      --resource-group "$RG" \
      --template-file main.bicep \
      --parameters location="$LOCATION" acrName=acrbookshelf environmentName=cae-bookshelf
  2. Preview the same deployment with --what-if first, which is the safer default for a shared resource group:

    az deployment group create \
      --resource-group "$RG" \
      --template-file main.bicep \
      --parameters location="$LOCATION" acrName=acrbookshelf environmentName=cae-bookshelf \
      --what-if

    On a first deployment into an empty resource group, expect three + (create) entries — the Log Analytics workspace, the container registry, and the Container Apps environment — with no ~ or - entries, since nothing in rg-bookshelf yet exists for ARM to modify or remove.

The rest of the Bookshelf section builds directly on top of this baseline — see Container Apps for deploying an application into cae-bookshelf, and Identity and Access for the managed identities and role assignments that let the Container Apps environment pull images from acrbookshelf without embedded credentials.

Clean up

Remove everything this page deployed, including the Bookshelf baseline, by deleting the resource group:

RG="rg-bookshelf"

az group delete --name "$RG" --yes --no-wait