Generate Azure Policy documentation from your EPAC definitions
One readable page per policy kept current by your pipeline
When a deployment is denied, the team opens the policy to find out why. What they find is a policyRule full of nested allOf and count blocks. For the policy engine, that's the documentation. For them, it isn't. They want to know which effect applies, which parameter changes it, and what the policy looks at.
I built a tool for this for three reasons. First, I was building an EPAC pipeline, and I like documentation that the pipeline enforces. I want to say when a page is out of date, which should make the build fail. Second, I'd started using the new docs command in Bicep and wanted the same thing for policy: one command, one page per file. Third, our platform engineering team maintains a large set of platform documentation that we go through. Every page has to be current by then, and a policy page that someone updates by hand is one more thing to remember.
EPAC already ships a documentation script, Build-PolicyDocumentation. It reports your assignments per environment and compares policy sets for overlapping policies and effects, in Markdown, CSV, or an Azure DevOps wiki. It answers what's assigned where. It doesn't produce a page for each policy definition that explains the rule, the parameters, and the permissions a remediation needs.
This article uses azurepolicydocs, a .NET tool I wrote that reads the same policy files EPAC deploys and renders a Markdown page for each one:

You generate a page for one policy, then for every definition in the repository, and build a catalog that links to all of them. Then you adjust the template, check the pages on every pull request, and link them from the error a denied developer actually sees.
If you followed How to version Azure Policy releases with EPAC and semantic-release, this is also where the docs/ folder in every release package comes from.
Prerequisites
Before you begin, make sure you have:
- The .NET 10 SDK or later.
- An EPAC repository. The one from the previous article works, but any repository with a
Definitions/policyDefinitionsfolder will do. - PowerShell 7 for the examples. The commands work in any shell; only the line continuation character differs.
- For the pipeline and wiki sections: an Azure DevOps project with a Git repository. Only the release section needs the release pipeline from the previous article.
The tool reads local files. It doesn't sign in to Azure or call any Azure API.
The examples were tested with:
- .NET SDK 10.0.404
- AzurePolicyDocs.Cli 1.0.1
- PowerShell 7.7
Install the tool
You can install azurepolicydocs globally or as a local tool in the repository. A global install is fine for trying it out. For a repository that a pipeline builds, use a local tool so the version is recorded in Git.
From the root of your EPAC repository, create a tool manifest and install the tool:
dotnet new tool-manifest
dotnet tool install AzurePolicyDocs.Cli
This creates .config/dotnet-tools.json with the exact version you installed. Commit it. Anyone who clones the repository, including the pipeline agent, runs dotnet tool restore and gets the same version. It's the same reason package.json pinned exact versions in the previous article: the version that generated the documentation is part of the commit.
Skip dotnet new tool-manifest if the repository already has a manifest.
Verify the installation:
dotnet azurepolicydocs --version
The command should print the version from the manifest. The rest of this article writes the command as azurepolicydocs, as you'd type it after a global install. With a local install, prefix it with dotnet.
Generate a page for one policy
Start with a single definition. Download rg-require-environment-tag.jsonc from the tool's repository and save it as Definitions/policyDefinitions/Tags/rg-require-environment-tag.jsonc. The parts that end up on the page are the effect parameter and the rule:
{
"name": "rg-require-environment-tag",
"type": "Microsoft.Authorization/policyDefinitions",
"properties": {
"displayName": "Resource groups should have an 'environment' tag",
// mode, description, metadata, and the tagName parameter are left out here.
"parameters": {
"effect": {
"type": "String",
"allowedValues": ["Audit", "Deny", "Disabled"],
"defaultValue": "Audit"
}
},
"policyRule": {
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Resources/subscriptions/resourceGroups"
},
{
// tags[...] is a dynamic field reference, so the tag name can come from a parameter.
"field": "[concat('tags[', parameters('tagName'), ']')]",
"exists": "false"
}
]
},
"then": {
"effect": "[parameters('effect')]"
}
}
}
}
Preview the page without writing a file:
azurepolicydocs generate ./Definitions/policyDefinitions/Tags/rg-require-environment-tag.jsonc --stdout
The page starts with the display name as the heading, then the description, then a table with the name, policy type, mode, category, and version. Below that are sections for the effect, the targeted resource types, the parameters, and the full policy rule.
The effect section is the one the application team reads first:
## Effect
The effect is set by the `effect` parameter.
- Default: `Audit`
- Allowed: `Audit`, `Deny`, `Disabled`
The tool recognizes three ways a rule can set its effect. A fixed value such as "deny" is shown as is. A parameter reference, as in this policy, is shown with the parameter name, its default, and its allowed values. An expression, such as an if() that picks between two effects, is summarized by the effect names it contains. The expression itself is always visible in the policy rule section.
The resource types table lists Microsoft.Resources/subscriptions/resourceGroups, taken from the type condition. The tags[...] field isn't listed as a field alias. Tag references aren't aliases, so the page leaves them out.
Run the same command without --stdout:
azurepolicydocs generate ./Definitions/policyDefinitions/Tags/rg-require-environment-tag.jsonc
Without an output option, the tool writes rg-require-environment-tag.md beside the input. That's convenient for one file, but it mixes generated Markdown into the folder EPAC reads. Delete the file. For a whole repository, write the pages to a separate folder.
Understand what the tool reads
Before you point the tool at a whole repository, it helps to know which files it accepts. It reads policy definitions and policy set definitions in the shapes you'll find in real repositories:
| Input | Where it comes from |
|---|---|
A full resource payload with name, type, and properties |
EPAC, exports from Azure, and the Azure/azure-policy repository |
Only the property bag (displayName, policyRule, and so on) |
Repositories that drop the wrapper. The name falls back to the file name. |
.jsonc files with comments and trailing commas |
EPAC |
Policy sets that reference members by policyDefinitionName |
EPAC |
Property names are matched case-insensitively, because exported files don't agree on casing.
The parser isn't strict, and that's deliberate. The tool documents the files you have. It doesn't judge whether they're correct. Validation is the job of the Azure Policy Linter from How to lint Azure Policy definitions in Azure DevOps with EPAC and Azure Policy Linter.
The parser does stop on two things: a file that isn't valid JSON, and a file that's neither a policy definition nor a policy set definition. The second one matters in an EPAC repository, as the next section shows.
Generate pages for every definition
The --pattern option takes a glob and generates a page for every matching file. The obvious pattern for an EPAC repository is ./Definitions/**/*.json*. It fails.
Definitions/ also holds global-settings.jsonc and the files under policyAssignments/. Neither is a policy definition. The tool reports each one on stderr with the message is not a policy definition or a policy set definition, still writes the pages for the files that did parse, and exits with 1.
Match only the two definition folders instead:
azurepolicydocs generate --pattern "./Definitions/policy*Definitions/**/*.json*" --outdir ./docs/policies
policy*Definitions matches policyDefinitions and policySetDefinitions. It doesn't match policyAssignments or policyExemptions, and global-settings.jsonc sits outside the matched folders.
The tool takes the longest literal directory before the first wildcard as the pattern root, here Definitions. Everything below the root is recreated under --outdir, so two files with the same name in different folders don't overwrite each other:
docs/policies/
├── policyDefinitions/
│ ├── General/
│ │ └── allowed-locations.md
│ └── Tags/
│ ├── resources-inherit-rg-tag-dynamic-notscope.md
│ └── rg-require-environment-tag.md
└── policySetDefinitions/
└── allowed-locations-initiative.md
Every output path is worked out and checked before the first file is written. An output can't overwrite its input, can't end in .json or .jsonc, and two inputs can't write to the same file. A conflict stops the run before anything on disk changes.
[!IMPORTANT]
Quote the pattern. On Linux and macOS, PowerShell expands wildcards in unquoted arguments to native commands. The tool then receives a list of files instead of a pattern and stops withThe input file and the --pattern parameter cannot both be specified.Quote it when it's in a variable too (--pattern "$pattern"), as the pipeline template does. The same error is in the troubleshooting table of the previous article.
Document a remediation policy
A page for an audit or deny policy answers what the policy blocks. A page for a modify or deployIfNotExists policy also has to answer what the policy changes, and with which permissions.
The resources-inherit-rg-tag-dynamic-notscope definition in the tree above copies a tag from the resource group to the resources in it. Its effect section looks like this:
## Effect
The effect is set by the `effect` parameter.
- Default: `Modify`
- Allowed: `Disabled`, `Audit`, `Deny`, `Modify`
Remediation needs these role assignments:
| Role | Role definition ID |
| :-- | :-- |
| Contributor | `b24988ac-6180-42a0-ab88-20f7382dd24c` |
Modify operations:
| Operation | Field |
| :-- | :-- |
| `addOrReplace` | `[concat('tags[', parameters('tagName'), ']')]` |
The role comes from roleDefinitionIds in the rule. Common built-in roles get their name. Other roles show only the GUID. For a deployIfNotExists policy, the section also names the related resource type and states whether the rule has an existence condition and a deployment.
This matters during review. A pull request that adds a remediation policy with Owner shows Owner on the generated page, in plain text, without anyone opening the JSON.
The same page also shows the limits of the summary. Its resource types section says the policy rule has no explicit type condition, even though the rule compares type inside a count expression.
Understand what the page doesn't show
A resource type is listed only when the rule compares the type field directly under if, with equals, in, like, match, matchInsensitively, or contains. Expressions are skipped. In the inherit-tag policy, type is compared against current('excludedResourceTypes'), a list of types the policy skips. Listing those as targets would be wrong, so the page lists nothing.
You might expect the tool to derive resource types from field aliases instead. It can't do that reliably. An alias such as Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly joins a resource type and a property path into one string, and nothing marks where one ends. Counting slashes doesn't help, because Microsoft.Sql/servers/databases is a type with three segments. Only the resource provider metadata in Azure has the answer, and the tool doesn't call Azure. Aliases are listed separately, exactly as they're written in the rule.
Both lists are a quick overview, not the source of truth. The full policy rule is always at the bottom of the page.
The tool also doesn't document policy assignments or exemptions. EPAC assignment files use their own tree format. That means a page tells you what a policy can do. Which scope it's assigned to, and with which parameter values, is still in policyAssignments/.
Build a catalog page
Individual pages are useful once you know which policy to look for. The catalog is the page you link to so people can find out.
Generate the catalog with the same pattern you used for the pages:
azurepolicydocs catalog --pattern "./Definitions/policy*Definitions/**/*.json*" `
--outfile ./docs/policies/README.md `
--docs-dir ./docs/policies `
--custom-template-value "title=Platform policies" `
--custom-template-value "description=Policies maintained by the platform team."
--docs-dir tells the catalog where generate --outdir wrote the pages. Without it, the links point to a .md file beside each source file, which doesn't exist when you generated into a separate folder. The catalog doesn't create the pages itself, so run generate first.
The catalog lists policy definitions and policy sets in separate tables, sorted by category and then by display name:
| Policy | Category | Mode | Effects | Description |
| :-- | :-- | :-- | :-- | :-- |
| [Resources should be deployed to allowed locations](policyDefinitions/General/allowed-locations.md) | General | `Indexed` | `Audit`, `Deny`, `Disabled` | Audits or denies resources deployed outside the allowed locations. ... |
| [Inherit Tag from Resource Group with dynamic notScope](policyDefinitions/Tags/resources-inherit-rg-tag-dynamic-notscope.md) | Tags | `Indexed` | `Disabled`, `Audit`, `Deny`, `Modify` | Resources inherit a tag from the resource group. ... |
The Effects column is the one people scan. It shows which policies can deny a deployment without opening any of them.
Customize the template
The built-in templates are a reasonable default. Most teams want to add something, such as who owns the policies and where to request an exemption.
Export the two built-in templates to a templates folder:
azurepolicydocs template export --kind policyDefinition --outfile ./templates/_policy.scriban
azurepolicydocs template export --kind policySetDefinition --outfile ./templates/_policy-set.scriban
A generate run uses one template for every input. Your pattern matches both policy definitions and policy sets, so the template you pass has to handle both. Save the following as templates/policy.scriban. It picks the right built-in template for each input and adds an ownership section to every page:
{{~ if policy.kind == "policySetDefinition" ~}}
{{~ include "_policy-set.scriban" ~}}
{{~ else ~}}
{{~ include "_policy.scriban" ~}}
{{~ end ~}}
{{~ if custom.owner ~}}
## Ownership
- Owner: {{ custom.owner }}
{{~ if custom.supportUrl ~}}
- Exemption requests: <{{ custom.supportUrl }}>
{{~ end ~}}
{{~ end ~}}
Scriban prints everything outside {{ }} as is, including the line break after a tag. The ~ in {{~ and ~}} removes the whitespace on that side. Putting every control statement on its own line with both markers, and writing blank lines explicitly, keeps the output predictable.
Values in custom are strings that you pass on the command line. Keep them in a file next to the template. Save the following as templates/values.json:
{
// Example: not really a website
"owner": "Platform team",
"supportUrl": "https://idontlikeai.dev/platform/exemptions"
}
Generate the pages with the custom template:
azurepolicydocs generate --pattern "./Definitions/policy*Definitions/**/*.json*" `
--outdir ./docs/policies `
--template ./templates/policy.scriban `
--include-root ./templates `
--custom-template-value-file-path ./templates/values.json
include statements resolve against the input file's folder by default. --include-root points them at the templates folder instead. Every page now ends with an Ownership section, and policy set pages still use the policy set layout.
A template that fails to parse stops the run before any file is written, and the error names the line and column. A template that fails while rendering one input reports that input and continues with the others. In both cases, the exit code is 1 and existing pages are left as they were.
[!NOTE]
The names a template reads, such aspolicy.displayNameoreffect.defaultEffect, are the tool's public interface. Renaming one is a breaking change for every custom template. That's another reason to pin the tool version in the manifest. An update that renames a key arrives as a major version, and you choose when to take it.
Check the documentation on pull requests
So far, the pages exist on your machine. They stay accurate only if they're regenerated every time a definition changes, and the only place that's guaranteed is the pipeline.
The template below generates the pages and the catalog, then runs in one of two modes. check fails when the committed documentation is out of date. commit regenerates the documentation and pushes it; the release section later in this article uses it. Save the template as pipelines/templates/policy-docs.yml:
parameters:
- name: mode
type: string
default: check
values:
- check
- commit
steps:
- task: UseDotNet@2
displayName: Install .NET 10 SDK
inputs:
packageType: sdk
version: 10.0.x
- pwsh: dotnet tool restore
displayName: Restore local tools
- pwsh: |
$pattern = './Definitions/policy*Definitions/**/*.json*'
dotnet azurepolicydocs generate --pattern "$pattern" `
--outdir ./docs/policies `
--template ./templates/policy.scriban `
--include-root ./templates `
--custom-template-value-file-path ./templates/values.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet azurepolicydocs catalog --pattern "$pattern" `
--outfile ./docs/policies/README.md `
--docs-dir ./docs/policies `
--custom-template-value "title=Platform policies"
exit $LASTEXITCODE
displayName: Generate policy documentation
- ${{ if eq(parameters.mode, 'check') }}:
- pwsh: |
$changes = git status --porcelain -- docs
if ($changes) {
Write-Host '##vso[task.logissue type=error]Policy documentation is out of date. Regenerate it and commit the result.'
$changes
exit 1
}
displayName: Check that documentation is current
- ${{ if eq(parameters.mode, 'commit') }}:
- pwsh: |
git add docs
git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host 'Documentation is current.'
exit 0
}
git -c user.name='Azure Pipelines' -c user.email='azure-pipelines@devopsagent.com' `
commit -m 'docs: regenerate policy documentation [skip ci]'
git push origin HEAD:$(Build.SourceBranchName)
displayName: Commit regenerated documentation
The check uses git status --porcelain instead of git diff --exit-code. A new policy produces a new page, and git diff doesn't report untracked files.
The check only works because the output is deterministic. Every page uses \n line endings, ends with exactly one newline, and is written as UTF-8 without a byte order mark. A page generated on a Windows laptop is byte-for-byte the same as one generated on a Linux agent, so the check fails only when a policy or template actually changed.
To run the check, save the following as pipelines/policy-docs-check.yml:
trigger: none
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
- template: templates/policy-docs.yml
parameters:
mode: check
Create a pipeline from this file, then add it as a build validation policy on main. In Azure Repos, branch policies trigger pull request builds, not the YAML file. If you already have a pull request pipeline, add only the template step to it.
With the check in place, a pull request that changes a policy must include its regenerated page. Reviewers then see the change in plain language. A diff that turns Default: Audit into Default: Deny is easier to catch than the same change in a parameter block.

