Introduction

In our last article, I explained why Enterprise Policy as Code (EPAC) is so important for Azure governance. But EPAC without automation is like Git without pull requests. You’re missing half the value.

This week, we’re building the CI/CD pipeline that transforms policy changes from “I hope this works” to “this was tested, reviewed, and automatically deployed.”

By the time you’ve finished reading this article, you’ll have a full set of pipeline architecture that supports:

  • Pull request validation (catch mistakes before they merge)
  • Environment promotion (DEV → Non-Prod → Production)
  • Manual approval gates (humans in the loop where it matters)
  • Automated remediation (fix non-compliant resources)

Pipeline Architecture Overview

A mature EPAC deployment uses multiple pipelines working together:

┌─────────────────────────────────────────────────────────────────┐
│ Git Repository │
│ (Policy Definitions, Assignments, Exemptions) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PR Validation Pipeline │
│ • Syntax validation │
│ • Build deployment plan │
│ • Post plan as PR comment │
└─────────────────────────────────────────────────────────────────┘
▼ (merge)
┌─────────────────────────────────────────────────────────────────┐
│ DEV Deployment Pipeline │
│ • Deploy to DEV environment │
│ • Run compliance tests │
│ • Automatic on merge to main │
└─────────────────────────────────────────────────────────────────┘
▼ (manual trigger)
┌─────────────────────────────────────────────────────────────────┐
│ Non-Prod Deployment Pipeline │
│ • Deploy to staging/test management groups │
│ • Extended validation │
│ • Manual approval gate │
└─────────────────────────────────────────────────────────────────┘
▼ (manual approval)
┌─────────────────────────────────────────────────────────────────┐
│ PROD Deployment Pipeline │
│ • Deploy to production management groups │
│ • Create remediation tasks │
│ • Generate compliance documentation │
└─────────────────────────────────────────────────────────────────┘

Service Principal Configuration

EPAC pipelines need service principals with specific permissions.

Required Permissions

PermissionScopePurpose
ReaderTenant Root GroupDiscover existing policies
Resource Policy ContributorTarget Management GroupsCreate/modify policies
User Access AdministratorTarget Management GroupsCreate role assignments for remediation

Creating the Service Principals

You’ll need separate service principals for each environment to maintain isolation:

Terminal window
# Create App Registration for EPAC Production
$prodApp = New-AzADApplication -DisplayName "epac-prod-deployer"
$prodSp = New-AzADServicePrincipal -ApplicationId $prodApp.AppId
# Create App Registration for EPAC Non-Production
$nonprodApp = New-AzADApplication -DisplayName "epac-nonprod-deployer"
$nonprodSp = New-AzADServicePrincipal -ApplicationId $nonprodApp.AppId
# Create App Registration for EPAC Development
$devApp = New-AzADApplication -DisplayName "epac-dev-deployer"
$devSp = New-AzADServicePrincipal -ApplicationId $devApp.AppId

Assigning Permissions

