Posts Azure Policy management with PowerShell
Post
Cancel

Azure Policy management with PowerShell

Deploy Arm Bicep and Terraform with PowerShell Orchestration

If you’ve spent any amount of time managing Azure environments, you already know this feeling:

“We should really have Azure Policy in place…”

And then days (or months) go by, and policies are still configured manually in the portal, half-documented, and nobody is quite sure who changed what, where, or why.

I tried to write this article not from the perspective of “perfect greenfield landing zones” or “everything is already deployed with pipelines”, but from the real world, where:

  • environments already exist,
  • governance was added later,
  • and you’re trying to make things more consistent without breaking everything.

Azure Policy is one of the most powerful tools we have for governance, but it can feel intimidating at first. JSON-heavy definitions, scopes, assignments, remediation identities… it’s a lot to take in.

The good news? You don’t have to get everything perfect on day one.

In this article, we’ll focus on a practical, PowerShell-based approach to Azure Policy that you can use:

  • to move away from click-ops,
  • to make policy deployment repeatable,
  • and to actually understand what’s happening under the hood.

Along the way, we’ll talk through why certain things work the way they do, not just how to make them work.

PowerShell vs IaC

Before we dive in, let’s set expectations.

If you are already using Terraform or Bicep everywhere, that’s great.
Those tools are absolutely the long-term best choice for managing Azure Policy as code.

But many teams aren’t there yet.

PowerShell is often:

  • already installed
  • already allowed
  • already familiar
  • and already used for day-to-day Azure administration

As an Azure administrator, you probably already use PowerShell to:

  • create and update resources
  • query configurations
  • automate repetitive tasks
  • fix things that drifted over time

Using PowerShell for Azure Policy fits naturally into that workflow. That makes it a very practical starting point for governance automation.

Think of PowerShell here as:

  • a stepping stone toward full IaC
  • a way to clean up existing environments
  • or a complement to IaC for operational tasks like remediation and reporting

If this article helps you go from portal-only policy to scripted and repeatable policy, that’s already a big win.

And prerequisites for starting with this are simple, you just need to install Az module, and authenticate. But I’m guessing you already have that.

Let’s get started.

What We Are Going to Build

Before jumping into code, let’s clearly define what will happen in this walkthrough.

By the end of this article, you will have PowerShell scripts that do all of the following:

  1. Create Azure Policy definitions from JSON files
    These define what is allowed, denied, audited, or automatically fixed.

  2. Assign those policies to a subscription scope
    This is where policy actually starts enforcing rules.

  3. Create a managed identity for policy remediation
    Required for policies that modify or deploy resources.

  4. Run remediation tasks
    To bring existing non-compliant resources into compliance.

  5. Group policies into a policy initiative
    So multiple policies can be managed and assigned together as a baseline.

  6. Assign the initiative and remediate it
    Just like a single policy, but at scale.

  7. Review compliance results using PowerShell
    So you can validate that policies are working and export results if needed.

These are the same steps you would use in:

  • customer environments,
  • production subscriptions,
  • or large enterprise tenants.

We’ll start simple, explain the tricky parts as we go, and gradually build up to more advanced scenarios like remediation and initiatives.

Next, before writing any code, we’ll take a moment to clarify some core Azure Policy concepts that often cause confusion — especially around scopes, effects, and remediation identities.

Azure Policy Concepts That Usually Cause Confusion

Before we write a single line of PowerShell, it’s worth taking a step back and making sure the core Azure Policy concepts actually make sense. Most problems people run into with Azure Policy are not caused by PowerShell or JSON syntax, but by misunderstanding how policy works conceptually.

Once these ideas click, Azure Policy becomes much easier to reason about and troubleshoot.

Policy Definitions vs Policy Assignments

This is the most important distinction to understand.

A policy definition is just a rule. It describes what Azure should check for and what action to take if the condition matches. Policy definitions are written in JSON and are fully reusable. By themselves, they do nothing. You can have dozens of policy definitions sitting in Azure without affecting a single resource.

A policy assignment is what actually makes a policy active. When you assign a policy, you tell Azure where the rule applies and how it should behave in that context. Assignments define the scope, provide parameter values, and optionally attach an identity for remediation.

A simple way to think about it is this:
a policy definition says what should be true, while a policy assignment says where and under what conditions it should be enforced.

