Azure Policy is the Azure service that blocks a bad deployment before it lands and flags the ones that already did. This Azure Policy tutorial takes 13 steps and about 60 minutes, and it ends with a working baseline: resources locked to two regions, a required cost-center tag, that tag copied down automatically, and old resources fixed through a remediation task.
The pressure to get this right keeps climbing. In the IDC Worldwide Digital Sovereignty Survey 2025, 37% of organizations named regulatory compliance a top driver for pursuing digital-sovereignty solutions such as sovereign cloud. Where your resources run is usually the first question an auditor asks, and Azure Policy is the control that answers it with evidence instead of a screenshot.
As of November 4, 2026, the service is active, still carries the name Azure Policy, and has no announced retirement or rename. Microsoft refreshed its built-in policy documentation on November 1, 2026. If you run many subscriptions, the guardrails you build here slot straight into an Azure Landing Zone design, where the same definitions get assigned once at a management group and inherited everywhere below it.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What Azure Policy Does and Which Effect to Pick
Azure Policy evaluates the properties of Azure resources against rules you write or borrow. When a resource is created or updated, Azure Resource Manager asks Azure Policy whether the request passes. When a resource already exists, a background scan compares it to the same rules and records the result as compliant or non-compliant. The service does not replace role-based access control. RBAC answers “who may do this,” while Azure Policy answers “what may exist.”
The same idea exists on other platforms. If you are weighing guardrail tooling across clouds, the AKS, EKS and GKE comparison shows how each managed Kubernetes service handles policy at the cluster layer, which is a different problem from the subscription layer this guide covers.
Definitions, Initiatives and Assignments
Three objects matter. A policy definition is a JSON document with an if condition and a then block that names the effect. An initiative (also called a policy set) bundles many definitions and shares parameters between them. An assignment applies one definition or one initiative to a scope. A definition with no assignment does nothing at all, which is the most common reason a new user thinks Azure Policy is broken.
Scope works top-down. You can assign at a management group, a subscription, a resource group, or a single resource. Assignments inherit downward, and you can carve out exclusions with notScopes or grant narrower exemptions later.
| Scope | Typical use | Inherited by |
|---|---|---|
| Management group | Company-wide rules such as allowed regions | All child subscriptions |
| Subscription | Environment baselines (prod, dev, sandbox) | All resource groups inside |
| Resource group | Team or workload rules, and this tutorial’s lab | All resources inside |
| Resource | Rare, one-off rules | Nothing |
| notScopes (exclusion) | Carve a child out of an assignment | Not applicable |
The Effects You Will Actually Use
The effect is the verb of the rule. Microsoft’s current documentation lists more effects than most tutorials cover, including denyAction, manual and addToNetworkGroup. For governance of tags and regions, a handful do almost all the work. Order matters at request time. Azure Policy evaluates Disabled first, then Append and Modify, then Deny, then Audit. That ordering is why a Modify rule can add a missing tag before a Deny rule gets a chance to reject the request.
| Effect | What it does | Fixes existing resources? | Use it for |
|---|---|---|---|
| Audit | Logs non-compliance, never blocks | No | Rolling out a rule safely |
| Deny | Rejects a matching create or update | No | Hard limits such as regions |
| Modify | Changes tags or properties on the request | Yes, with a remediation task | Adding or inheriting tags |
| DeployIfNotExists | Deploys a related resource if it is missing | Yes, with a remediation task | Diagnostic settings, agents |
| AuditIfNotExists | Flags a resource missing a related resource | No | Checking for backups or extensions |
| Append | Adds fields to the request | No | Older tag rules (Modify is the better pick) |
| Disabled | Turns the rule off without removing it | No | Temporarily muting a rule in an initiative |
Microsoft’s training material, updated as recently as November 4, 2026, repeats the key point for this tutorial. Compliance evaluation alone never corrects existing resources. Only a remediation task, run against a modify or deployIfNotExists policy, changes what is already deployed.
Prerequisites and Versions
You need a lab you can break. A pay-as-you-go or free Azure subscription works, and the resources in this guide cost close to nothing because the only things you deploy are empty storage accounts, which you delete at the end. Do not run the Deny steps against a production subscription on your first pass.
- Azure subscription: any active subscription where you hold the Owner role. Owner matters because assigning a policy with a managed identity also creates a role assignment, and that needs permission to write role assignments.
- Azure CLI 2.x, latest release: run
az versionto check andaz upgradeto update. Older builds miss neweraz policyflags. - Bash or Zsh: Linux, macOS, WSL or Azure Cloud Shell all work. The scripts use plain Bash.
- jq (optional): any recent release. It makes the compliance export in Step 13 shorter.
- Bicep (optional): installed through
az bicep install, only for the infrastructure-as-code variant in the working project. - Resource provider:
Microsoft.PolicyInsightsmust be registered on the subscription. Step 2 handles that. - Time: about 60 minutes, split as shown below.
| Step | Task | Minutes |
|---|---|---|
| 1 | Sign in and pick the subscription | 3 |
| 2 | Set variables, register the provider | 3 |
| 3 | Build the sandbox and a legacy resource | 4 |
| 4 | Find the Allowed locations definition | 3 |
| 5 | Assign and test the region lock | 6 |
| 6 | Require a tag with a built-in policy | 5 |
| 7 | Write a custom tag-value rule | 7 |
| 8 | Assign Modify with a managed identity | 7 |
| 9 | Test automatic tag inheritance | 4 |
| 10 | Bundle the rules into an initiative | 6 |
| 11 | Dry-run in DoNotEnforce, then enforce | 4 |
| 12 | Scan and remediate existing resources | 5 |
| 13 | Add an exemption and export the report | 3 |
Steps 1-3: Sign In, Set Variables, Build a Sandbox
Step 1: Sign in and pick the subscription. Open a terminal and authenticate. If you use Cloud Shell you are already signed in, so skip the login line.
az login
az account list --query "[].{name:name, id:id, state:state}" -o table
az account set --subscription "<your-subscription-id>"
az account show --query "{name:name, id:id, tenant:tenantId}" -o table
The last command should print one row with your subscription name, its ID and your tenant. If it prints the wrong subscription, fix it now. Every later command inherits this context, and assigning a Deny policy to the wrong subscription is the fastest way to ruin a Monday.
Step 2: Set variables and register the provider. Save the values you reuse so you never retype a scope. The Microsoft.PolicyInsights provider stores compliance results, and without it the compliance views stay empty.
export SUB_ID=$(az account show --query id -o tsv)
export RG=rg-policy-lab
export LOC=eastus
export ALLOWED='["eastus","westeurope"]'
export TAG=costcenter
az provider register --namespace Microsoft.PolicyInsights
az provider show --namespace Microsoft.PolicyInsights --query registrationState -o tsv
Registering
# wait a minute, then run the show command again
Registered
Registration takes a minute or two. Move on once the state reads Registered.
Step 3: Build the sandbox and a legacy resource. Create a resource group that carries the costcenter tag, then deploy one storage account before any policy exists. That account plays the part of the old, untagged resource every real estate has. You will fix it in Step 12.
az group create --name $RG --location $LOC \
--tags costcenter=cc-1042 owner=platform-team environment=dev
export LEGACY=stlegacy$RANDOM
az storage account create --name $LEGACY --resource-group $RG \
--location $LOC --sku Standard_LRS
az storage account show --name $LEGACY --resource-group $RG --query tags
{}
The empty object confirms the account has no tags. Storage account names must be globally unique, lowercase, and between 3 and 24 characters, so the random suffix keeps the name from colliding.
Steps 4-5: Lock Deployments to Two Regions
Step 4: Find the Allowed locations definition. Look the built-in policy up by display name instead of pasting an ID from a blog post. Display names are stable enough to script against, and the lookup proves the definition exists in your tenant.
az policy definition list \
--query "[?displayName=='Allowed locations'].{name:name, type:policyType, mode:mode}" -o table
export ALLOWED_ID=$(az policy definition list \
--query "[?displayName=='Allowed locations'].name | [0]" -o tsv)
echo $ALLOWED_ID
Name Type Mode
---------------- ------- -------
<built-in-guid> BuiltIn Indexed
Microsoft’s built-in Allowed locations policy uses the Deny effect to restrict where new resources can be deployed. The documentation reportedly also lists Audit and Disabled effects, and says the policy skips resource groups, Microsoft Entra ID B2C directories and resources that use the global region. Because resource groups are not covered, plan for them separately once this baseline works.
Step 5: Assign the region lock and test it. Assign the policy to the lab resource group with your two allowed regions as the parameter. The parameter name for this built-in is listOfAllowedLocations.
export RG_ID=$(az group show --name $RG --query id -o tsv)
az policy assignment create \
--name allowed-locations-lab \
--display-name "Lab: allowed regions" \
--policy $ALLOWED_ID \
--scope $RG_ID \
--params "{\"listOfAllowedLocations\":{\"value\":$ALLOWED}}"
# give the assignment a minute to propagate, then try a disallowed region
az storage account create --name stblocked$RANDOM --resource-group $RG \
--location brazilsouth --sku Standard_LRS
(RequestDisallowedByPolicy) Resource 'stblocked18423' was disallowed by policy.
Policy identifiers: '[{"policyAssignment":{"name":"Lab: allowed regions",
"id":"/subscriptions/.../providers/Microsoft.Authorization/policyAssignments/allowed-locations-lab"},
"policyDefinition":{"name":"Allowed locations","id":"/providers/Microsoft.Authorization/policyDefinitions/..."}}]'.
That error is the whole point. The request never created a resource, and the message names both the assignment and the definition, so the engineer who hit it knows exactly which rule to read. If the deployment succeeds instead, wait two more minutes and retry. New assignments take a short while to reach the enforcement path. Then confirm the allowed region still works by deploying one account to westeurope and deleting it right after.
Steps 6-7: Require Tags, Then Write a Custom Rule
Step 6: Require a tag with a built-in policy. The built-in Require a tag on resources policy denies any resource created without the named tag. Its one parameter is the tag name.
export REQTAG_ID=$(az policy definition list \
--query "[?displayName=='Require a tag on resources'].name | [0]" -o tsv)
az policy assignment create \
--name require-costcenter-lab \
--display-name "Lab: require costcenter tag" \
--policy $REQTAG_ID \
--scope $RG_ID \
--params "{\"tagName\":{\"value\":\"$TAG\"}}"
az storage account create --name sttagless$RANDOM --resource-group $RG \
--location $LOC --sku Standard_LRS
The last command should fail with another RequestDisallowedByPolicy error, this time naming the tag rule. Adding --tags costcenter=cc-1042 makes it pass. Azure treats tag names as case-insensitive and tag values as case-sensitive, a detail that causes a surprising number of “it passes on my laptop” arguments.
Step 7: Write a custom tag-value rule. Built-in policies cover the common cases, but a requirement like “the environment tag must be dev, test or prod” needs a custom definition. Create two small files. The first holds the rule, and the second holds its parameters, so one definition can serve several tag names later.
cat > rule.json <<'EOF'
{
"if": {
"anyOf": [
{ "field": "[concat('tags[', parameters('tagName'), ']')]", "exists": "false" },
{ "not": {
"field": "[concat('tags[', parameters('tagName'), ']')]",
"in": "[parameters('allowedValues')]"
} }
]
},
"then": { "effect": "[parameters('effect')]" }
}
EOF
cat > params.json <<'EOF'
{
"tagName": { "type": "String", "defaultValue": "environment" },
"allowedValues": { "type": "Array", "defaultValue": ["dev", "test", "prod"] },
"effect": { "type": "String", "defaultValue": "Audit",
"allowedValues": ["Audit", "Deny", "Disabled"] }
}
EOF
az policy definition create \
--name tag-value-in-list \
--display-name "Tag value must be in an approved list" \
--mode Indexed \
--rules rule.json \
--params params.json
Two choices here deserve a note. The mode is Indexed, which evaluates only resource types that support tags and locations, so you avoid false alarms on child resources that cannot carry tags. And the effect is a parameter that defaults to Audit. That single decision lets you ship the rule in report-only form today and flip it to Deny by changing an assignment parameter, with no new definition to maintain.
Assign it in Audit mode to the lab. You do not need to read the result yet, because Step 12 does that.
az policy assignment create \
--name env-tag-values-lab \
--display-name "Lab: environment tag values" \
--policy tag-value-in-list \
--scope $RG_ID
Steps 8-9: Auto-Tag With Modify and a Managed Identity
Step 8: Assign Modify with a managed identity. Denying untagged resources works, but it makes every engineer remember the tag. A Modify policy removes the chore. The built-in Inherit a tag from the resource group if missing policy copies the tag from the parent group onto any resource that lacks it. Because Modify changes resources, the assignment needs a managed identity with a role that can write tags. The built-in definition declares which roles it needs, and Tag Contributor is the narrowest one that covers tag writes.
export INHERIT_ID=$(az policy definition list \
--query "[?displayName=='Inherit a tag from the resource group if missing'].name | [0]" -o tsv)
az policy assignment create \
--name inherit-costcenter-lab \
--display-name "Lab: inherit costcenter tag" \
--policy $INHERIT_ID \
--scope $RG_ID \
--params "{\"tagName\":{\"value\":\"$TAG\"}}" \
--mi-system-assigned \
--location $LOC \
--identity-scope $RG_ID \
--role "Tag Contributor"
az policy assignment show --name inherit-costcenter-lab --scope $RG_ID \
--query "{identity:identity.type, principal:identity.principalId}" -o table
Identity Principal
-------------- ------------------------------------
SystemAssigned <principal-guid>
Two details trip people up. First, the --location flag is required whenever an identity is attached, because the identity itself lives in a region. Second, the role is scoped to the group named in --identity-scope, so the identity cannot touch tags outside the lab. Granting broader access than the policy needs is a habit worth refusing from day one.
For more guardrail and governance walkthroughs across clouds, browse the cloud computing coverage, where the setup guides share this same step-by-step format.
Step 9: Test automatic tag inheritance. Remember that Step 6 installed a Deny rule for untagged resources. Modify runs before Deny, so a tagless request should now get its tag added and then pass. This is the test that proves the evaluation order from the effects table.
export NEW=stinherit$RANDOM
az storage account create --name $NEW --resource-group $RG \
--location $LOC --sku Standard_LRS
az storage account show --name $NEW --resource-group $RG --query tags
{
"costcenter": "cc-1042"
}
The account was created without a tag on the command line and came back with costcenter set to the value from the resource group. If the create is rejected instead, the Modify assignment has not propagated yet. Wait a couple of minutes and try again. The legacy account from Step 3 is still untagged, since Modify only acts on new and updated requests until you run remediation.
Step 10: Bundle Everything Into an Initiative
Four separate assignments are manageable in a lab, but they do not scale. An initiative turns them into one object with one compliance score, one set of shared parameters and one assignment to maintain. Build the initiative from the four definitions you already used, then swap the individual assignments for a single one.
cat > initiative.json <<EOF
[
{
"policyDefinitionReferenceId": "allowed-locations",
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/$ALLOWED_ID",
"parameters": { "listOfAllowedLocations": { "value": "[parameters('allowedRegions')]" } }
},
{
"policyDefinitionReferenceId": "require-costcenter",
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/$REQTAG_ID",
"parameters": { "tagName": { "value": "[parameters('costTagName')]" } }
},
{
"policyDefinitionReferenceId": "inherit-costcenter",
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/$INHERIT_ID",
"parameters": { "tagName": { "value": "[parameters('costTagName')]" } }
},
{
"policyDefinitionReferenceId": "environment-values",
"policyDefinitionId": "/subscriptions/$SUB_ID/providers/Microsoft.Authorization/policyDefinitions/tag-value-in-list",
"parameters": {}
}
]
EOF
cat > initiative-params.json <<'EOF'
{
"allowedRegions": { "type": "Array", "defaultValue": ["eastus", "westeurope"] },
"costTagName": { "type": "String", "defaultValue": "costcenter" }
}
EOF
az policy set-definition create \
--name baseline-governance \
--display-name "Baseline governance: regions and tags" \
--definitions initiative.json \
--params initiative-params.json
Note the unquoted EOF on the first heredoc. It lets Bash expand the ID variables inside the file, while the second heredoc stays quoted because it contains no variables. Each entry carries a policyDefinitionReferenceId. Keep those IDs short and stable, because remediation tasks and exemptions refer to them later.
Now remove the four lab assignments so the rules do not evaluate twice. Then assign the initiative with a managed identity, since it contains a Modify policy.
for A in allowed-locations-lab require-costcenter-lab env-tag-values-lab inherit-costcenter-lab; do
az policy assignment delete --name $A --scope $RG_ID
done
az policy assignment create \
--name baseline-governance \
--display-name "Baseline governance" \
--policy-set-definition baseline-governance \
--scope $RG_ID \
--mi-system-assigned --location $LOC \
--identity-scope $RG_ID --role "Tag Contributor" \
--enforcement-mode DoNotEnforce
The assignment goes in with --enforcement-mode DoNotEnforce on purpose. That is the setup for the next step.
Step 11: Dry-Run With DoNotEnforce Before You Enforce
Enforcement mode is the safety valve most teams skip. In DoNotEnforce, Azure Policy still evaluates every resource and records compliance, but it does not apply Deny or create the enforcement entries in the Activity Log. You see the blast radius of a rule before it blocks anyone. Remediation tasks can still be started in this mode, which makes it a good place to rehearse a bulk fix.
| Control | Evaluates? | Blocks or modifies? | When to use it |
|---|---|---|---|
| Default mode | Yes | Yes | Normal operation |
| DoNotEnforce mode | Yes | No | Dry runs, new rules, audits before a Deny rollout |
| Enroll mode | Yes | Only for enrolled resources | Staged rollouts, per Microsoft’s assignment documentation |
| Disabled effect | No | No | Muting one rule inside an initiative |
| notScopes | Skipped for that child | No | Permanent carve-outs for a subtree |
Leave the baseline in dry-run through Step 12, because you want a compliance report that shows violations without breaking deployments. When you are ready to enforce, re-run the same create command with the mode set to Default. The create call is an idempotent PUT, so it updates the existing assignment in place. Here is the command to hold in reserve.
az policy assignment create \
--name baseline-governance \
--display-name "Baseline governance" \
--policy-set-definition baseline-governance \
--scope $RG_ID \
--mi-system-assigned --location $LOC \
--identity-scope $RG_ID --role "Tag Contributor" \
--enforcement-mode Default
az policy assignment show --name baseline-governance --scope $RG_ID \
--query enforcementMode -o tsv
Default
Treat enforcement as a one-line change you make deliberately, with a ticket and a rollback plan, not a flag someone toggles at the end of a long afternoon. The rollback is the same command with DoNotEnforce.
Step 12: Scan, Read Compliance, Run a Remediation Task
Compliance data refreshes on its own schedule, and Microsoft’s documentation does not promise a fixed interval you can plan around. Do not wait. Trigger an on-demand scan of the resource group instead, and expect it to take several minutes. The command blocks until the scan finishes unless you add --no-wait.
az policy state trigger-scan --resource-group $RG
az policy state summarize --resource-group $RG \
--query "results.{nonCompliantResources:nonCompliantResources, nonCompliantPolicies:nonCompliantPolicies}"
az policy state list --resource-group $RG \
--filter "complianceState eq 'NonCompliant'" \
--query "[].{resource:resourceId, policy:policyDefinitionReferenceId}" -o table
Resource Policy
---------------------------------------------------------------- ------------------
/subscriptions/.../storageAccounts/stlegacy20417 require-costcenter
/subscriptions/.../storageAccounts/stlegacy20417 inherit-costcenter
/subscriptions/.../storageAccounts/stlegacy20417 environment-values
The legacy account shows up three times because it fails three rules. The tag inheritance rule is the fixable one. The Require rule resolves as a side effect once the tag exists, and the environment rule needs a human to choose dev, test or prod. Create the remediation task for the Modify policy by naming the initiative member it targets.
az policy remediation create \
--name fix-costcenter-legacy \
--policy-assignment baseline-governance \
--definition-reference-id inherit-costcenter \
--resource-group $RG
az policy remediation show --name fix-costcenter-legacy \
--resource-group $RG \
--query "{state:provisioningState, total:deploymentSummary.totalDeployments, ok:deploymentSummary.successfulDeployments}"
az storage account show --name $LEGACY --resource-group $RG --query tags
State Total Ok
--------- ------- ----
Succeeded 1 1
{
"costcenter": "cc-1042"
}
That is the full loop: detect, remediate, verify. In production you point the same command at a subscription or management group, and one task can cover a large batch of resources. Run it in a quiet window anyway. Remediation issues real update calls against real resources, and each update can trigger downstream automation such as change alerts or deployment pipelines that watch for tag changes.
Step 13: Exemptions and Compliance Export
Every policy program meets an exception. A vendor appliance only runs in one region, or a migration needs three weeks in a region you are about to ban. Exemptions handle that without weakening the rule for everyone. An exemption removes a resource or scope from evaluation for one assignment, and it should always carry an expiry date so the exception cannot quietly become permanent.
ASSIGN_ID=$(az policy assignment show --name baseline-governance --scope $RG_ID --query id -o tsv)
az policy exemption create \
--name migration-region-waiver \
--display-name "Temporary: migration needs northeurope" \
--policy-assignment $ASSIGN_ID \
--policy-definition-reference-ids allowed-locations \
--exemption-category Waiver \
--expires-on 2026-12-31T00:00:00Z \
--scope $RG_ID \
--description "Ticket CHG-2041, owner platform-team"
az policy exemption list --scope $RG_ID \
--query "[].{name:name, category:exemptionCategory, expires:expiresOn}" -o table
The Waiver category means “we accept the risk.” The other category, Mitigated, means “another control covers it.” Auditors read those two words differently, so pick honestly. Note that an exempt resource is not evaluated, yet Microsoft’s documentation says exemptions still count in the scope’s compliance totals, so a clean-looking score may hide waivers. Report the exemption count next to the percentage.
Finish by exporting evidence. A JSON dump satisfies most audit requests, and a short jq filter turns it into a CSV that a spreadsheet owner can open.
az policy state list --resource-group $RG -o json > compliance-$(date +%F).json
jq -r '(["resource","policy","state"] | @csv),
(.[] | [.resourceId, .policyDefinitionReferenceId, .complianceState] | @csv)' \
compliance-$(date +%F).json > compliance-$(date +%F).csv
head -4 compliance-$(date +%F).csv
The Complete Working Project
Here is the whole baseline as one repeatable bundle. Keep it in Git, review changes through pull requests, and run it from a pipeline so no one edits governance in the portal. The layout is small on purpose.
azure-policy-baseline/
rule.json custom rule: tag value in approved list
params.json parameters for the custom rule
initiative-params.json parameters shared across the initiative
deploy.sh creates definitions, initiative, assignment
cleanup.sh removes every object this project created
#!/usr/bin/env bash
# deploy.sh - usage: ./deploy.sh rg-policy-lab eastus DoNotEnforce
set -euo pipefail
RG=${1:-rg-policy-lab}
LOC=${2:-eastus}
MODE=${3:-DoNotEnforce}
SUB_ID=$(az account show --query id -o tsv)
RG_ID=$(az group show --name "$RG" --query id -o tsv)
lookup() { az policy definition list --query "[?displayName=='$1'].name | [0]" -o tsv; }
ALLOWED_ID=$(lookup "Allowed locations")
REQTAG_ID=$(lookup "Require a tag on resources")
INHERIT_ID=$(lookup "Inherit a tag from the resource group if missing")
for ID in "$ALLOWED_ID" "$REQTAG_ID" "$INHERIT_ID"; do
[ -n "$ID" ] || { echo "built-in definition not found"; exit 1; }
done
az policy definition create --name tag-value-in-list \
--display-name "Tag value must be in an approved list" \
--mode Indexed --rules rule.json --params params.json >/dev/null
cat > initiative.json <<EOF
[
{"policyDefinitionReferenceId":"allowed-locations",
"policyDefinitionId":"/providers/Microsoft.Authorization/policyDefinitions/$ALLOWED_ID",
"parameters":{"listOfAllowedLocations":{"value":"[parameters('allowedRegions')]"}}},
{"policyDefinitionReferenceId":"require-costcenter",
"policyDefinitionId":"/providers/Microsoft.Authorization/policyDefinitions/$REQTAG_ID",
"parameters":{"tagName":{"value":"[parameters('costTagName')]"}}},
{"policyDefinitionReferenceId":"inherit-costcenter",
"policyDefinitionId":"/providers/Microsoft.Authorization/policyDefinitions/$INHERIT_ID",
"parameters":{"tagName":{"value":"[parameters('costTagName')]"}}},
{"policyDefinitionReferenceId":"environment-values",
"policyDefinitionId":"/subscriptions/$SUB_ID/providers/Microsoft.Authorization/policyDefinitions/tag-value-in-list",
"parameters":{}}
]
EOF
az policy set-definition create --name baseline-governance \
--display-name "Baseline governance: regions and tags" \
--definitions initiative.json --params initiative-params.json >/dev/null
az policy assignment create --name baseline-governance \
--display-name "Baseline governance" \
--policy-set-definition baseline-governance --scope "$RG_ID" \
--mi-system-assigned --location "$LOC" \
--identity-scope "$RG_ID" --role "Tag Contributor" \
--enforcement-mode "$MODE" >/dev/null
echo "assigned baseline-governance to $RG in mode $MODE"
#!/usr/bin/env bash
# cleanup.sh - removes everything created by this tutorial
set -uo pipefail
RG=${1:-rg-policy-lab}
RG_ID=$(az group show --name "$RG" --query id -o tsv)
az policy exemption delete --name migration-region-waiver --scope "$RG_ID"
az policy remediation delete --name fix-costcenter-legacy --resource-group "$RG"
az policy assignment delete --name baseline-governance --scope "$RG_ID"
az policy set-definition delete --name baseline-governance
az policy definition delete --name tag-value-in-list
az group delete --name "$RG" --yes --no-wait
Prefer infrastructure as code for the assignment itself? This Bicep file assigns the same initiative at subscription scope and keeps the enforcement mode as a parameter. Deploy it with az deployment sub create --location eastus --template-file assign.bicep.
targetScope = 'subscription'
param enforcementMode string = 'DoNotEnforce'
param location string = 'eastus'
resource baseline 'Microsoft.Authorization/policyAssignments@2024-04-01' = {
name: 'baseline-governance'
location: location
identity: { type: 'SystemAssigned' }
properties: {
displayName: 'Baseline governance'
policyDefinitionId: subscriptionResourceId('Microsoft.Authorization/policySetDefinitions', 'baseline-governance')
enforcementMode: enforcementMode
}
}
One caveat with the Bicep route. The managed identity still needs its Tag Contributor role assignment, which you add as a separate Microsoft.Authorization/roleAssignments resource. Forgetting it is the number one reason an IaC-deployed Modify policy “does nothing.”
Common Pitfalls
- Starting with Deny at subscription scope. One engineer’s failed pipeline becomes the team’s outage. Begin with Audit or
DoNotEnforce, read the report, then enforce. The two-step rollout adds a day and removes most incidents. - Expecting policies to fix old resources on their own. Compliance scans only report. Existing resources change only through a remediation task on a
modifyordeployIfNotExistspolicy. - Forgetting the managed identity role. A Modify or DeployIfNotExists assignment without the right role evaluates fine and then fails at remediation with an authorization error. Grant the narrowest role the definition lists, scoped to the assignment.
- Leaving out
--locationon identity assignments. The CLI rejects the command, and the error does not always point at the missing flag. - Expecting Modify to invent a tag value. The inherit rule copies from the resource group. If the group lacks the tag, nothing is added and the Deny rule then blocks the resource. Tag your groups first.
- Assuming Allowed locations covers everything. Resources in the
globalregion and resource groups themselves are outside its reach, so a lab can pass the test while a global resource still deploys. - Mixing Append and Modify for the same tag. Both run before Deny and can fight over a field. Pick Modify, which can also remediate, and retire older Append rules.
- Trusting a stale built-in ID. A display-name lookup fails loudly when a definition disappears, while a hardcoded ID fails quietly inside a template. Put a not-found check in your scripts, as
deploy.shdoes. - Ignoring Kubernetes. Subscription-level rules do not reach inside a cluster. If you run an Azure AKS cluster, pod-level rules need the cluster integration, which Microsoft reportedly refreshed in its training material in late October 2026.
Troubleshooting: 10 Problems and Fixes
- 1.
RequestDisallowedByPolicyon a deployment you expected to pass. Read the policy identifiers in the error. They name the assignment and definition that rejected the call. Fix the template, move to an allowed region, or request an exemption. - 2. A new assignment does nothing. Propagation takes a few minutes. Wait, retry, and confirm the scope in
az policy assignment showis the one your test resource lives under. - 3. Compliance view is empty or says “Not started.” The scan has not run yet, or
Microsoft.PolicyInsightsis not registered. Register the provider and runaz policy state trigger-scan. - 4.
PolicyDefinitionNotFoundwhen assigning a custom definition. The definition lives in a different subscription or management group than the assignment scope. Create it at the highest scope that will use it. - 5.
InvalidPolicyParameteror “parameter not defined.” The parameter name in--paramsdoes not match the definition. Check names withaz policy definition show --name <id> --query parameters. The built-in region policy useslistOfAllowedLocations, notlocations. - 6. “The location property is required” on create. You attached a managed identity without
--location. Add the flag. - 7. Remediation task fails with an authorization error. The assignment’s identity lacks the role the definition requires, or the role is scoped too narrowly. Add the role at the right scope and create a new task.
- 8. Remediation reports success but tags did not change. The resource group has no tag to inherit, or the resource type does not support tags. Check the parent group first.
- 9. An exemption does not take effect. The exemption points at the wrong assignment ID, or the reference ID does not match the initiative member. List the initiative’s reference IDs and compare. Also check whether the expiry date has passed.
- 10. A custom rule flags resources that cannot carry tags. The definition mode is
All. Switch toIndexedfor tag and location rules and re-create the definition.
Advanced Tips
Move Up to Management Groups
The lab uses a resource group so you can wreck it safely. A real rollout assigns the initiative at a management group and uses notScopes for sandboxes. Create definitions at that same management group so child subscriptions can see them. Split the estate into a strict production branch and a looser sandbox branch, and give the sandbox branch an Audit-only copy of the initiative.
Policy as code pays off fast. Store definitions in Git, test them in a sandbox subscription with DoNotEnforce, and promote through pull requests. Reviewers can see exactly what changes, and the history becomes the audit trail.
Guard AI Workloads and Use New Features Carefully
Microsoft’s governance article on AI workloads pointed to Azure Policy as the mechanism for guardrails, including limiting which models can be deployed and requiring private endpoints. If your team builds on Microsoft Foundry, the same tag and region baseline you built here is the right first layer, and AI-specific rules go on top.
Custom policy versioning reportedly entered public preview, letting a custom definition keep several versions under one ID. Preview features change. Test it in a sandbox, and do not build a production rollout on it until it reaches general availability.
- Use
denyActionfor delete protection. It blocks deletes of critical resources, which Deny on create or update cannot do. - Query with Resource Graph. Azure Resource Graph answers “which resources lack the tag” across subscriptions in seconds and works alongside compliance data.
- Name things for humans. Reference IDs like
inherit-costcenterread better in a remediation command than a GUID. - Alert on drift. Route Activity Log entries for policy denials to a channel so platform teams see rules that block real work.
- Review exemptions monthly. Sort by expiry date and close the ones that outlived their tickets.
FAQ
What is Azure Policy used for?
Azure Policy enforces organizational rules on Azure resources. Teams use it to restrict regions, require tags, limit resource types, and audit configuration. It evaluates new deployments at request time and existing resources through scans.
What is the difference between Azure Policy and RBAC?
RBAC controls which identities can perform actions. Azure Policy controls which resource configurations are allowed. A user with Owner rights can still be blocked by a Deny policy, because the rule targets the resource, not the person.
Does Azure Policy fix existing resources automatically?
No. Compliance evaluation reports non-compliance. Existing resources change only after you run a remediation task for a modify or deployIfNotExists policy. New and updated resources can be corrected during the request itself.
How long does it take for a policy assignment to apply?
Allow a few minutes for a new assignment to start affecting deployments. Compliance data follows its own schedule, and Microsoft documents no fixed interval to rely on, so trigger an on-demand scan with az policy state trigger-scan when you need an answer now.
Is Azure Policy free?
The Microsoft pages retrieved for this guide did not show a separate paid plan for core Azure Policy, and no figure is quoted here. Confirm on the Azure Policy pricing page before you scale. Some adjacent hybrid-server features carry their own pricing, so read those pages separately.
Should I use Audit or Deny first?
Start with Audit, or assign in DoNotEnforce mode, then read the compliance report. Move to Deny when the violations are understood and owners have had time to fix them.
Can I use Azure Policy with Terraform or Bicep?
Yes. Policy assignments, definitions and initiatives are Azure resources, so Bicep, ARM templates and Terraform can all manage them. Remember to grant the managed identity its role in the same deployment.
How do I clean up the lab?
Run cleanup.sh. It deletes the exemption, remediation task, assignment, initiative, custom definition and the resource group. Check the portal afterward to confirm nothing was left behind.





