---
title: Bicep: Never Hand-Write Azure ARM JSON Again
description: Learn how Bicep simplifies Azure infrastructure deployment with domain-specific language, dependency inference, and reusable modules for cleaner IaC.
canonical: https://adamtheautomator.com/azure-bicep-vs-arm-templates/
---

# Bicep: Never Hand-Write Azure ARM JSON Again

> Learn how Bicep simplifies Azure infrastructure deployment with domain-specific language, dependency inference, and reusable modules for cleaner IaC.

Source: https://adamtheautomator.com/azure-bicep-vs-arm-templates/

---

- Bicep: Never Hand-Write Azure ARM JSON Again Tap to hide Home

- Tutorials

- Guidebooks

- Instructors

- Get Paid to Write

- Advertising

- Recommended Resources

- About Adam

                Search for:

-

-

-

-

# Bicep: Never Hand-Write Azure ARM JSON Again

        Published:8 September 2026 - 11 min. read

- Azure
- DevOps
- GitHub Actions
- Infrastructure as Code

          [](https://adamtheautomator.com/author/adam-bertram/)

            [Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)

            Read [more tutorials](https://adamtheautomator.com/author/adam-bertram/) by Adam Bertram!

-

-

        [](https://specopssoft.com/product/specops-password-auditor/?utm_source=adamtheautomator&utm_medium=referral&utm_campaign=adamtheautomator_referral_na&utm_content=display)
        Audit Active Directory for stale users, weak passwords, and other security risks with [Specops Password Auditor](https://specopssoft.com/product/specops-password-auditor/?utm_source=adamtheautomator&utm_medium=referral&utm_campaign=adamtheautomator_referral_na&utm_content=text).

Table of Contents

- Prerequisites: What You Need Before You Start
- What Azure Bicep Replaces (and Why It Exists)From ARM JSON to a Domain-Specific Language
- The Symbolic Name Isn't the Real Resource Name
- How a .bicep File Becomes Running InfrastructureWhy Deployment Order Matters
- How Bicep Infers the Dependency Graph
- Parameters, Variables, and the Decorators That Keep You HonestTyped Parameters With Guardrails
- Looping Without Overloading the Azure API
- Building Reusable Infrastructure with ModulesSkipping the Boilerplate with Azure Verified Modules
- Deploying and Validating Changes with What-IfRunning What-If Before You Deploy
- Reading the Change-Type Table
- Automating Deployment with a GitHub Actions PipelineA Two-Job Pipeline: Validate, Then Deploy
- Why the Permissions Block Is the Point
- Keeping Secrets Out of Your Templates
- Making Bicep Your Default Way to Touch Azure

                XFacebookLinkedIn
                The fastest way to deploy Azure infrastructure is to write less of it. That sounds like the kind of line a consultant says right before billing you for a six-month DevOps transformation, but it happens to be the entire premise behind Azure Bicep.

If you've ever opened an Azure Resource Manager (ARM) JSON template and watched a single storage account definition sprawl past a hundred lines of nested brackets, you already know the problem Bicep solves. One misplaced comma in that file breaks the whole deployment, and tracking it down means scrolling through bracket after bracket. Bicep compiles down to that same JSON structure, but you never write the JSON by hand again. For the click-by-click walkthrough of setting up your first deployment, see our [step-by-step guide to getting started with Azure Bicep](https://adamtheautomator.com/azure-bicep/). This post covers the why behind Bicep's design, then goes further into what a production Infrastructure as Code setup actually needs: Microsoft-maintained modules instead of hand-rolled ones, a safety net that catches a bad deployment before it runs, and a CI/CD pipeline that doesn't depend on someone's laptop.

## Prerequisites: What You Need Before You Start

Everything below assumes a working Bicep setup, not a fresh install. You'll need the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) with the Bicep CLI installed (az bicep install, or az bicep upgrade if you already have an older version), the [VS Code Bicep extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep) for inline validation and IntelliSense, and Contributor rights on the Azure infrastructure you're deploying into. The commands in this post were tested against Azure CLI 2.80.0 and Bicep CLI 0.40.2.

## What Azure Bicep Replaces (and Why It Exists)

Something still has to write that JSON, and something still has to receive it. Azure Resource Manager is Azure's native deployment engine, the one on the receiving end of every ARM template. Every resource you create, whether through the portal, the CLI, or a script, eventually becomes an [ARM template](https://learn.microsoft.com/en-us/azure/azure-resource-manager/templates/overview) submitted to that engine. For most of Azure's history, authoring that template meant writing raw JSON by hand: a data format built for machines to parse, not for humans to read or maintain.

### From ARM JSON to a Domain-Specific Language

[Azure Bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/overview) is a domain-specific language, meaning it was built for exactly one job instead of general-purpose programming. That job is describing Azure resources. When you run a deployment command against a .bicep file, the Bicep CLI compiles, or transpiles, your code into the same ARM JSON that Azure has always accepted. Azure Resource Manager never sees Bicep syntax directly; it only ever sees the JSON that comes out the other side.

That transpilation step matters for a reason beyond convenience. Because Bicep is a thin layer over ARM rather than a separate platform, it inherits ARM's day-zero support for new capabilities. Microsoft's Bicep overview documentation states it directly for both preview and generally available (GA) services: "Bicep immediately supports all preview and GA versions for Azure services." The moment Azure ships a new resource type or API version, you can reference it in Bicep without waiting for a separate provider update. Consider a resource declaration:

resource myStorageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = {
  name: 'myuniquestorage001'
  location: resourceGroup().location
  kind: 'StorageV2'
  sku: {
    name: 'Standard_GRS'
  }
}

myStorageAccount is a symbolic name here. It exists only inside this file, letting you reference the resource's properties elsewhere in your code (myStorageAccount.id, for instance).

Bicep and ARM templates have identical capability by construction, since one compiles straight into the other, so the only real Infrastructure as Code decision left is whether you also manage non-Azure resources, where a cross-provider tool like Terraform wins outright.

### The Symbolic Name Isn't the Real Resource Name

The symbolic name is not the actual Azure resource name, which is the myuniquestorage001 value inside the properties block. Confusing the two is the single most common mistake new Bicep authors make, and it produces deployment errors that point at the wrong line. Written this way, the same storage account that takes dozens of lines in ARM JSON, with its nested properties object and bracketed apiVersion string, collapses to roughly seven readable lines.

## How a .bicep File Becomes Running Infrastructure

Transpilation handles readability. [Dependency management](https://learn.microsoft.com/en-us/azure/azure-resource-manager/templates/define-resource-dependency) handles deployment order.

### Why Deployment Order Matters

In raw ARM JSON, if a subnet depends on a virtual network that doesn't exist yet, you have to declare that relationship manually with a dependsOn array. Forget it, and Azure tries to create both resources at once, and the subnet deployment fails because its parent doesn't exist.

### How Bicep Infers the Dependency Graph

Bicep infers resource dependencies automatically. When one resource's symbolic name shows up inside another, the compiler works out the dependency graph itself:

resource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {
  name: 'app-vnet'
  location: resourceGroup().location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.0.0.0/16'
      ]
    }
  }
}

resource subnet 'Microsoft.Network/virtualNetworks/subnets@2023-09-01' = {
  parent: vnet
  name: 'app-subnet'
  properties: {
    addressPrefix: '10.0.1.0/24'
  }
}

The parent property links the subnet to the virtual network's symbolic name, vnet. Bicep sees that link during compilation and injects the equivalent dependsOn array into the ARM JSON it produces, in the correct order, without you writing it. Explicit dependsOn declarations still work in Bicep and remain the only option when two resources are related in a way the compiler can't infer from a direct reference, but for anything else they add a line of maintenance for no benefit. Get the parent-child relationship backward, or reference a resource that was never declared, and the deployment fails with a dependency error at runtime rather than a syntax warning while you're editing, which is why it's worth double-checking every parent and cross-resource reference before you deploy, not after. The compile step, the injected dependsOn array, and the resulting deployment order look like this:

## Parameters, Variables, and the Decorators That Keep You Honest

Parameters let you pass values into a template at deployment time; variables store expressions you calculate once and reuse. Both exist in ARM JSON too, but Bicep adds decorators: annotations that validate and document a parameter before Azure ever sees a deployment request.

### Typed Parameters With Guardrails

A handful of decorators do most of the work:

- @description() documents what a parameter is for, so the next person editing the file doesn't have to guess

- @allowed() restricts a parameter to a specific list of values, catching a typo'd environment name during authoring instead of after a failed API call

- @secure() masks a parameter's value from Azure CLI output and deployment logs

- @minLength() and @maxLength() enforce string or array size constraints before deployment starts

@description('Specifies the storage account environment type.')
@allowed([
  'dev'
  'prod'
])
param envType string = 'dev'

@secure()
param adminPassword string

Pro Tip: @secure() hides a parameter from logs and CLI output, but it does not encrypt the value at rest inside a parameter file. Pull real secrets from [Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) with the getSecret() function instead of typing them into even a @secure() parameter.

### Looping Without Overloading the Azure API

Bicep's [for ... in ...] syntax deploys multiple copies of a resource from a single block, which is useful until you try to create fifty of anything at once and Azure Resource Manager starts returning 429 rate-limit errors partway through.

@batchSize(3)
resource storageAccounts 'Microsoft.Storage/storageAccounts@2022-09-01' = [for i in range(0, 10): {
  name: 'stg${i}${uniqueString(resourceGroup().id)}'
  location: resourceGroup().location
  kind: 'StorageV2'
  sku: {
    name: 'Standard_LRS'
  }
}]

The [@batchSize() decorator](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/loops) caps how many resources deploy concurrently from the loop, here at three. Without that cap, a large array can trip Azure's throttling limits before the loop finishes. Some resources end up created, others fail, and the partial deployment is harder to clean up than a complete failure would have been.

## Building Reusable Infrastructure with Modules

A Bicep module is nothing more than a standard .bicep file referenced by another .bicep file. There's no special syntax that marks a file as a module. You call it with the module keyword instead of resource.

module storage './modules/storageAccount.bicep' = {
  name: 'storageDeployment'
  params: {
    storageAccountName: 'mystorage001'
    location: 'eastus'
  }
}

The orchestrator file passes parameters in and can capture outputs back, without knowing or caring about the module's internal resource properties. That encapsulation is what makes modules worth the extra file: change how the storage module configures diagnostic settings, and every orchestrator that consumes it picks up the change without being touched.

### Skipping the Boilerplate with Azure Verified Modules

Writing your own networking module means you're also responsible for keeping it aligned with Azure's security and reliability guidance as that guidance evolves. [Azure Verified Modules](https://azure.github.io/Azure-Verified-Modules/) shift that maintenance to Microsoft. AVM is a library of pre-tested, Microsoft-maintained Bicep modules published to the public Bicep registry and referenced with a br/public: alias:

module vnet 'br/public:avm/res/network/virtual-network:0.1.6' = {
  name: 'vnetDeployment'
  params: {
    name: 'app-vnet'
    addressPrefixes: [
      '10.0.0.0/16'
    ]
  }
}

AVM ships two kinds of modules: resource modules that deploy a single Azure resource with sensible defaults baked in, and pattern modules that deploy entire architectures at once, the kind of turnkey setup our [guide to building Azure landing zones](https://adamtheautomator.com/build-azure-landing-zones/) covers in more depth. A pattern module might be a hub-and-spoke network topology, or a baseline set of RBAC assignments for a new subscription. A version pin like 0.1.6 above beats tracking latest, because an unpinned module reference means your infrastructure can change behavior on a date you didn't choose.

## Deploying and Validating Changes with What-If

Deploying a Bicep file is one command, but the deployment engine can't tell the difference between a change you meant to make and a typo that deletes a production database. That's what the [What-If operation](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deploy-what-if) exists to catch, before either one reaches Azure.

### Running What-If Before You Deploy

az login
az account set --subscription "<subscription-id>"
az deployment group what-if \
  --resource-group rg-app-prod \
  --template-file main.bicep \
  --parameters main.bicepparam

az login authenticates the session against [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/whatis), and az account set scopes every command that follows to the right subscription so you're not accidentally previewing changes against the wrong environment. The what-if command then performs a dry run: it compiles your Bicep file, queries the live state of the resource group, and prints a color-coded diff categorized by change type.

### Reading the Change-Type Table

Change Type
Meaning

Create
Defined in Bicep, doesn't exist in Azure yet

Modify
Exists, but a property differs from the template

Ignore
Exists in Azure but isn't in your template. In Incremental mode (the default, and what the command above runs) this is what you'll see, and the resource is left alone

Delete
Complete mode only: exists in Azure but isn't in the template, and will be removed

NoChange
Matches the template exactly

NoEffect
A property would change, but it's a read-only property, so the change has no real effect

Deploy
What-If doesn't have enough information to determine the change type

Run the command above as written and Ignore is the category to watch, not Delete. The gotcha below explains why.

Warning: Azure deploys Bicep templates in [Incremental mode](https://learn.microsoft.com/en-us/azure/azure-resource-manager/templates/deployment-modes) by default, which never removes a resource missing from your file. Delete only shows up as a real deletion in Complete mode. Complete mode is safe only when you're certain every resource in that group belongs to this deployment. What-If's Delete warning is your last chance to catch a mistake before it runs.

## Automating Deployment with a GitHub Actions Pipeline

Running What-If by hand from your laptop works for one engineer. It stops working the moment a second person can also merge to main. A CI/CD pipeline turns that manual check into a gate everyone goes through, running on GitHub's own runners rather than anyone's local machine. That is the same DevOps automation principle behind [Azure Pipelines](https://adamtheautomator.com/azure-pipelines/) if your team lives in Azure DevOps instead of GitHub.

### A Two-Job Pipeline: Validate, Then Deploy

The workflow below runs two jobs against a Bicep template in infra/: a validate job that builds the template and previews changes with What-If, and a deploy job that only runs after validate succeeds and a reviewer approves it.

name: deploy-infrastructure

on:
  push:
    branches: [main]
    paths: ['infra/**']

permissions:
  id-token: write
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Lint and build
        run: az bicep build --file infra/main.bicep
      - name: Preview changes
        run: |
          az deployment group what-if \
            --resource-group rg-app-prod \
            --template-file infra/main.bicep \
            --parameters infra/main.bicepparam

  deploy:
    needs: validate
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Deploy
        run: |
          az deployment group create \
            --resource-group rg-app-prod \
            --template-file infra/main.bicep \
            --parameters infra/main.bicepparam

### Why the Permissions Block Is the Point

permissions: id-token: write is what makes [OpenID Connect federation](https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure) possible: GitHub issues a short-lived token that [azure/login](https://github.com/marketplace/actions/azure-login) trades for an Entra ID access token, so no client secret sits in your repository settings waiting to be leaked. That token exchange still needs an [app registration](https://adamtheautomator.com/azure-service-principal/) on the Azure side configured to trust GitHub's federated credential. The environment: production line on the deploy job is the actual safety gate; configure that GitHub Environment with required reviewers, and the deploy job pauses until someone approves it, giving them the What-If output from the validate job as the thing to review.

The three secrets.AZURE_* values are identifiers, not credentials. They tell azure/login which app registration and tenant to present the OIDC token to; none of them is usable on its own, which is the whole point of federation. contents: read keeps the rest of the token least-privilege, because naming any permission in the block drops every default you didn't list. And paths: ['infra/**'] keeps application commits from triggering an infrastructure deploy.

Skip that gate and a few concrete failure modes become likely instead of hypothetical:

- A resource-group name typo silently deploys infrastructure into the wrong subscription, one nobody's watching

- A merged pull request with an unreviewed delete change removes a resource before anyone reads the diff

- A stale Bicep CLI version on the runner builds a template that passes locally and fails in CI with a version-mismatch error nobody expects

The two jobs and the approval gate between them lay out as you can see below:

## Keeping Secrets Out of Your Templates

A .bicepparam file supplies parameter values outside your main template, using plain Bicep syntax instead of JSON. Pair it with the [getSecret() function](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/key-vault-parameter) and you can pull a secret directly from Key Vault at deployment time, instead of typing it anywhere:

using './main.bicep'

param environmentName = 'production'
param sqlAdminPassword = getSecret(
  '<subscription-id>',
  'rg-shared',
  'kv-secrets',
  'sql-admin-password-prod'
)

For getSecret() to pull the value at deployment time, the target Key Vault needs its [enabledForTemplateDeployment](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/key-vault-parameter) property set to true; skip that and Azure rejects the secret lookup with an access error that has nothing to do with your Bicep syntax. During deployment, Azure Resource Manager fetches the secret server-side and injects it directly into the resource provider call. It never touches the .bicepparam file, your source control history, or your terminal.

Warning: Never assign a secret value to a Bicep [output](https://adamtheautomator.com/arm-output/). Outputs are recorded in plain text in the Azure deployment history, and anyone with Reader access on the resource group can read that value straight out of the portal, no matter how it was masked going in.

## Making Bicep Your Default Way to Touch Azure

If you're carrying an existing library of ARM JSON templates, you don't have to rewrite everything before you get value from Bicep. Run [az bicep decompile](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/decompile) against a template to get a starting point, treat the output as a rough draft rather than production code (decompilation is best-effort and can leave behind linting warnings), and migrate one resource group at a time.

Start with the parts of this post that compound: pin your Bicep CLI to a recent version so day-zero API support actually applies to you, reach for an Azure Verified Module before writing a networking or identity resource from scratch, and put What-If in front of a pull request before your first production deploy rather than after a change you didn't review causes an incident. None of that requires new tooling. It's deployment automation built entirely from what's already in your Azure subscription and GitHub repo.

  Hate ads? Want to support the writer? Get many of our tutorials packaged as an ATA Guidebook.

  [Explore ATA Guidebooks](https://adamtheautomator.com/ata-guidebooks/)

## More from ATA Learning and Partners

- ### Recommended Resources! Recommended Resources for Training, Information Security, Automation, and more!

- ### Get Paid to Write! ATA Learning is always seeking instructors of all experience levels. Regardless if you’re a junior admin or system architect, you have something to share. Why not write on a platform with an existing audience and share your knowledge with the world?

- ### ATA Learning Guidebooks ATA Learning is known for its high-quality written tutorials in the form of blog posts. Support ATA Learning with ATA Guidebook PDF eBooks available offline and with no ads!

## Categories

- IT Ops

- Cloud

- DevOps

- Home Ops

- Information Security

- Software Development

## Site

- Home

- Tutorials

- Guidebooks

- Instructors

- Get Paid to Write

- Advertising

- Recommended Resources

- About Adam

        Copyright 2026&copy; ATA Learning | [Privacy Policy](https://adamtheautomator.com/privacy/)

                                                        Don't be left behind with the ATA Learning Newsletter!

Looks like you're offline!