This separation is intentional and powerful. It allows you to reuse the same definition across multiple subscriptions or management groups while tailoring the behavior through assignments.

Understanding Policy Scope and Inheritance

Azure Policy always applies within a scope. That scope can be a management group, a subscription, a resource group, or in rare cases, an individual resource.

Policies are inherited downward. If you assign a policy at a management group, it applies to all subscriptions below it. If you assign it at a subscription, it applies to all resource groups and resources inside that subscription.

For learning and for many real-world environments, starting at the subscription level is a good balance. It keeps the scope predictable and avoids accidental enforcement across large parts of the tenant.

You can also exclude specific scopes using notScopes on an assignment, which is useful when a policy mostly applies everywhere but has a few justified exceptions.

Policy Effects: What Azure Actually Does

The effect in a policy definition determines what happens when a resource matches the policy condition.

Some effects only observe, while others actively change resources.

A Deny effect blocks create or update operations. It prevents non-compliant resources from being created in the future, but it does not fix anything that already exists. This makes it ideal for hard guardrails where you want to stop bad configurations immediately.

An Audit effect allows the resource to exist but marks it as non-compliant. This is often used when introducing governance gradually, so you can understand the impact before enforcing stricter rules.

Modify and DeployIfNotExists are more advanced. These effects actively change the environment. Modify alters a resource during creation or update, while DeployIfNotExists deploys supporting resources when something is missing. These effects are extremely powerful, but they also introduce additional complexity.

Why Some Policies Need a Managed Identity

Policies that only audit or deny configurations don’t change anything, so Azure can evaluate them without special permissions.

Policies that modify resources or deploy additional components need an identity that has permission to perform those actions. That identity is attached to the policy assignment, not the policy definition.

This is a common point of confusion. The policy definition describes what should happen, but the assignment defines who is allowed to make it happen.

The managed identity created during assignment starts with no permissions. You must explicitly grant it the required RBAC roles, otherwise remediation and deployment will silently fail.

If you’ve ever seen a policy that looks correct but doesn’t remediate anything, missing permissions on the assignment identity are very often the reason.

Remediation Tasks Explained Properly

Remediation tasks are used to fix existing resources that were already non-compliant when the policy was assigned.

They do not affect new resources. New resources are handled automatically by the policy engine when they are created or updated.

Remediation runs as the policy assignment’s managed identity, uses the permissions you granted to that identity, and processes resources in scope over time. In large environments, remediation can take minutes or hours depending on the number of resources involved.

Think of remediation as a one-time (or occasional) clean-up operation to bring the environment into compliance.

Policy Initiatives: How This Scales in Real Environments

Managing policies one by one doesn’t scale very well. That’s why Azure provides policy initiatives, also called policy sets.

An initiative groups multiple policy definitions together and allows you to assign them as a single unit. This is how most organizations implement things like security baselines, network standards, or tagging requirements.

Initiatives also allow you to pass parameters to individual policies and report compliance in a more structured way. From an operational perspective, initiatives are almost always preferred once you move beyond a handful of policies.

In this article, we’ll start by creating individual policies so everything is clear, and then group them into an initiative the same way you would in a production environment.


With these concepts out of the way, we can move on to the hands-on part.

Next, we’ll start by creating Azure Policy definitions from JSON files and deploying them using PowerShell, step by step, with explanations along the way.

Creating Azure Policy Definitions from JSON

Now that the concepts are clear, we can finally start doing something practical.

Everything in Azure Policy starts with a policy definition, and policy definitions are just JSON files. If you’re comfortable keeping ARM, Bicep, or Terraform files in source control, this will feel very familiar. If you’re not, don’t worry. We’ll take it step by step.

The key idea here is simple:
treat policy definitions like code.

That means:

  • they live in files
  • they are versioned
  • and they are deployed in a repeatable way

Why JSON Files (and Not Inline PowerShell)?

Yes, PowerShell allows you to inline policy JSON directly in the script. Technically, that works.

In practice, it becomes painful very quickly.

Keeping policies as separate JSON files gives you:

  • readable and reviewable definitions
  • easier troubleshooting
  • clean separation between policy logic and deployment logic
  • and a much smoother transition to Bicep or Terraform later

PowerShell’s job will be to deploy the policy, not to define it.


You don’t need anything fancy, but a little structure goes a long way:

