This is Part 1 of a four-part series for Azure Spring Clean “Policy-as-Code Made Simple: Modern Azure Governance with Bicep”.
I have been working with Azure Policy across multiple projects and environments over the past few years, and the one thing that consistently slows teams down is authoring and maintaining policy definitions in raw JSON. The structure is verbose, the nesting gets deep quickly, and a single misplaced bracket can break an entire deployment.
This series shows some of the recent additions to bicep that help with managing reusable JSON blocks.
Azure Policy definitions are JSON documents at their core. A simple “deny resources without tags” policy looks something like this:
{
"mode": "Indexed",
"parameters": {
"tagName": {
"type": "String",
"metadata": {
"displayName": "Tag Name",
"description": "Name of the tag"
}
}
},
"policyRule": {
"if": {
"field": "[concat('tags[', parameters('tagName'), ']')]",
"exists": "false"
},
"then": {
"effect": "deny"
}
}
}
This is a straightforward policy, and it already has enough nesting and bracket-matching to trip people up, making it hard to manage and maintain. Every definition needs its own JSON file, which leads to many files with subtle differences and makes reviews difficult.
Bicep is a domain specific language that generates ARM Templates and deploys to Azure. This can be used for policy definitions and assignments, it brings the IDE and Agentic tools, and produces a more readable structure. The same tag-enforcement policy in Bicep:
targetScope = 'subscription'
param tagName string
resource policyDefinition 'Microsoft.Authorization/policyDefinitions@2023-04-01' = {
name: 'require-tag-on-resources'
properties: {
displayName: 'Require a tag on resources'
policyType: 'Custom'
mode: 'Indexed'
metadata: {
version: '1.0.0'
category: 'Tags'
}
parameters: {
tagName: {
type: 'String'
metadata: {
displayName: 'Tag Name'
description: 'Name of the tag'
}
}
}
policyRule: {
if: {
field: '[concat(\'tags[\', parameters(\'tagName\'), \']\')]'
exists: 'false'
}
then: {
effect: 'deny'
}
}
}
}
The structure is immediately cleaner. There are no closing braces to count, the property types are validated at compile time, and VS Code gives auto-completion for the resource API schema. The targetScope = 'subscription' declaration makes it explicit where this definition lives.
The catch is that the policyRule block still contains ARM template expressions (the concat and parameters calls). These are evaluated at runtime by the Azure Policy engine, not at deployment time by Bicep. This is an important distinction. Bicep compiles to ARM JSON, but the policy rule expressions are pass-through strings that Azure Policy evaluates later during compliance scans.
Policy definitions and assignments typically target subscription or management group scope. Bicep handles this with the targetScope declaration and the corresponding deployment command:
# Deploy a policy definition at subscription scope
az deployment sub create \
--location uksouth \
--template-file policy-definition.bicep
For management group deployments, the Bicep file needs targetScope = 'managementGroup' instead. This is a common pattern for organisations that manage policies centrally deploying the definitions to a central Management Group and assigning them across multiple subscriptions or lower management groups.
Moving policy authoring to Bicep is not just about syntax. The practical benefits I have found across projects include:
The gotcha is that you still need to understand how ARM template expressions work inside the policyRule block. Bicep does not abstract those away. The policy engine evaluates those expressions at compliance scan time, so they follow ARM function syntax regardless of the authoring language.
Bicep 0.40.2 graduated multi-line string interpolation to GA. In Part 2 I dig into how this and other Bicep features make policy authoring simpler, and in Part 3 I cover multi-line string interpolation in depth.