Cyber Intelligence
Cloud Security11 min read

Getting Started with Azure Bicep: Infrastructure as Code Made Simple

Azure Bicep makes deploying Azure resources easier than ARM templates. Learn Bicep syntax, modules, secure Key Vault references, least-privilege deployment identities, and how it compares to Terraform for Azure-only teams.

I
Microsoft Cloud Solution Architect
Getting Started with Azure Bicep: Infrastructure as Code Made Simple infographic showing key Cloud Security concepts and controls
Getting Started with Azure Bicep: Infrastructure as Code Made Simple infographic showing key Cloud Security concepts and controls
AzureBicepInfrastructure as CodeARMDevOps

What is Azure Bicep?

Azure Bicep is a domain-specific language (DSL) from Microsoft for deploying Azure resources declaratively. It compiles to standard ARM template JSON, so it runs on the same Azure Resource Manager deployment engine as ARM templates, but the syntax is far more concise and readable to write and review.

For a security team, that readability is the actual payoff. A shorter, well-structured template is one a reviewer can read in a pull request, which means a wide-open storage account, a missing diagnostic setting, or a role assignment scoped too broadly gets caught before merge, not after an audit finds it.

Why Bicep over ARM templates

If you've ever written an ARM template by hand, you know the pain: hundreds of lines of nested JSON, bracket-matching by feel, and error messages that reference line numbers in a compiled template you never wrote yourself.

Bicep fixes this without changing what actually gets deployed. It compiles down to the same ARM JSON, runs through the same Azure Resource Manager validation, and supports the same resource providers and API versions. What changes is the authoring experience: type checking, IntelliSense, automatic dependency resolution, and syntax you can read top to bottom.

Setting up your environment

Install Bicep

If you have the Azure CLI installed, Bicep is already there. Run az bicep version to check your version, and az bicep upgrade to pull the latest release. The Bicep CLI and the ARM deployment engine ship separately, so keeping the CLI current matters more than it sounds: newer language features, like user-defined types and the .bicepparam parameter format, need a recent CLI even when your subscription already supports the underlying resource types.

Install the VS Code extension

The Bicep extension for VS Code gives you IntelliSense, real-time validation, and a visualizer that renders your resource dependency graph. It flags problems before you ever run a deployment: unresolved parameter references, outdated API versions, and property names that don't exist on a given resource type.

Bicep basics

Parameters

Parameters customize a deployment without touching the template itself: environment name, region, SKU, and so on.

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

@allowed(['Standard_LRS', 'Standard_GRS', 'Premium_LRS'])
param storageSku string = 'Standard_LRS'

@secure()
param adminPassword string

The @secure() decorator does more than document intent: it stops Azure Resource Manager from writing the parameter's value into the deployment history, so it doesn't show up in Activity Log entries or the deployment operations blade for anyone with Reader access to the resource group. It does not, by itself, stop the value from being hardcoded into a parameter file that gets committed to source control. Those are two separate problems, and the fix for the second one is the Key Vault reference pattern covered later in this article.

Variables

Variables build derived values from parameters: concatenated names, conditional logic, computed tags. They exist for readability, not for anything Resource Manager treats specially.

Resources

Resources are the actual deployment targets.

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: { name: storageSku }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    supportsHttpsTrafficOnly: true
  }
}

Notice the properties block enforces TLS 1.2 and blocks public blob access explicitly, instead of leaving them to whatever the API defaults to. Bicep also resolves deployment order automatically from resource references: if a role assignment references this storage account's resource ID, Resource Manager deploys the storage account first with no explicit dependsOn needed. You only need dependsOn for dependencies that don't show up as a property reference, most often RBAC role assignments that depend on an identity existing but don't reference it directly.

Outputs

Outputs return values from a deployment for use in scripts or other templates, most commonly resource IDs, endpoint URLs, or generated names.

One rule worth internalizing: never output a value marked @secure(). Deployment outputs are visible in the deployment history to anyone with read access to the resource group, which defeats the point of marking the parameter secure in the first place. If a module needs to hand a secret to its caller, that's a sign the secret should come from Key Vault at the point of use instead.

Previewing changes before you deploy: what-if

The what-if operation predicts what a deployment will change without actually changing anything, which makes it the closest thing Bicep has to a dry run:

az deployment group what-if \
  --resource-group rg-myproject-prod \
  --template-file main.bicep \
  --parameters main.bicepparam

The output uses seven change types: Create, Delete, Modify, NoChange, NoEffect, Ignore, and Deploy. Create and Delete are self-explanatory; Modify means a property will change; NoEffect flags read-only properties the API silently ignores. Ignore shows up when Resource Manager hits its expansion limits, which cap out around 500 nested templates or 5 minutes of expansion time, worth knowing before assuming a large multi-module deployment was fully analyzed (Microsoft Learn: Bicep what-if operation).