policy/ ├─ definitions/ │ ├─ deny-public-ip-nic.json │ └─ deploy-rg-lock-if-tagged.json └─ scripts/ └─ deploy-policies.ps1

This mirrors how most teams eventually organize governance code.

Example 1: A Simple Deny Policy

Let’s start with a straightforward example that doesn’t involve remediation or identities.

This policy prevents associating a Public IP address directly to a network interface.

definitions/deny-public-ip-nic.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  "properties": {
    "displayName": "Deny Public IP on Network Interfaces",
    "policyType": "Custom",
    "mode": "All",
    "description": "Prevents associating a Public IP address to a network interface.",
    "metadata": {
      "category": "Network"
    },
    "parameters": {},
    "policyRule": {
      "if": {
        "allOf": [
          {
            "field": "type",
            "equals": "Microsoft.Network/networkInterfaces"
          },
          {
            "field": "Microsoft.Network/networkInterfaces/ipconfigurations[*].publicIpAddress.id",
            "exists": "true"
          }
        ]
      },
      "then": {
        "effect": "deny"
      }
    }
  }
}

Let’s break down what matters here.

The mode is set to All, which works for most resource types and is generally safe unless you have a very specific reason to restrict it.

The if block checks two things:

  • the resource type is a network interface
  • and at least one IP configuration references a public IP

If both conditions are true, the deny effect blocks the operation.

This policy will not fix existing resources. It simply stops new or updated NICs from violating the rule. That’s exactly what we want for a hard guardrail.

Making the Script Idempotent (Very Important)

In real environments, you don’t want scripts that fail just because something already exists.

A common pattern is:

  • check if the definition exists
  • create it if it doesn’t
  • update it if it does
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$policyName = "deny-public-ip-nic"
$policyJson = Get-Content ".\definitions\deny-public-ip-nic.json" -Raw

$existingPolicy = Get-AzPolicyDefinition -Name $policyName -ErrorAction SilentlyContinue

if ($existingPolicy) {
    Write-Host "Updating policy definition: $policyName"
    Set-AzPolicyDefinition `
      -Name $policyName `
      -Policy $policyJson `
      -DisplayName "Deny Public IP on Network Interfaces" `
      -Mode All
} else {
    Write-Host "Creating policy definition: $policyName"
    New-AzPolicyDefinition `
      -Name $policyName `
      -Policy $policyJson `
      -DisplayName "Deny Public IP on Network Interfaces" `
      -Mode All
}

At this point, we have:

  • a policy definition stored as JSON
  • deployed to Azure using PowerShell
  • and ready to be used

In the next section, we’ll take this definition and assign it to a subscription, which is where policy enforcement actually begins — and where scopes and permissions start to matter.

Assigning a Policy to a Subscription

So far, we’ve created a policy definition. That’s an important step, but by itself it doesn’t change anything in your environment.

Policies only start doing something once they are assigned.

This is where many people have their first “aha” moment with Azure Policy:
definitions describe rules, assignments apply them.

In this section, we’ll assign the policy we created to a subscription scope, explain what actually happens behind the scenes, and highlight a few things that commonly trip people up.

Choosing the Right Scope

For this walkthrough, we’ll assign the policy at the subscription level.

That means:

  • every resource group in the subscription is in scope
  • every applicable resource inside those resource groups is evaluated
  • and enforcement is predictable and easy to reason about

Later, you can always move the same policy to a management group if needed.

Our first policy uses the deny effect, which means:

  • it does not modify resources
  • it does not deploy anything
  • and it does not need a managed identity

That makes it a good starting point.

1
2
3
4
5
6
7
8
9
10
$subscriptionId = (Get-AzContext).Subscription.Id
$scope = "/subscriptions/$subscriptionId"

$policyDefinition = Get-AzPolicyDefinition -Name "deny-public-ip-nic"

New-AzPolicyAssignment `
  -Name "deny-public-ip-nic-assignment" `
  -DisplayName "Deny Public IP on Network Interfaces" `
  -Scope $scope `
  -PolicyDefinition $policyDefinition

Once this command completes, enforcement starts immediately for:

  • new resource creations
  • and updates to existing resources.

If someone now tries to attach a public IP to a NIC in this subscription, Azure will block it.

Naming Assignments (Future You Will Thank You)

Assignment names matter more than people think.