Commit the documentation before a release
This section is for readers of How to version Azure Policy releases with EPAC and semantic-release. If you don't release with semantic-release, skip to the next section.
In that article, the Release job runs the template with mode: commit as its first step, before semantic-release:
- template: templates/policy-docs.yml
parameters:
mode: commit
The order is deliberate. semantic-release refuses to release when the remote branch is ahead of the commit it runs on. It logs The local branch main is behind the remote one, therefore a new version won't be published. and exits without an error, so the pipeline stays green while nothing is released. Committing the documentation first means semantic-release tags the commit that already contains the docs, and the package for that version carries them.
Two details make the commit work:
- The commit uses the
docstype, which doesn't trigger a release under the rules from the previous article, and[skip ci]so the push doesn't start another run. - The push needs the checkout step to keep its credentials (
persistCredentials: true), and the build service needs the Contribute and Bypass policies permissions you already granted for semantic-release.
A diff that turns Default: Audit into Default: Deny is also exactly the change that calls for a feat! commit under that article's rules.
If you'd rather not ask contributors to run the tool locally, leave the pull request check out and let commit mode regenerate the docs on main. You lose the review diff, but the package still ships accurate documentation.
Put the page where the denied developer looks
A page in docs/policies or inside a release package doesn't help the developer whose deployment was just denied. They see an error in the portal, the Azure CLI, or their pipeline log, and that's where the link has to be. Two steps get it there.
First, give the pages a URL. In Azure DevOps, go to Wiki, select Publish code as wiki, and pick your repository, the main branch, and the docs/policies folder. The head of the branch is mapped to the wiki, so every commit that changes a page updates the wiki without another pipeline step. The catalog appears as the README page.
Second, put the page URL in the assignment's non-compliance message. Azure Policy shows that message in the deny error and on the resource's compliance results. In an EPAC assignment file, add nonComplianceMessages to the assignment:
"nonComplianceMessages": [
{
"message": "Resource groups need an 'environment' tag. See https://dev.azure.com/contoso/platform/_wiki/wikis/policies for what the policy checks and how to fix it."
}
]
For an assignment of a policy set, add policyDefinitionReferenceId to each message to link each member policy to its own page.