In a CI/CD pipeline, gate the actual deployment behind a human, or an automated policy check, reviewing the what-if output for production resource groups, specifically watching for unexpected Delete entries. What-if output does contain noise: properties Resource Manager reports as changing because they weren't set explicitly in the template even though the deployed default already matches. Read the diff, don't just count the lines.

Deploying your Bicep files

Using the Azure CLI

Create a resource group, then deploy:

az group create --name rg-myproject-dev --location eastus
az deployment group create \
  --resource-group rg-myproject-dev \
  --template-file main.bicep \
  --parameters projectName=myproject environment=dev

Using parameter files

For repeatable deployments across environments, externalize parameter values instead of passing them on the command line. Bicep has its own native parameter file format, .bicepparam, which has replaced the older JSON parameters file for new projects:

using './main.bicep'

param environment = 'prod'
param location = 'eastus'
param storageSku = 'Standard_GRS'

Keep one .bicepparam file per environment (dev, staging, prod) rather than a single file with conditional logic buried inside it. It's easier to review a prod parameter file in a pull request when it's the only thing that changed, and harder to accidentally deploy a dev-sized SKU to production.

Modules: organizing your code

As a template grows past a few dozen resources, split it into modules: separate .bicep files for reusable components like storage accounts, networking, or a web app, each with its own parameters and outputs.

module storage './modules/storage.bicep' = {
  name: 'deployStorage'
  params: {
    storageAccountName: storageAccountName
    location: location
    storageSku: storageSku
  }
}

module webApp './modules/webapp.bicep' = {
  name: 'deployWebApp'
  params: {
    webAppName: webAppName
    location: location
    storageAccountName: storage.outputs.storageAccountName
  }
}

The webApp module consumes an output from the storage module directly (storage.outputs.storageAccountName), and Bicep resolves the deployment order from that reference without a manual dependsOn. For components you don't want to author from scratch, Microsoft and the community publish the Azure Verified Modules registry: reviewed, versioned Bicep modules for common resource patterns that already bake in the kind of hardening this article covers (Microsoft Learn: Bicep modules). For reuse across your own teams, publish modules to a private registry backed by an Azure Container Registry with bicep publish, and reference them by version tag, so a breaking change in one team's module doesn't silently break another team's deployment.

Securing Bicep deployments

Stop hardcoding secrets: reference Key Vault instead

A parameter value in a Bicep file or a JSON parameters file that gets committed to git is a secret in git history. Deleting the file later doesn't remove it: history retains every commit, and rotating the actual credential is the only real remediation once that happens.

Bicep's getSecret() function retrieves a value from an existing Key Vault at deployment time without the caller ever seeing the plaintext. It only works inside the params block of a module, and only for parameters marked @secure() (Microsoft Learn: Key Vault parameter reference):

resource kv 'Microsoft.KeyVault/vaults@2025-05-01' existing = {
  name: keyVaultName
  scope: resourceGroup(subscriptionId, keyVaultResourceGroup)
}

module sql './modules/sql.bicep' = {
  name: 'deploySql'
  params: {
    sqlServerName: sqlServerName
    adminLogin: adminLogin
    adminPassword: kv.getSecret('sqlAdminPassword')
  }
}

If you'd rather not wire a module for this, .bicepparam files support the same pattern with az.getSecret():

using './main.bicep'

param sqlServerName = 'sql-myproject-prod'
param adminPassword = az.getSecret('<subscription-id>', '<rg-name>', '<key-vault-name>', 'sqlAdminPassword')

Either way, the Key Vault itself needs enabledForTemplateDeployment set to true, and the identity running the deployment needs the Microsoft.KeyVault/vaults/deploy/action permission scoped to that vault, not broad Key Vault Contributor or Owner access. Microsoft's own documentation recommends a custom role limited to exactly that one action for this reason, and it's a pattern worth copying rather than reinventing.

Scope deployment identities to least privilege

The identity running az deployment group create typically needs write access to every resource type in the template, plus full rights on Microsoft.Resources/deployments. It's common for pipelines to solve this once by granting Owner at the subscription scope, then reusing that same service principal for every environment and every project after. That leaves a single compromised credential one step away from every resource group in the subscription.

Two changes fix most of this. First, scope the identity per environment: a deployment identity for the dev resource group should not also hold write access to prod. Second, stop storing long-lived client secrets for the identity at all. GitHub Actions and Azure DevOps both support OpenID Connect federated credentials, where a Microsoft Entra app registration or user-assigned managed identity trusts short-lived tokens issued by the pipeline itself, with no secret to leak, rotate, or expire in a way that breaks a deployment overnight (Microsoft Learn: connect GitHub Actions to Azure with OpenID Connect):

