---
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/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Bicep: Never Hand-Write Azure ARM JSON Again](https://adamtheautomator.com/wp-content/uploads/2026/09/featured_image-1.webp)

# Bicep: Never Hand-Write Azure ARM JSON Again

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)8 September 202611 min. read

Categories: [Cloud](/category/cloud/)

Tags:[Azure](/tag/azure/)[DevOps](/tag/devops/)[GitHub Actions](/tag/github-actions/)[Infrastructure as Code](/tag/infrastructure-as-code/)

Table of Contents

*   [Prerequisites: What You Need Before You Start](#prerequisites-what-you-need-before-you-start)
*   [What Azure Bicep Replaces (and Why It Exists)](#what-azure-bicep-replaces-and-why-it-exists)
*   [From ARM JSON to a Domain-Specific Language](#from-arm-json-to-a-domain-specific-language)
*   [The Symbolic Name Isn’t the Real Resource Name](#the-symbolic-name-isnt-the-real-resource-name)
*   [How a .bicep File Becomes Running Infrastructure](#how-a-bicep-file-becomes-running-infrastructure)
*   [Why Deployment Order Matters](#why-deployment-order-matters)
*   [How Bicep Infers the Dependency Graph](#how-bicep-infers-the-dependency-graph)
*   [Parameters, Variables, and the Decorators That Keep You Honest](#parameters-variables-and-the-decorators-that-keep-you-honest)
*   [Typed Parameters With Guardrails](#typed-parameters-with-guardrails)
*   [Looping Without Overloading the Azure API](#looping-without-overloading-the-azure-api)
*   [Building Reusable Infrastructure with Modules](#building-reusable-infrastructure-with-modules)
*   [Skipping the Boilerplate with Azure Verified Modules](#skipping-the-boilerplate-with-azure-verified-modules)
*   [Deploying and Validating Changes with What-If](#deploying-and-validating-changes-with-what-if)
*   [Running What-If Before You Deploy](#running-what-if-before-you-deploy)
*   [Reading the Change-Type Table](#reading-the-change-type-table)
*   [Automating Deployment with a GitHub Actions Pipeline](#automating-deployment-with-a-github-actions-pipeline)
*   [A Two-Job Pipeline: Validate, Then Deploy](#a-two-job-pipeline-validate-then-deploy)
*   [Why the Permissions Block Is the Point](#why-the-permissions-block-is-the-point)
*   [Keeping Secrets Out of Your Templates](#keeping-secrets-out-of-your-templates)
*   [Making Bicep Your Default Way to Touch Azure](#making-bicep-your-default-way-to-touch-azure)

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:

![Bicep compile-to-deploy](https://adamtheautomator.com/wp-content/uploads/2026/09/bicep-compile-deploy-flow.png)

## 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

```bash
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](https://docs.github.com/en/actions/writing-workflows/about-workflows) 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:

![GitHub Actions deploy gate](https://adamtheautomator.com/wp-content/uploads/2026/09/github-actions-deploy-gate-scaled.jpg)

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

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-vs-arm-templates%2F&text=Bicep%3A%20Never%20Hand-Write%20Azure%20ARM%20JSON%20Again)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-vs-arm-templates%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-vs-arm-templates%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b819f9960cc3af514f9a0/4edae5c1327c7aaa91f7cd7fb033f216b4ba0310906a424978cd0f2b4bca4345.webp)

### [Azure Data Sovereignty Guardrails: Cut Jurisdictional Risk](/azure-data-sovereignty-guardrails/)

Master Azure data sovereignty with residency policies, encryption keys, operator oversight, and compliance controls to prevent jurisdictional data access.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3075d9c85b2b811696d7c3fb28215d5b/2632cbcaed949b31f4a9b4c4ea73d850076a5034cf55e309a5815dd451ab0388.webp)

### [Stop Scaling Azure SQL: Find Real Performance Issues](/azure-sql-performance-tuning/)

Use Azure's built-in diagnostics to find real performance bottlenecks in your Azure SQL Database instead of scaling up immediately.

![](https://adamtheautomator.com/wp-content/uploads/2026/08/featured_image-7.webp)

### [Build Azure Landing Zones That Prevent Cloud Sprawl](/build-azure-landing-zones/)

Build governed Azure landing zones end to end with the IaC accelerator: management group hierarchies, the eight design areas, Terraform and Bicep deployments,

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
