This is Part 4 of a four-part series for Azure Spring Clean “Policy-as-Code Made Simple: Modern Azure Governance with Bicep”.
In Part 1, Part 2, and Part 3 I covered the shift from JSON to Bicep, the language features that simplify policy authoring, and multi-line string interpolation in detail. In this final part, I want to focus on the structural patterns that make policy-as-code maintainable at scale: Bicep modules for reusability, initiative definitions for grouping related policies, and CI/CD pipelines for safe, repeatable deployments.
These are the patterns I have been refining across governance projects, and they make the difference between a one-off policy deployment and a sustainable policy-as-code practice.
Before getting into the code, the file layout matters. A structure I have found works well for teams managing policies across multiple subscriptions:
policies/
definitions/
require-tag.bicep
audit-storage-https.bicep
deny-public-ip.bicep
rules/
audit-storage-https.json
initiatives/
security-baseline.bicep
tagging-standards.bicep
assignments/
prod-security-baseline.bicep
dev-security-baseline.bicep
modules/
policy-definition.bicep
policy-assignment.bicep
initiative-definition.bicep
parameters/
prod.bicepparam
dev.bicepparam
Policy definitions, initiatives, and assignments each live in their own directory. Reusable modules sit in modules/. Environment-specific configuration goes into .bicepparam files. This separation makes reviews straightforward because a change to a policy rule is clearly distinct from a change to an assignment scope.
The first module pattern I use wraps the policy definition resource with a consistent interface:
targetScope = 'subscription'
@description('Unique name for the policy definition')
param policyName string
@description('Display name shown in the Azure Portal')
param displayName string
@description('Policy category for portal grouping')
param category string = 'General'
@description('The policy rule object')
param policyRule object
@description('Policy parameters schema')
param policyParameters object = {}
@allowed(['All', 'Indexed'])
param mode string = 'Indexed'
resource policyDefinition 'Microsoft.Authorization/policyDefinitions@2023-04-01' = {
name: policyName
properties: {
displayName: displayName
policyType: 'Custom'
mode: mode
metadata: {
version: '1.0.0'
category: category
}
parameters: policyParameters
policyRule: policyRule
}
}
output policyDefinitionId string = policyDefinition.id
output policyDefinitionName string = policyDefinition.name
Every policy definition follows the same shape. The calling template provides the policy name, display name, category, and the rule object. The module handles the boilerplate. The output exposes the definition ID for chaining into assignments or initiatives.
Consuming the module looks like this:
targetScope = 'subscription'
module denyPublicIp 'modules/policy-definition.bicep' = {
name: 'deploy-deny-public-ip'
params: {
policyName: 'deny-public-ip-addresses'
displayName: 'Deny Public IP Address Creation'
category: 'Networking'
mode: 'All'
policyRule: {
if: {
field: 'type'
equals: 'Microsoft.Network/publicIPAddresses'
}
then: {
effect: 'deny'
}
}
}
}
Clean, readable, and consistent across every policy definition in the repository.
Initiatives (policy set definitions) group related policies together. In Bicep, an initiative references the definition IDs of its member policies. This is where the module output values chain together:
targetScope = 'subscription'
param securityPolicyIds array
resource securityInitiative 'Microsoft.Authorization/policySetDefinitions@2023-04-01' = {
name: 'security-baseline-initiative'
properties: {
displayName: 'Security Baseline Initiative'
policyType: 'Custom'
metadata: {
version: '1.0.0'
category: 'Security'
}
parameters: {
effect: {
type: 'String'
metadata: {
displayName: 'Effect'
description: 'The effect for all policies in this initiative'
}
allowedValues: [
'Audit'
'Deny'
'Disabled'
]
defaultValue: 'Audit'
}
}
policyDefinitions: [
for (policyId, index) in securityPolicyIds: {
policyDefinitionId: policyId
policyDefinitionReferenceId: 'policy-${index}'
parameters: {
effect: {
value: '[[parameters(\'effect\')]'
}
}
}
]
}
}
output initiativeId string = securityInitiative.id
The loop over securityPolicyIds builds the policyDefinitions array dynamically. Adding a new policy to the initiative means appending its ID to the input array, not editing the initiative structure. The [[parameters('effect')] expression uses the double-bracket escape so it passes through to the ARM engine as a runtime parameter reference.
A top-level orchestrator Bicep file deploys definitions, the initiative, and the assignment in sequence:
targetScope = 'subscription'
@allowed(['dev', 'test', 'prod'])
param environmentName string
var enforcementMode = environmentName == 'prod' ? 'Default' : 'DoNotEnforce'
var initiativeEffect = environmentName == 'prod' ? 'Deny' : 'Audit'
// Deploy policy definitions
module denyPublicIp 'definitions/deny-public-ip.bicep' = {
name: 'def-deny-public-ip'
}
module auditStorageHttps 'definitions/audit-storage-https.bicep' = {
name: 'def-audit-storage-https'
}
// Deploy initiative
module securityBaseline 'initiatives/security-baseline.bicep' = {
name: 'init-security-baseline'
params: {
securityPolicyIds: [
denyPublicIp.outputs.policyDefinitionId
auditStorageHttps.outputs.policyDefinitionId
]
}
}
// Deploy assignment
resource assignment 'Microsoft.Authorization/policyAssignments@2023-04-01' = {
name: 'assign-security-baseline-${environmentName}'
properties: {
displayName: 'Security Baseline (${environmentName})'
policyDefinitionId: securityBaseline.outputs.initiativeId
enforcementMode: enforcementMode
parameters: {
effect: {
value: initiativeEffect
}
}
nonComplianceMessages: [
{
message: 'This resource does not comply with the security baseline. Review the policy details for remediation steps.'
}
]
}
}
The module outputs chain naturally. Definition IDs feed into the initiative, the initiative ID feeds into the assignment. The environment parameter controls enforcement behaviour throughout the entire stack.
The deployment pipeline needs two stages: validate and deploy. In Azure DevOps, this pattern works reliably:
trigger:
branches:
include:
- main
paths:
include:
- policies/**
stages:
- stage: Validate
jobs:
- job: BicepValidation
steps:
- task: AzureCLI@2
displayName: 'Bicep Build and Validate'
inputs:
azureSubscription: 'policy-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Compile Bicep to check for syntax errors
az bicep build --file policies/main.bicep
# Validate against Azure without deploying
az deployment sub validate \
--location uksouth \
--template-file policies/main.bicep \
--parameters policies/parameters/prod.bicepparam
- stage: DeployDev
dependsOn: Validate
jobs:
- deployment: PolicyDeployDev
environment: 'governance-dev'
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
displayName: 'Deploy Policies to Dev'
inputs:
azureSubscription: 'policy-service-connection-dev'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az deployment sub create \
--location uksouth \
--template-file policies/main.bicep \
--parameters policies/parameters/dev.bicepparam
- stage: DeployProd
dependsOn: DeployDev
jobs:
- deployment: PolicyDeployProd
environment: 'governance-prod'
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
displayName: 'Deploy Policies to Prod'
inputs:
azureSubscription: 'policy-service-connection-prod'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az deployment sub create \
--location uksouth \
--template-file policies/main.bicep \
--parameters policies/parameters/prod.bicepparam
The paths filter ensures the pipeline only triggers when policy files change. The validation stage catches Bicep compilation errors and ARM template validation failures before anything is deployed. The environment gates on the deployment stages provide manual approval checkpoints for production changes.
The az bicep build step is particularly useful. It compiles the Bicep file to ARM JSON and reports any errors. Combined with az deployment sub validate, this gives high confidence that the deployment will succeed before committing to it.
Across these four posts, the core message is simple: Bicep turns Azure Policy management from an error-prone, file-heavy process into something that feels like normal infrastructure-as-code. The language features in Parts 2 and 3 address the authoring pain. The module patterns and CI/CD integration in this post address the operational pain.
Key takeaways from the series:
loadTextContent solve the JSON-embedding problem, covered in depth in Part 3I look forward to continuing to refine these patterns as Bicep’s feature set grows. The experimental resource existence checks and MCP tooling I covered in my Bicep 0.40.2 post will likely open up additional workflows for policy management in the coming months.