Terminal window
# Example: Assign permissions for production deployer
$prodMgId = "/providers/Microsoft.Management/managementGroups/production"
# Reader at tenant root for policy discovery
New-AzRoleAssignment `
-ObjectId $prodSp.Id `
-RoleDefinitionName "Reader" `
-Scope "/providers/Microsoft.Management/managementGroups/tenant-root"
# Resource Policy Contributor for policy management
New-AzRoleAssignment `
-ObjectId $prodSp.Id `
-RoleDefinitionName "Resource Policy Contributor" `
-Scope $prodMgId
# User Access Administrator for remediation role assignments
New-AzRoleAssignment `
-ObjectId $prodSp.Id `
-RoleDefinitionName "User Access Administrator" `
-Scope $prodMgId

Azure DevOps Pipeline: PR Validation

This pipeline runs on every pull request, validating syntax and showing what would change.

azure-pipelines-pr.yml

trigger: none
pr:
branches:
include:
- main
paths:
include:
- Definitions/**
- Scripts/**
pool:
vmImage: 'ubuntu-latest'
variables:
- group: epac-dev-credentials
stages:
- stage: Validate
displayName: 'Validate Policy Changes'
jobs:
- job: BuildPlan
displayName: 'Build Deployment Plan'
steps:
- task: PowerShell@2
displayName: 'Install EPAC Module'
inputs:
targetType: 'inline'
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name EnterprisePolicyAsCode -Force -AllowClobber
pwsh: true
- task: AzurePowerShell@5
displayName: 'Build Plan for DEV'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: PowerShell@2
displayName: 'Generate Plan Summary'
inputs:
targetType: 'inline'
script: |
$planPath = "$(Build.ArtifactStagingDirectory)/plans"
$summary = Get-Content "$planPath/policy-plan.json" | ConvertFrom-Json
$markdown = @"
## EPAC Deployment Plan Summary
| Action | Count |
|--------|-------|
| New Definitions | $($summary.policyDefinitions.new.Count) |
| Updated Definitions | $($summary.policyDefinitions.update.Count) |
| Deleted Definitions | $($summary.policyDefinitions.delete.Count) |
| New Assignments | $($summary.policyAssignments.new.Count) |
| Updated Assignments | $($summary.policyAssignments.update.Count) |
| Deleted Assignments | $($summary.policyAssignments.delete.Count) |
Review the full plan in the pipeline artifacts.
"@
$markdown | Out-File "$(Build.ArtifactStagingDirectory)/plan-summary.md"
pwsh: true
- task: PublishBuildArtifacts@1
displayName: 'Publish Plan Artifacts'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/plans'
ArtifactName: 'deployment-plans'
- task: GitHubComment@0
displayName: 'Post Plan to PR'
condition: eq(variables['Build.Reason'], 'PullRequest')
inputs:
gitHubConnection: 'github-connection'
repositoryName: '$(Build.Repository.Name)'
comment: |
$(cat $(Build.ArtifactStagingDirectory)/plan-summary.md)

Azure DevOps Pipeline: Environment Deployment

This pipeline handles the actual deployment across environments with appropriate gates.

azure-pipelines-deploy.yml

trigger:
branches:
include:
- main
paths:
include:
- Definitions/**
pr: none
pool:
vmImage: 'ubuntu-latest'
stages:
# ============================================
# DEV DEPLOYMENT - Automatic
# ============================================
- stage: DeployDev
displayName: 'Deploy to DEV'
variables:
- group: epac-dev-credentials
jobs:
- job: DeployPolicies
displayName: 'Deploy Policies to DEV'
steps:
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Build DEV Plan'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/dev-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy DEV Policies'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-PolicyPlan `
-PacEnvironmentSelector "dev" `
-InputFolder "$(Build.ArtifactStagingDirectory)/dev-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy DEV Role Assignments'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-RolesPlan `
-PacEnvironmentSelector "dev" `
-InputFolder "$(Build.ArtifactStagingDirectory)/dev-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
# ============================================
# NON-PROD DEPLOYMENT - Manual Trigger
# ============================================
- stage: DeployNonProd
displayName: 'Deploy to Non-Prod'
dependsOn: DeployDev
condition: succeeded()
variables:
- group: epac-nonprod-credentials
jobs:
- deployment: DeployPolicies
displayName: 'Deploy Policies to Non-Prod'
environment: 'epac-nonprod' # Requires approval
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Build Non-Prod Plan'
inputs:
azureSubscription: 'epac-nonprod-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "nonprod" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/nonprod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy Non-Prod Policies'
inputs:
azureSubscription: 'epac-nonprod-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-PolicyPlan `
-PacEnvironmentSelector "nonprod" `
-InputFolder "$(Build.ArtifactStagingDirectory)/nonprod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
# ============================================
# PRODUCTION DEPLOYMENT - Requires Approval
# ============================================
- stage: DeployProd
displayName: 'Deploy to Production'
dependsOn: DeployNonProd
condition: succeeded()
variables:
- group: epac-prod-credentials
jobs:
- deployment: DeployPolicies
displayName: 'Deploy Policies to Production'
environment: 'epac-production' # Requires approval
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Build Production Plan'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "prod" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/prod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy Production Policies'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-PolicyPlan `
-PacEnvironmentSelector "prod" `
-InputFolder "$(Build.ArtifactStagingDirectory)/prod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy Production Role Assignments'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-RolesPlan `
-PacEnvironmentSelector "prod" `
-InputFolder "$(Build.ArtifactStagingDirectory)/prod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Generate Compliance Documentation'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Build-PolicyDocumentation `
-PacEnvironmentSelector "prod" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/docs"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: PublishBuildArtifacts@1
displayName: 'Publish Documentation'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/docs'
ArtifactName: 'compliance-docs'

templates/install-epac.yml

steps:
- task: PowerShell@2
displayName: 'Install EPAC Module'
inputs:
targetType: 'inline'
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name EnterprisePolicyAsCode -Force -AllowClobber
# Verify installation
$module = Get-Module -Name EnterprisePolicyAsCode -ListAvailable
Write-Host "Installed EPAC version: $($module.Version)"
pwsh: true

Environment Configuration

EPAC uses a configuration file to define environments. Create this at the root of your repository:

global-settings.jsonc

{
"$schema": "https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/global-settings-schema.json",
"pacOwnerId": "your-unique-guid-here",
"pacEnvironments": [
{
"pacSelector": "dev",
"cloud": "AzureCloud",
"tenantId": "your-tenant-id",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/dev",
"desiredState": {
"strategy": "full",
"keepDfcSecurityAssignments": true
}
},
{
"pacSelector": "nonprod",
"cloud": "AzureCloud",
"tenantId": "your-tenant-id",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/nonprod",
"desiredState": {
"strategy": "full",
"keepDfcSecurityAssignments": true
}
},
{
"pacSelector": "prod",
"cloud": "AzureCloud",
"tenantId": "your-tenant-id",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/production",
"desiredState": {
"strategy": "full",
"keepDfcSecurityAssignments": true
}
}
]
}