You’ll see them:

  • in compliance reports
  • the Azure portal
  • PowerShell queries
  • and sometimes in alerting or dashboards

Clear names make governance easier to operate long-term.

Verifying the Assignment

You can quickly confirm the assignment exists:

Get-AzPolicyAssignment -Scope $scope | Where-Object { $_.Name -eq “deny-public-ip-nic-assignment” }

At this point:

  • the policy is active
  • enforcement is in place
  • and Azure will start evaluating resources in the background

Compliance data won’t appear instantly. Azure Policy evaluates on a schedule and also reacts to resource changes. Give it a bit of time before checking compliance results.

Policies That Fix Things: Managed Identity and Remediation in Practice

Up to now, everything we’ve done has been relatively safe and simple.
Our deny policy prevents bad configurations going forward, but it doesn’t touch anything that already exists.

This is where Azure Policy starts to feel really powerful — and also where things often go wrong if the details aren’t clear.

In this section, we’ll work with a policy that actually changes the environment:

  • it checks whether something is missing
  • and if it is, Azure Policy deploys it automatically

To do that safely, we need to understand managed identities and remediation.

Example 2: Protecting Important Resource Groups

Let’s say you have resource groups that are marked as important using a tag: protect = true.

For those resource groups, you want to make sure a CanNotDelete lock is always present. If someone forgets to add it, Azure should take care of it automatically.

This is a perfect use case for the DeployIfNotExists effect.

Why a Managed Identity Is Required

Unlike a deny policy, a DeployIfNotExists policy doesn’t just evaluate state — it performs actions.

That means Azure needs an identity that:

  • can create resource locks
  • in the correct scope
  • without using your personal credentials

That identity is created when you assign the policy and is attached to the policy assignment itself.

Important things to remember:

  • the identity starts with no permissions
  • permissions must be granted explicitly
  • remediation runs using this identity, not you

If remediation doesn’t work, permissions are the first thing to check.

Assigning the Policy with a Managed Identity

We’ll assume the policy definition for deploying resource group locks already exists (created in the previous section from JSON).

Now we assign it to the subscription and request a system-assigned managed identity.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$subscriptionId = (Get-AzContext).Subscription.Id
$scope = "/subscriptions/$subscriptionId"

$policyDefinition = Get-AzPolicyDefinition -Name "deploy-rg-lock-if-tagged"

$assignment = New-AzPolicyAssignment `
  -Name "deploy-rg-lock-if-tagged-assignment" `
  -DisplayName "Deploy RG lock when protect=true" `
  -Scope $scope `
  -PolicyDefinition $policyDefinition `
  -IdentityType SystemAssigned `
  -Location "eastus" `
  -PolicyParameterObject @{
    tagName  = "protect"
    tagValue = "true"
  }

Granting Permissions to the Policy Assignment Identity

Now we need to give that identity the rights required to deploy locks.

For simplicity, we’ll use the Contributor role at the subscription level. This is broad, but it keeps the example easy to follow.

1
2
3
4
5
6
$identityObjectId = $assignment.Identity.PrincipalId

New-AzRoleAssignment `
  -ObjectId $identityObjectId `
  -RoleDefinitionName "Contributor" `
  -Scope $scope

In real environments, you would usually:

  • use a more restrictive role
  • or create a custom role that only allows the specific actions required by the policy

But the mechanism is the same.

Running a Remediation Task

At this point:

  • the policy is assigned
  • the identity has permissions
  • but existing resource groups may still be missing locks

This is where remediation comes in.

