← All writing
AzureAutomation

Deploying Bicep from Azure DevOps Pipelines

· 6 min read

I came to Bicep after years of hand editing ARM templates, and the thing that won me over was how much smaller the files got. The same deployment that used to be three hundred lines of JSON is now sixty lines you can actually read. Pair it with an Azure DevOps pipeline and you get something better than convenience: every infrastructure change goes through the same review, preview, and approval path as your application code.

The pipeline below lints the template, shows you exactly what will change with a what-if preview, and only then deploys, with an approval gate in front of production.

Prerequisites

  • An Azure DevOps project and an Azure subscription.
  • An ARM service connection in your project (Project settings, Service connections). Use workload identity federation rather than a secret if you can, it avoids storing a credential at all.
  • A resource group to deploy into. I will use rg-app-prod.
  • A Bicep file in the repo. Azure CLI ships with Bicep built in, so there is nothing extra to install on the agent.

1. A small Bicep file to deploy

Keep the example honest by giving it a few settings that actually matter, like blocking public blob access and pinning the TLS version.

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

resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: 'st${env}${uniqueString(resourceGroup().id)}'
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    publicNetworkAccess: 'Disabled'
  }
}

2. Validate and preview before you deploy

The piece people skip is the preview, and it is the piece that saves you. az deployment group what-if tells you what Azure will create, modify, or delete if you run the deployment, before anything happens. Run it on every pull request and you stop guessing whether a change is additive or destructive.

Here is the first stage of the pipeline. It lints the template and runs the what-if against the live resource group.

trigger:
  branches:
    include: [ main ]

pool:
  vmImage: ubuntu-latest

variables:
  resourceGroup: rg-app-prod
  serviceConnection: sc-azure-prod

stages:
  - stage: validate
    jobs:
      - job: lint_and_whatif
        steps:
          - task: AzureCLI@2
            displayName: Lint and what-if
            inputs:
              azureSubscription: $(serviceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                az bicep build --file main.bicep
                az deployment group what-if \
                  --resource-group $(resourceGroup) \
                  --template-file main.bicep \
                  --parameters env=prod

az bicep build compiles the file and surfaces any type errors or warnings, so a broken template fails the build instead of the deployment.

3. Deploy behind an approval

The second stage does the actual deployment, and it runs as an Azure DevOps deployment job targeting an environment called prod. The reason that matters: you attach approvals and checks to the environment, so a human has to sign off before the deploy runs.

  - stage: deploy
    dependsOn: validate
    jobs:
      - deployment: deploy_infra
        environment: prod
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self
                - task: AzureCLI@2
                  displayName: Deploy Bicep
                  inputs:
                    azureSubscription: $(serviceConnection)
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az deployment group create \
                        --resource-group $(resourceGroup) \
                        --template-file main.bicep \
                        --parameters env=prod

To wire up the gate, go to Pipelines, Environments, prod, and add an approval check with yourself or your team as approvers. From then on the deploy stage pauses until someone approves it.

4. Use deployment scopes deliberately

az deployment group create deploys into a single resource group. When you need to create the resource group itself, or assign policy, or set up role assignments at a higher level, switch to a subscription scoped deployment with az deployment sub create and a targetScope = 'subscription' line at the top of your Bicep. Keeping application infrastructure at resource group scope and platform concerns at subscription scope keeps the blast radius of any one deployment small.

5. A few habits worth keeping

  • Run what-if on pull requests, deploy only from main. Reviewers see the preview in the PR build and merge with their eyes open.
  • Parameterise per environment with .bicepparam files rather than copying templates. One template, several parameter files.
  • Treat warnings as failures for anything security related. A template that compiles is not the same as a template that is safe.

Closing thoughts

What you get from this is predictability more than speed. You see the diff before it happens, an approval stands in front of production, and the same template runs in every environment. Once a team has this, infrastructure changes stop being the scary part of a release. The next step, and the subject of a separate article, is checking those templates against your security baseline before they ever reach the deploy stage.