Why EPAC needs CI/CD pipelines
In our last article, EPAC Introduction, 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
| Permission | Scope | Purpose |
|---|---|---|
| Reader | Tenant Root Group | Discover existing policies |
| Resource Policy Contributor | Target Management Groups | Create/modify policies |
| Role Based Access Control Administrator | Target Management Groups | Create role assignments for remediation |
Creating the Service Principals
You’ll need separate service principals for each environment to maintain isolation:
# 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.AppIdAssigning Permissions
# Example: Assign permissions for production deployer$prodMgId = "/providers/Microsoft.Management/managementGroups/production"
# Reader at tenant root for policy discoveryNew-AzRoleAssignment ` -ObjectId $prodSp.Id ` -RoleDefinitionName "Reader" ` -Scope "/providers/Microsoft.Management/managementGroups/tenant-root"
# Resource Policy Contributor for policy managementNew-AzRoleAssignment ` -ObjectId $prodSp.Id ` -RoleDefinitionName "Resource Policy Contributor" ` -Scope $prodMgId
# Role Based Access Control Administrator for remediation role assignments# Preferred over User Access Administrator - supports ABAC conditions to restrict assignable rolesNew-AzRoleAssignment ` -ObjectId $prodSp.Id ` -RoleDefinitionName "Role Based Access Control Administrator" ` -Scope $prodMgIdAzure 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'
# NOTE: GitHubComment@0 is a GitHub Actions task and does not exist # in Azure DevOps. The $(cat ...) shell expansion also does not work # in AzDO YAML fields. Instead, read the file into a variable first, # then post a comment via the Azure DevOps REST API. - task: PowerShell@2 displayName: 'Set Plan Summary Variable' condition: eq(variables['Build.Reason'], 'PullRequest') inputs: targetType: 'inline' script: | $summary = Get-Content "$(Build.ArtifactStagingDirectory)/plan-summary.md" -Raw Write-Host "##vso[task.setvariable variable=PlanSummary]$summary" pwsh: true
- task: PowerShell@2 displayName: 'Post Plan Comment to PR' condition: eq(variables['Build.Reason'], 'PullRequest') inputs: targetType: 'inline' script: | $commentBody = @{ comments = @(@{ parentCommentId = 0; content = "$(PlanSummary)"; commentType = 1 }); status = 1 } | ConvertTo-Json -Depth 5 $prId = "$(System.PullRequest.PullRequestId)" $org = "$(System.CollectionUri)" $project = "$(System.TeamProject)" $repo = "$(Build.Repository.Name)" $url = "${org}${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/threads?api-version=7.1" Invoke-RestMethod -Uri $url -Method Post -Body $commentBody -ContentType "application/json" -Headers @{ Authorization = "Bearer $(System.AccessToken)" } pwsh: true env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)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 # The manual gate comes from the 'environment' setting below. # You must configure approval checks on the 'epac-nonprod' environment # in Azure DevOps (Pipelines > Environments) for the gate to work. # ============================================ - 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 ` -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 # Pin the version to avoid breaking changes between EPAC major versions Install-Module -Name EnterprisePolicyAsCode -Force -AllowClobber -RequiredVersion "10.4.2"
# Verify installation $module = Get-Module -Name EnterprisePolicyAsCode -ListAvailable Write-Host "Installed EPAC version: $($module.Version)" pwsh: trueEnvironment Configuration
EPAC uses a configuration file to define environments, with management group scopes that align with your cloud foundation hierarchy. 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 UTC 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: | $scope = $env:DEPLOYMENT_SCOPE
# Get non-compliant states, filter to effects that support remediation $nonCompliantStates = Get-AzPolicyState ` -ManagementGroupName ($scope -split '/')[-1] ` -Filter "ComplianceState eq 'NonCompliant' and (PolicyDefinitionAction eq 'deployifnotexists' or PolicyDefinitionAction eq 'modify')" ` -Top 1000
# Group by assignment and create one remediation task per assignment $grouped = $nonCompliantStates | Group-Object PolicyAssignmentId
foreach ($group in $grouped) { Write-Host "Creating remediation for: $($group.Name) ($($group.Count) resources)"
Start-AzPolicyRemediation ` -Name "remediation-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$($group.Group[0].PolicyDefinitionName)" ` -ManagementGroupName ($scope -split '/')[-1] ` -PolicyAssignmentId $group.Name ` -ResourceDiscoveryMode ReEvaluateCompliance
Start-Sleep -Seconds 10 # Avoid throttling } 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
git checkout -b feature/add-storage-encryption-policy2. Make Changes
Add or modify policy definitions, assignments, or exemptions in the Definitions/ folder.
3. Test Locally (Optional)
# Build plan without deployingBuild-DeploymentPlans ` -PacEnvironmentSelector "dev" ` -OutputFolder "./local-test"
# Review the planGet-Content "./local-test/policy-plan.json" | ConvertFrom-Json | Format-List4. Create Pull Request
git add .git commit -m "Add storage encryption policy requirement"git push origin feature/add-storage-encryption-policy5. 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
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 stepsPost-Emergency Requirements
- Create a PR with the emergency changes (even after deployment)
- Document the incident
- Ensure changes flow through normal promotion
Key Takeaways
-
Separate service principals per environment: Isolation prevents accidents and limits blast radius.
-
PR validation catches mistakes early: Always review the plan before merging.
-
Manual gates for production: Automation is great, but humans should approve production changes.
-
Remediation is separate: Don’t mix policy deployment with remediation—they have different risk profiles.
-
Emergency processes exist: Plan for exceptions, but make them auditable.
What’s Next
In the final part of this series, Advanced EPAC Patterns, we’ll tackle policy exemptions with expiry dates, scalable assignment strategies, initiative design, and real-world troubleshooting. After the EPAC series, the governance framework post ties it all into a broader Azure governance strategy.
Sources
-
Microsoft, “Enterprise Policy as Code,” Cloud Adoption Framework, https://learn.microsoft.com/azure/cloud-adoption-framework/ready/policy-management/enterprise-policy-as-code
-
Microsoft, “Azure DevOps Pipelines,” Azure DevOps Documentation, https://learn.microsoft.com/azure/devops/pipelines/
-
Microsoft, “Resource Policy Contributor Role,” Azure RBAC Documentation, https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#resource-policy-contributor
-
Microsoft, “Azure Policy Remediation,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources
-
Azure, “EPAC Pipeline Examples,” GitHub, https://github.com/Azure/enterprise-azure-policy-as-code/tree/main/StarterKit/Pipelines