How to lint Azure Policy definitions in Azure DevOps with EPAC and Azure Policy Linter
Treating your Azure Policy like code
Azure accepts a policy definition long before it's a good one. You can make a definition with a hard-coded Deny, a like operator that was meant to be equals, or a field alias that doesn't exist in older API versions deploys without a notice. Then, you find out later, when a developer's deployment is blocked for the wrong reason, or when compliance shows green while enforcement quietly does nothing.
That knowledge is spread across resource provider documentation, or simply in people's minds. Or is it?
Azure Policy Linter collects it all together into a command-line tool. Version 0.8.0 ships 42 rules that inspect a single policy definition and report findings at three severities, each linked to a documentation page that explains the risk and the fix. It runs offline, reads the same JSON you commit to source control, and writes JSON for CI systems.
In this article, you apply the same DevOps principles to Azure Policy that you already apply to your application code. You first start by linting every change, delivering it through a DevOps pipeline, and promoting it from development to production.
Prerequisites
Before you begin this tutorial, there are a couple of prerequisites:
- An Azure subscription and permission to create management groups
- An Azure DevOps project with permission to create pipelines, service connections, and environments
- .NET SDK 10.0 or later to run the linter locally
- Working knowledge of EPAC
The EPAC getting started guide covers the fundamental knowledge, and this article doesn't repeat it. This tutorial will also provision some resources and assumes you clone the sample repository:
git clone https://github.com/Gijsreyn/random-tools-gijs
cd random-tools-gijs/azure-devops/epac-policy-linter-pipelineThe code blocks in this article are condensed to the lines that matter. The repository holds the complete pipeline, templates, scripts, and sample definitions, and every path in this article is relative to its root.
How the linter reports findings
Every finding has one of three severities. The linter's own design guide defines them like this:
| Severity | Meaning |
|---|---|
| Error | The policy fails to deploy, fails at evaluation time, or behaves significantly differently from what the author intended. |
| Warning | The policy behaves unexpectedly in some cases: logic gaps, missed edge cases, resource type gotchas, misused operators. Also used for high-value best practices such as parameterizing the effect. |
| Informational | Worth knowing, not worth a warning. Ignoring it has little or no effect on the policy's behavior. |
Two details matter for a pipeline. First, the linter processes up to 1,000 files per invocation and writes one JSON document with --output, keyed by input path. Second, its exit code reports whether the tool could run, not whether your policies are healthy. A policy with ten Error findings still exits with 0. That's deliberate, and it's why a later section wraps the linter in a PowerShell script that decides pass or fail.
[!NOTE]
The linter inspects policy definitions only. Policy set definitions, assignments, and exemptions are out of scope, and so are data-plane policies and effect details such asdeployIfNotExiststemplates. EPAC'sBuild-DeploymentPlansvalidates the rest of yourDefinitionsfolder.
Run the linter locally
The Azure Policy Linter CLI comes as a global .NET tool, so you can install it with the .NET CLI. Then you can point it to a flawed policy in the sample repository:
dotnet tool install --global Microsoft.Azure.Policy.PolicyLinter.Cli
policylinter ./samples/lint-findings/allowed-locations.before.json
The policy is a custom allowed-locations rule, as someone might write it on a first attempt. Here are the relevant parts:
"parameters": {
"allowedLocations": {
"type": "Array",
"defaultValue": [ "westeurope", "northeurope" ]
}
},
"policyRule": {
"if": {
"allOf": [
{ "field": "location", "notIn": "[parameters('allowedLocation')]" },
{ "field": "location", "notEquals": "global" },
{ "field": "type", "notLike": "Microsoft.Resources/subscriptions/resourceGroups" }
]
},
"then": {
"effect": "Deny"
}
}The linter reports five findings. The output is abbreviated here to the three that change how the policy behaves:
All Parameter References Must Resolve
Identifier: all-parameter-references-must-resolve
Line: 32, Position: 66, Path: properties.policyRule.if.allOf[0].notIn
The parameter 'allowedLocation' is referenced but is not declared in the policy's 'parameters' block, so the reference cannot resolve.
like/notLike Without Wildcards
Identifier: like-notlike-without-wildcards
Line: 40, Position: 85, Path: properties.policyRule.if.allOf[2].notLike
The condition uses the 'notLike' operator with value 'Microsoft.Resources/subscriptions/resourceGroups' which contains no wildcard (*). Without a wildcard it behaves identically to 'notEquals'; use 'notEquals' to express exact-match intent.
Hard-Coded Policy Enforcement Effect
Identifier: hard-coded-policy-enforcement-effect
Line: 45, Position: 32, Path: properties.policyRule.then.effect
The policy effect 'Deny' is hard-coded, so assignments cannot select a non-enforcement effect. Add a string 'effect' parameter with defaultValue 'audit' and allowedValues containing 'audit', 'Deny', 'disabled', then set the policy effect to "[parameters('effect')]".The first finding is an Error: allowedLocation is a typo for allowedLocations, and the definition fails when you deploy it. The other two are Warnings. The notLike condition behaves exactly like notEquals, so the operator only misleads the next person who reads the rule. The hard-coded Deny means every assignment enforces it from day one, with no way to audit first. The remaining two findings are Informational: the description is missing, and the type condition isn't first in the allOf.
Each finding carries a line and column. That's what lets the pipeline annotations in a later section land on the right line of the file.
The corrected definition at Definitions/policyDefinitions/General/allowed-locations.json fixes all five. Its rule looks like this:
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "notEquals": "Microsoft.Resources/subscriptions/resourceGroups" },
{ "field": "location", "notIn": "[parameters('allowedLocations')]" },
{ "field": "location", "notEquals": "global" }
]
},
"then": {
"effect": "[parameters('effect')]"
}
}The effect parameter defaults to Audit with Audit, Deny, and Disabled as allowed values. That's the pattern the linter's rule documentation recommends, and the pattern EPAC's own guidance asks for.
Turn findings into a pass or fail
Now that you ran the linter locally, you can see that the linter leaves something to you. That's where those annotations come in, and the wrapper at scripts/Invoke-PolicyLinter.ps1 does it all into one:
- Finds every
.jsonand.jsoncfile underDefinitions/policyDefinitions. The linter parses comments and trailing commas, so EPAC's `.jsonc` files need no preprocessing. - Installs the tool if
policylinterisn't on thePATH, with an optional pinned version. - Runs the linter in batches of 200 files with
--output, then merges the JSON. - Prints the findings. When the
TF_BUILDenvironment variable shows it's on an Azure Pipelines agent, it also writes##vso[task.logissue]logging commands, so Errors and Warnings appear in the run summary and link to the source line. - Writes a Markdown summary and attaches it to the run with
##vso[task.uploadsummary]. - Exits with 1 when any finding is at or above the
-FailOnseverity. The default isWarning.
The core of the script, condensed:
& policylinter @files --output $resultPath
if ($LASTEXITCODE -ne 0) { throw 'policylinter could not complete.' } # a tool failure, not a finding
$results = Get-Content $resultPath -Raw | ConvertFrom-Json -AsHashtable
$findings = foreach ($file in $results.Keys) {
foreach ($entry in @($results[$file])) {
[pscustomobject]@{
Severity = [string] $entry.severity
RuleIdentifier = $entry.ruleIdentifier
File = [IO.Path]::GetRelativePath($sourcesRoot, $file)
Line = $entry.lineNumber
Column = $entry.linePosition
Description = $entry.description
}
}
}
foreach ($f in $findings | Where-Object { $_.Severity -in 'Error', 'Warning' }) {
$type = if ($f.Severity -eq 'Error') { 'error' } else { 'warning' }
Write-Host "##vso[task.logissue type=$type;sourcepath=$($f.File);linenumber=$($f.Line);columnnumber=$($f.Column);code=$($f.RuleIdentifier)]$($f.Description)"
}
$severityRank = @{ Informational = 1; Warning = 2; Error = 3 }
$blocking = $findings | Where-Object { $severityRank[$_.Severity] -ge $severityRank[$FailOn] }
if ($blocking) { exit 1 }Two parameters deserve a mention. -FailOn sets the threshold: Error lets warnings through as annotations, Informational fails on everything, and None only reports. -ExcludeRule takes rule identifiers whose findings are still printed but never fail the run, for findings you have reviewed and accepted.
[!TIP]
You need-ExcludeRulesooner than you might expect. The storage policy atsamples/alias-warnings/storage-https-only.jsonis well formed, yet it gets a Warning fromfield-alias-unavailable-in-old-api-versions: thesupportsHttpsTrafficOnlyproperty doesn't exist in storage API versions from 2015 and 2016, so a request on one of those versions resolves the alias to an empty value.
Set up the (repository) folder structure
The sample repository folder structure follows the EPAC convention of a Definitions root. The pipeline and scripts live beside it, never inside it. This way, EPAC doesn't mistake them for policy files.