permissions:
  id-token: write

steps:
  - uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

The secrets referenced here are identifiers, not credentials: nothing in this workflow is a client secret that expires or needs rotation. For the deeper walkthrough of wiring security scanning and gated approvals into the rest of the pipeline around this step, see our guide to integrating security into CI/CD pipelines.

Enforce guardrails with policy as code

A well-written Bicep module still deploys whatever a developer asks it to deploy. Azure Policy is the layer that blocks a resource from existing at all if it violates a rule, regardless of which template created it or who ran the deployment.

Assign policies as part of the same Bicep pipeline that deploys resources, using the Microsoft.Authorization/policyAssignments resource type, so policy coverage grows alongside infrastructure instead of trailing behind it as a separate manual task:

resource denyPublicStorage 'Microsoft.Authorization/policyAssignments@2024-04-01' = {
  name: 'deny-public-blob-access'
  properties: {
    displayName: 'Deny storage accounts with public blob access'
    policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/<built-in-policy-id>'
    enforcementMode: 'Default'
  }
}

For a subscription-wide baseline, mandatory tags, denied public IPs on VMs, required diagnostic settings, start from Microsoft's built-in policy definitions rather than writing custom policy JSON from scratch. Our Azure landing zone security baseline guide covers how to structure policy assignments across an entire landing zone, not just a single resource group.

Deployment stacks add a second, complementary layer: a deny setting (DenyDelete or DenyWriteAndDelete) on a stack blocks control-plane changes to its managed resources from anyone outside an explicit exclusion list, independent of what Azure Policy allows. The failure mode section below covers what that buys you.

Common failure modes

No state file means drift is invisible until you go looking

Terraform tracks deployed resources in a state file and diffs against it on every run. Bicep and ARM don't maintain an equivalent by default: each deployment is evaluated against the live resource graph at deploy time, so a change made directly in the Azure portal, an emergency fix during an incident being the usual culprit, doesn't get flagged anywhere until the next what-if run, if anyone thinks to run one, or the next full deployment silently overwrites it back to the templated value.

Deployment stacks close part of this gap. A stack tracks its own managed resource list independent of what-if, and its deny settings can block changes to managed resources from anyone outside an excluded identity list, catching portal drift attempts as they happen rather than after the fact (Microsoft Learn: Bicep deployment stacks). They require Azure CLI 2.61.0 or later or Azure PowerShell 12.0.0 or later, and the exclusion list for deny settings caps out at five principals, so plan to exclude a group, your CI/CD service connection, your break-glass admins, rather than five individual accounts.

Secret leakage in parameter files

The most common secret leak isn't a value hardcoded in main.bicep, it's a JSON parameters file with a plaintext value, committed once during a rushed deployment, that nobody remembers to remove afterward. Enforce the Key Vault reference pattern above, add parameter files matching a *.local.json convention to .gitignore for anything containing real values, and run secret scanning in the pipeline itself so a leaked credential gets caught at commit time instead of at the next security review.

Missing RBAC scoping on deployment identities

Covered above, but worth repeating as its own failure mode: a deployment identity with Owner at subscription scope is a single point of failure for every resource group underneath it. Audit what your CI/CD service principals and managed identities actually hold on a recurring schedule, not just at the moment you set them up.

Treating what-if output as infallible

What-if can't evaluate nondeterministic functions like utcNow() or newGuid(), references to resources outside the current template, or most resource functions like listKeys(). Those show up as unresolved expressions in the diff rather than real predictions. Teams that don't know this either rubber-stamp what-if output that's actually flagging noise, or panic over a Modify entry that isn't a real change. Read what the diff is actually telling you, not just whether it's red or green.

Bicep vs Terraform for Azure-only shops

Both compile down to declarative infrastructure, both support modules, and both are legitimate choices. The decision usually comes down to how much of your estate is Azure-only versus multi-cloud.

Choose BicepChoose Terraform
Cloud footprintAzure-only, no plans to add AWS or GCPMulti-cloud or hybrid, want one tool across providers
State managementFine with no state file; Resource Manager is the source of truthWant an explicit state file and drift detection built into the tool
API freshnessNeed same-day support for new Azure resource types and API versionsComfortable waiting on the AzureRM provider to catch up
Team skill setTeam already knows ARM and Azure-native toolingTeam already standardized on Terraform and HCL for other clouds
Tooling ecosystemWants native VS Code IntelliSense, what-if, deployment stacksWants Terraform Cloud or Enterprise, Sentinel policy, existing HCL modules

Neither answer is permanent. Plenty of Azure-only shops run Bicep for infrastructure and Terraform for the handful of non-Azure resources, DNS at a third-party registrar, a SaaS provider with its own Terraform provider, they still need to manage. Our Terraform best practices for real teams guide covers the operational side of that decision if Terraform is the direction you end up leaning.

