Azure Spring Clean 2026 : Policy-as-Code Made Simple: Reusable Modules and CI/CD Integration (Part 4)

Azure Spring Clean 2026 : Policy-as-Code Made Simple: Reusable Modules and CI/CD Integration (Part 4)

This is Part 4 of a four-part series for Azure Spring Clean “Policy-as-Code Made Simple: Modern Azure Governance with Bicep”.

Introduction

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.

Repository Structure

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.

A Reusable Policy Definition Module

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.

Initiative Definitions

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.

Wiring It Together with an Orchestrator

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.

CI/CD Pipeline Integration

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.

Wrapping Up the Series

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:

  • Bicep provides compile-time validation and IntelliSense for policy definitions, catching errors early
  • Multi-line interpolated strings and loadTextContent solve the JSON-embedding problem, covered in depth in Part 3
  • Loops and conditional expressions eliminate file duplication across environments
  • Reusable modules enforce consistency across policy definitions and assignments
  • CI/CD pipelines with validation gates make policy deployment safe and repeatable

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

Further Resources