1
2
3
4
Start-AzPolicyRemediation `
  -Name "remediate-rg-locks" `
  -Scope $scope `
  -PolicyAssignmentId $assignment.PolicyAssignmentId

This tells Azure Policy to:

  • find existing non-compliant resource groups in scope
  • deploy the missing locks
  • and track progress

Remediation runs asynchronously. In larger subscriptions, it may take some time to complete.

What Happens Going Forward?

After remediation:

  • existing resource groups with protect=true will have locks
  • new resource groups created with that tag will automatically get a lock
  • and compliance will stay green unless someone removes the lock

This is the real value of Azure Policy:

  • not just blocking bad behavior
  • but actively enforcing standards in a consistent, automated way

Grouping Policies with an Initiative (How This Scales in Real Environments)

Once you have more than a handful of policies, managing them one by one quickly becomes painful. You end up with multiple assignments, scattered parameters, and compliance views that are hard to reason about.

This is where policy initiatives (also called policy sets) come in.

If you think in terms of real environments, initiatives are what allow you to move from “a few policies” to an actual governance baseline.

What a Policy Initiative Really Is

A policy initiative is simply a collection of policy definitions that are managed and assigned together.

Nothing magical happens to the policies themselves:

  • each policy still evaluates independently
  • each policy still reports compliance
  • remediation still works the same way

The difference is how you manage them.

Instead of assigning five or ten policies individually, you assign one initiative, which:

  • applies all policies at once
  • keeps related policies grouped together
  • makes compliance reporting easier to understand

This is how organizations typically implement:

  • security baselines
  • network standards
  • tagging requirements
  • cost and governance guardrails

Why Initiatives Are Easier to Operate

From an operational perspective, initiatives solve a few real problems.

They give you:

  • fewer assignments to track
  • a single place to tune parameters
  • cleaner compliance views
  • and a clearer story when someone asks “What governance do we have in place?”

Instead of answering with a long list of individual policies, you can say:

“We apply our subscription governance baseline.”

That matters more than it sounds.

Creating an Initiative from Existing Policies

We’ll build an initiative using the two policies we already created:

  • the deny policy for public IPs on NICs
  • the deploy-if-not-exists policy for resource group locks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$denyPolicy = Get-AzPolicyDefinition -Name "deny-public-ip-nic"
$lockPolicy = Get-AzPolicyDefinition -Name "deploy-rg-lock-if-tagged"

$policyDefinitions = @(
  @{
    policyDefinitionId = $denyPolicy.PolicyDefinitionId
    policyDefinitionReferenceId = "denyPublicIpOnNic"
  },
  @{
    policyDefinitionId = $lockPolicy.PolicyDefinitionId
    policyDefinitionReferenceId = "deployRgLock"
    parameters = @{
      tagName  = @{ value = "protect" }
      tagValue = @{ value = "true" }
    }
  }
)

$initiative = New-AzPolicySetDefinition `
  -Name "subscription-governance-baseline" `
  -DisplayName "Subscription Governance Baseline" `
  -Description "Baseline policies for network and resource protection." `
  -PolicyDefinition $policyDefinitions `
  -Metadata '{ "category": "Governance" }'

Assigning the Initiative

From here on, working with an initiative feels very similar to working with a single policy.

Because our initiative contains a policy that uses DeployIfNotExists, we again need a managed identity.

1
2
3
4
5
6
7
8
9
10
$subscriptionId = (Get-AzContext).Subscription.Id
$scope = "/subscriptions/$subscriptionId"

$initiativeAssignment = New-AzPolicyAssignment `
  -Name "subscription-governance-baseline-assignment" `
  -DisplayName "Subscription Governance Baseline" `
  -Scope $scope `
  -PolicySetDefinition $initiative `
  -IdentityType SystemAssigned `
  -Location "eastus"

You also need to grant permissions and run the remediation same as we did it before.

Conclusion

If there’s one takeaway from all of this, it’s that Azure Policy doesn’t have to be scary or overly complex to be useful. You don’t need a perfect landing zone, a fully built CI/CD pipeline, or a massive IaC refactor before you can start enforcing better governance. PowerShell gives you a very practical way to move from “we should really do something about this” to actually having repeatable, understandable policy enforcement in place.

What matters most is not whether your policies are deployed with PowerShell, Bicep, or Terraform, but that they are written down, versioned, and applied consistently. PowerShell simply happens to be a tool many Azure administrators already know and trust, which makes it a great entry point into policy-as-code and automated governance. Once the concepts are clear, moving those same policies into Bicep or Terraform later becomes a natural next step, not a rewrite from scratch.

Azure Policy works best when it supports administrators instead of fighting them. Start small, apply policies at a scope you can control, understand what each policy actually does, and add remediation only when you’re confident it’s safe. Over time, those small, well-understood policies turn into a solid governance baseline that quietly does its job in the background.

And that’s really the goal: fewer surprises, fewer manual fixes, and an Azure environment that behaves the way you expect it to, even when you’re not watching.

I hope this was helpful. Thanks for reading and keep clouding around.

Vukasin Terzic

This post is licensed under CC BY 4.0