Remediation Pipeline

Policies with deployIfNotExists or modify effects need remediation tasks to fix existing non-compliant resources:

azure-pipelines-remediation.yml

trigger: none # Manual or scheduled only
schedules:
- cron: "0 6 * * *" # Run daily at 6 AM
displayName: 'Daily Remediation'
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
parameters:
- name: environment
displayName: 'Environment to Remediate'
type: string
default: 'prod'
values:
- dev
- nonprod
- prod
variables:
- group: epac-${{ parameters.environment }}-credentials
stages:
- stage: Remediate
displayName: 'Create Remediation Tasks'
jobs:
- job: RemediateNonCompliant
displayName: 'Remediate Non-Compliant Resources'
steps:
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Create Remediation Tasks'
inputs:
azureSubscription: 'epac-${{ parameters.environment }}-connection'
ScriptType: 'InlineScript'
Inline: |
# Get all non-compliant policy assignments
$assignments = Get-AzPolicyAssignment -Scope $env:DEPLOYMENT_SCOPE
foreach ($assignment in $assignments) {
$compliance = Get-AzPolicyState `
-PolicyAssignmentName $assignment.Name `
-Filter "ComplianceState eq 'NonCompliant'" `
-Top 1
if ($compliance) {
Write-Host "Creating remediation for: $($assignment.Properties.DisplayName)"
Start-AzPolicyRemediation `
-Name "remediation-$(Get-Date -Format 'yyyyMMdd-HHmmss')" `
-PolicyAssignmentId $assignment.PolicyAssignmentId `
-ResourceDiscoveryMode ReEvaluateCompliance
}
}
azurePowerShellVersion: 'LatestVersion'
pwsh: true
env:
DEPLOYMENT_SCOPE: '/providers/Microsoft.Management/managementGroups/${{ parameters.environment }}'

GitHub Flow for Policy Changes

When using EPAC, follow this workflow for policy changes:

1. Create Feature Branch

Terminal window
git checkout -b feature/add-storage-encryption-policy

2. Make Changes

Add or modify policy definitions, assignments, or exemptions in the Definitions/ folder.

3. Test Locally (Optional)

Terminal window
# Build plan without deploying
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "./local-test"
# Review the plan
Get-Content "./local-test/policy-plan.json" | ConvertFrom-Json | Format-List

4. Create Pull Request

Terminal window
git add .
git commit -m "Add storage encryption policy requirement"
git push origin feature/add-storage-encryption-policy

5. Review PR Validation

  • Pipeline automatically runs
  • Plan summary posted as PR comment
  • Reviewers can see exactly what will change

6. Merge and Deploy

  • Merge triggers DEV deployment
  • Manual trigger for Non-Prod
  • Approval required for Production

Handling Emergencies

Sometimes you need to bypass the normal flow. Here’s how to handle emergencies safely:

Hotfix Process

azure-pipelines-hotfix.yml
trigger: none
parameters:
- name: environment
type: string
values: ['nonprod', 'prod']
- name: justification
type: string
stages:
- stage: EmergencyDeploy
displayName: 'Emergency Policy Deployment'
jobs:
- deployment: HotfixDeploy
environment: 'epac-${{ parameters.environment }}-emergency'
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Emergency deployment requested"
echo "Environment: ${{ parameters.environment }}"
echo "Justification: ${{ parameters.justification }}"
displayName: 'Log Emergency Request'
# ... deployment steps

Post-Emergency Requirements

  1. Create a PR with the emergency changes (even after deployment)
  2. Document the incident
  3. Ensure changes flow through normal promotion

Key Takeaways

  1. Separate service principals per environment: Isolation prevents accidents and limits blast radius.

  2. PR validation catches mistakes early: Always review the plan before merging.

  3. Manual gates for production: Automation is great, but humans should approve production changes.

  4. Remediation is separate: Don’t mix policy deployment with remediation—they have different risk profiles.

  5. Emergency processes exist: Plan for exceptions, but make them auditable.


Sources

  1. Microsoft, “Enterprise Policy as Code,” Cloud Adoption Framework, https://learn.microsoft.com/azure/cloud-adoption-framework/ready/policy-management/enterprise-policy-as-code

  2. Microsoft, “Azure DevOps Pipelines,” Azure DevOps Documentation, https://learn.microsoft.com/azure/devops/pipelines/

  3. Microsoft, “Resource Policy Contributor Role,” Azure RBAC Documentation, https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#resource-policy-contributor

  4. Microsoft, “Azure Policy Remediation,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources

  5. Azure, “EPAC Pipeline Examples,” GitHub, https://github.com/Azure/enterprise-azure-policy-as-code/tree/main/StarterKit/Pipelines