Troubleshoot
Most of these come from running the tool against a real EPAC repository for the first time.
| Symptom | Cause | Fix |
|---|---|---|
is not a policy definition or a policy set definition for global-settings.jsonc or an assignment file |
The pattern matches all of Definitions/ |
Use ./Definitions/policy*Definitions/**/*.json* |
The pattern root directory "..." does not exist. |
A typo in the literal part of the pattern, or the command runs from a different folder | Fix the path, or run the command from the repository root |
| The command exits with 0 but writes nothing | The part of the pattern after the root matches no files. The tool prints No files matched the pattern on stderr and returns 0. |
Check the file extension in the pattern |
The input file and the --pattern parameter cannot both be specified. |
PowerShell on Linux expanded an unquoted wildcard | Quote the pattern, including when it's in a variable: --pattern "$pattern" |
| Catalog links return 404 | The catalog ran without --docs-dir after generate --outdir |
Pass the same folder to --docs-dir |
| Policy set pages are missing their member tables | A policy definition template rendered a policy set | Branch on policy.kind, as in templates/policy.scriban |
Failed to parse the documentation template |
A Scriban syntax error, such as an if without end |
Fix the line and column in the message. No files were written. |
| The check fails on a pull request | A definition changed without its page | Run the generate and catalog commands locally and commit docs/ |
The push in commit mode is rejected |
Missing permissions or credentials | Set persistCredentials: true on the checkout and check the build service permissions from the previous article |
Documentation as code is more than generated Markdown
Generating Markdown from JSON is the easy part. What makes the page worth reading is where it comes from. It's rebuilt from the same file EPAC deploys, in the same commit, and shipped in the same package as the version number semantic-release assigned. When someone asks what version 1.4.0 of the baseline did, the answer is in the package for 1.4.0.
A hand-written wiki page is a promise that someone will update it. A generated page doesn't depend on anyone remembering, and a pipeline check enforces it.
Clean up
If you only tried the tool locally, remove it and the generated files.
Uninstall the local tool, which also removes it from the manifest:
dotnet tool uninstall AzurePolicyDocs.Cli
For a global install, add --global. Then delete docs/policies and templates. If you added policy-docs.yml to a pipeline, remove the template reference and the build validation policy as well. If you published docs/policies as a wiki, unpublish it, and remove the link from any nonComplianceMessages.
Where to go next
You now generate a page for every policy definition and policy set, a catalog that links to them, and a custom section on every page. A pull request can't change a policy without its page, and a denied deployment links straight to the page that explains it.
The next step is the one the previous article ended with: splitting the pipeline so deployments come straight from the Azure Artifacts feed. Once the documentation is in the package, the docs for the version running in production are the docs in that version's package, not whatever is on main today.
If Markdown files in a repository aren't where your team reads documentation, the AzurePolicyDocs.Core NuGet package exposes the same parser, model, and renderer to your own .NET code. A custom template can also render HTML, AsciiDoc, or CSV. With --pattern and --outdir, the tool still names every output .md, so use --outfile for a single file in another format, or call AzurePolicyDocs.Core to choose the file names yourself.