Checklist: modules vs a monolithic template

Split into a module when:

  • The resource pattern is reused across more than one workload, a standard storage account configuration deployed for every project, for example
  • A different team owns the update cadence for that piece: networking owns the vnet module, app teams own their own web app module
  • You want to what-if or test a component in isolation before wiring it into the full deployment
  • You are publishing the pattern for reuse across the org through a module registry

Keep it monolithic when:

  • It's a single environment for a single small project with no reuse on the horizon
  • You are prototyping and the indirection of a module boundary costs more than it saves right now
  • The resources are tightly coupled enough that splitting them just adds a parameter and output relay with no real decoupling

Tips for success

  1. Use the VS Code extension. Real-time validation saves hours compared to finding out about a syntax error from a failed deployment.
  2. Start small. Convert one resource at a time from an existing ARM template with az bicep decompile, then clean up the output rather than trusting it as final.
  3. Use what-if before every production deployment, not just when something feels risky.
  4. Version your templates in git, with a branch or folder structure that matches your environments.
  5. Automate deployments. Pair Bicep with Azure DevOps Pipelines for gate-protected, multi-environment deployments, and authenticate the pipeline with OIDC instead of a stored secret.
  6. Leverage existing modules. Check the Azure Verified Modules registry before writing a resource pattern from scratch.
  7. Scope every deployment identity to the resource group it deploys to, not the subscription.

Bicep makes infrastructure as code accessible without making it careless. Start with simple deployments, build a library of modules and policy assignments as you go, and treat secrets, RBAC scope, and drift the same way you'd treat any other production risk: designed for from the start, not bolted on after an incident.

Frequently asked questions

What is Azure Bicep?

Azure Bicep is a domain-specific language (DSL) from Microsoft for deploying Azure resources declaratively. It compiles down to standard ARM template JSON, so it runs on the same deployment engine, but the syntax is far more concise and readable than hand-written JSON.

Is Bicep the same as ARM templates?

No, but they are closely related. Bicep is a syntax layer on top of ARM templates: every Bicep file compiles to an equivalent ARM JSON template before deployment. You get the same resource providers, API versions, and deployment behavior as ARM, with a cleaner authoring experience and built-in tooling like type checking and IntelliSense.

How do I convert an existing ARM template to Bicep?

Use the Azure CLI command az bicep decompile, pointing it at your ARM template JSON file. It generates a starting Bicep file that you should then review and clean up, since automated decompilation sometimes produces verbose variable names or structures that benefit from manual simplification.

What does the Bicep What-If command do?

az deployment group what-if runs a dry run of your deployment, showing exactly what resources will be created, modified, or deleted without actually making changes. This is essential before running deployments against production resource groups, since it catches unintended changes such as a resource being recreated due to an immutable property change.

Do I need to install anything extra to use Bicep?

If you have a recent version of the Azure CLI, Bicep is bundled in. Run az bicep version to confirm, and az bicep upgrade to update to the latest version. For local development, install the Bicep extension for VS Code to get syntax highlighting, IntelliSense, and inline validation as you write templates.

How do I avoid hardcoding secrets in Bicep parameter files?

Use the getSecret() function inside a module's params block, or az.getSecret() in a .bicepparam file, to pull the value from Azure Key Vault at deployment time instead of writing it into a parameter file. Scope the deployment identity to only the Microsoft.KeyVault/vaults/deploy/action permission on that vault, not broad Key Vault access, and never commit a plaintext secret to a JSON parameters file.

What is a Bicep deployment stack?

A deployment stack (Microsoft.Resources/deploymentStacks) manages a group of resources as a single unit and can apply deny settings that block changes to those resources from anyone outside an approved list, including changes made directly in the Azure portal. It requires Azure CLI 2.61.0 or later or Azure PowerShell 12.0.0 or later, and is the closest thing Bicep has to Terraform-style drift protection.

Free download

Cloud Security Checklist

A 20-point hardening checklist for AWS, Azure, and GCP workloads.

No spam. Unsubscribe anytime.

Continue Learning

Cloud Security Engineer Roadmap

Protect cloud workloads at scale.

Start the Intermediate Path13h · 4 topics · 10 quiz questions
I

Microsoft Cloud Solution Architect

Cloud Solution Architect with deep expertise in Microsoft Azure and a strong background in systems and IT infrastructure. Passionate about cloud technologies, security best practices, and helping organizations modernize their infrastructure.

Share this article

Questions & Answers

Ask a Question

0/2000 characters

Your email is used for moderation only and will not be displayed.

Related Articles

Need Help with Your Security?

Our team of security experts can help you implement the strategies discussed in this article.

Contact Us