It's good to know that in this example, the global-settings.jsonc declares two EPAC environments. Each one points to the management group the pipeline creates:
"pacEnvironments": [
{
"pacSelector": "epac-dev",
"cloud": "AzureCloud",
"tenantId": "00000000-0000-0000-0000-000000000000",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/mg-epac-dev",
"desiredState": { "strategy": "full", "keepDfcSecurityAssignments": false, "doNotDisableDeprecatedPolicies": false },
"managedIdentityLocation": "westeurope"
},
{
"pacSelector": "epac-prod",
// same shape, rooted at mg-epac-prod
}
]Replace both tenantId values with your tenant ID and generate a new pacOwnerId with (New-Guid).Guid. EPAC uses the owner ID to tell its own deployments apart from everything else at the scope, so never copy one between repositories.
The assignment file assigns both definitions at the root of each environment. The scope map is keyed by pacSelector, so one file drives dev and prod:
"scope": {
"epac-dev": [ "/providers/Microsoft.Management/managementGroups/mg-epac-dev" ],
"epac-prod": [ "/providers/Microsoft.Management/managementGroups/mg-epac-prod" ]
}[!NOTE]
EPAC's documentation names its production environmenttenantrather thanprod, to keep EPAC environments distinct from your workload environments. This sample usesepac-prodbecause the two management groups are the point of the exercise. In a real tenant, the production EPAC environment is usually your existing intermediate root management group, and only the development one is new.
Create the management groups with PowerShell
Time to create the management groups. scripts/New-PolicyManagementGroups.ps1 creates mg-epac-dev and mg-epac-prod under a parent that you choose. With no parent specified, it uses the tenant root group, whose ID is the tenant ID. The script is idempotent: existing groups are reported and left alone, so the pipeline can run it on every build.
The core logic per management group:
$existing = Get-ManagementGroupOrNull -Name $target.Name # wraps Get-AzManagementGroup, returns $null on NotFound
if ($existing) {
[pscustomobject]@{ Name = $existing.Name; Id = $existing.Id; Status = 'Exists' }
continue
}
if ($PSCmdlet.ShouldProcess("$($target.Name) under $parentResourceId", 'Create management group')) {
New-AzManagementGroup -GroupName $target.Name -DisplayName $target.DisplayName -ParentId $parentResourceId
Wait-ManagementGroup -Name $target.Name -Timeout $TimeoutSeconds # poll until it reads back
}The wait loop matters. A freshly created management group can take a short while before Azure Policy accepts it as a scope, and the next stage of the pipeline deploys definitions to it. Because the script supports -WhatIf, you can preview it from your own machine:
Connect-AzAccount
./scripts/New-PolicyManagementGroups.ps1 -WhatIf[!NOTE]
The identity that runs the script needs the Management Group Contributor role on the parent management group.
Build the pipeline
pipelines/epac-policy-pipeline.yml runs six stages in order:

Pull requests run only Lint and Plan epac-dev. Pushes to main run everything. The plan and deploy templates follow the EPAC Starter Kit's Azure DevOps templates: Build-DeploymentPlans -DevOpsType ado publishes the plans as an artifact and sets the deployPolicyChanges and deployRoleChanges output variables, and the deploy stages skip themselves when both are no.
The Lint stage is the new part. Its template installs the .NET SDK and runs the wrapper:
steps:
- checkout: self
- task: UseDotNet@2
displayName: Use .NET SDK 10.0
inputs:
packageType: sdk
version: 10.0.x
- pwsh: |
./scripts/Invoke-PolicyLinter.ps1 `
-Path '$(Build.SourcesDirectory)/Definitions/policyDefinitions' `
-FailOn '${{ parameters.failOn }}' `
-OutputPath '$(Build.ArtifactStagingDirectory)/lint/policy-lint-results.json'
displayName: Run Azure Policy Linter
- publish: $(Build.ArtifactStagingDirectory)/lint
artifact: policy-lint-results
condition: succeededOrFailed()The pipeline exposes the threshold as a runtime parameter, lintFailOn, allowing you to rerun a build with Error or None without editing YAML. A second parameter, lintExcludeRules, passes a comma-separated list of rule identifiers to -ExcludeRule.
Running the pipeline
The pipeline references four Azure Resource Manager service connections by name. Create them with the roles below, following EPAC's service principal guidance. For a lab, one service principal with all three roles at the parent management group can stand behind all four names.
| Pipeline variable | Role | Scope |
|---|---|---|
bootstrapServiceConnection |
Management Group Contributor | Parent management group |
planServiceConnection |
Reader | Tenant root group |
devServiceConnection |
Resource Policy Contributor, Role Based Access Control Administrator | mg-epac-dev |
prodServiceConnection |
Resource Policy Contributor, Role Based Access Control Administrator | mg-epac-prod |
Then run it end to end:
- In Azure DevOps, create a pipeline from
pipelines/epac-policy-pipeline.ymland run it onmain. The Lint stage passes, the Bootstrap stage creates both management groups, and EPAC deploys the two definitions and their assignments tomg-epac-dev, then tomg-epac-prod. The deploy stages use deployment jobs, so this first run also creates the Azure DevOps environmentsepac-devandepac-prod. - (Optional) Put a person in front of production. Select Pipelines > Environments > epac-prod > Approvals and checks, and add an approval. Without it, a push to
mainflows to production as soon asepac-devsucceeds, which is fine for a lab and not for a tenant you share. - Create a branch, copy
samples/lint-findings/allowed-locations.before.jsonoverDefinitions/policyDefinitions/General/allowed-locations.json, and open a pull request. The Lint stage fails with one error and two warnings. Each one appears in the run summary with a link to the file and line, and the Extensions tab shows the Markdown table of findings. The Plan stage never runs. - Restore the corrected file, push, and watch the same pull request go green. Merge it. The run on
maindeploys toepac-dev, then toepac-prod, pausing there for approval if you added one.
Clean up resources
Delete the two management groups when you're done. Azure removes the policy assignments and definitions EPAC deployed to them. Move any subscriptions out first, because a management group with children can't be deleted.
Remove-AzManagementGroup -GroupName mg-epac-dev
Remove-AzManagementGroup -GroupName mg-epac-prod
Summary
You now run Azure Policy the way you run code. Every pull request is linted, and a finding at Warning or higher fails the build with the file and line attached. Changes that pass are delivered by EPAC to a development management group first and to production second, behind an approval if you added one. The linter judges the definition, EPAC deploys it, and the only custom code is one PowerShell script whose two decisions, the failing severity and the accepted rules, are pipeline parameters.