This guide continues the Active Directory at Scale series. It assumes a Windows host (GPMC cmdlets are Windows-only), the GroupPolicy module (Add-WindowsCapability -Online -Name Rsat.GroupPolicy.Management.Tools~~~~0.0.1.0), and Domain Admin or equivalent delegated rights on the target domain.

Group Policy is the piece of Active Directory most teams never put in source control. Everything else OUs, groups, DNS can be written as YAML with enough squinting, but GPOs are XML blobs inside SYSVOL with timestamps, SIDs, and binary ADMX references baked in. "Just export them" produces files that diff noisily on every read.

This post fixes that. We export GPOs with Backup-GPO, normalize the output so it diffs cleanly, commit the normalized form to git, and apply with an idempotent pipeline. The end state: a pull request changing a registry setting or a security filter shows exactly those changes, and nothing else.

The Two Forms of a GPO

A GPO on disk has two halves:

  1. The GPO container an AD object with a GUID, display name, permissions, WMI filter, links. Lives in CN=Policies,CN=System,DC=….
  2. The GPO template the actual settings. Lives in \\domain\SYSVOL\<domain>\Policies\{GUID}\. Subdivided into Machine\, User\, with Registry.pol, GptTmpl.inf, scripts.ini, etc.

Backup-GPO produces a folder that contains everything needed to recreate both halves on any domain. That's our starting point.

Export Backup-GPO + Normalization

$backupRoot = 'C:\gpo-export'
$null = New-Item -ItemType Directory -Path $backupRoot -Force

Get-GPO -All | ForEach-Object {
    $dest = Join-Path $backupRoot $_.DisplayName
    $null = New-Item -ItemType Directory -Path $dest -Force
    Backup-GPO -Guid $_.Id -Path $dest -Domain $_.DomainName | Out-Null
}

That gives you one folder per GPO containing:

{BACKUP-GUID}\
├── Backup.xml          <- metadata (timestamps, source GUID, etc.)
├── bkupInfo.xml
├── gpreport.xml        <- the full, human-readable settings export
└── DomainSysvol\       <- the SYSVOL tree to restore
    └── GPO\
        ├── Machine\
        └── User\

Backup.xml and bkupInfo.xml change on every export (timestamps, random backup GUIDs). Left alone, they poison every diff.

The normalizer

A small post-processor strips the volatile bits and pretty-prints gpreport.xml and the Registry.pol → XML conversion so git sees only the actual setting changes:

function ConvertTo-NormalizedGpoBackup
{
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [string]$BackupPath,
        [Parameter(Mandatory)] [string]$OutputPath
    )

    begin
    {
        Write-Verbose -Message '[ConvertTo-NormalizedGpoBackup] Begin'
    }

    process
    {
        if (Test-Path $OutputPath) { Remove-Item $OutputPath -Recurse -Force }
        $null = New-Item -ItemType Directory -Path $OutputPath -Force

        # 1. gpreport.xml - canonical human-readable form
        $report = Get-Content (Join-Path $BackupPath 'gpreport.xml') -Raw
        $xml = [xml]$report
        # Strip timestamps that change on every backup
        $xml.SelectNodes('//*[local-name()="CreatedTime" or local-name()="ModifiedTime" or local-name()="ReadTime"]') |
            ForEach-Object { $_.ParentNode.RemoveChild($_) | Out-Null }

        # Pretty-print with stable ordering
        $settings = New-Object System.Xml.XmlWriterSettings
        $settings.Indent = $true
        $settings.IndentChars = '  '
        $settings.OmitXmlDeclaration = $false

        $out = Join-Path $OutputPath 'gpreport.xml'
        $writer = [System.Xml.XmlWriter]::Create($out, $settings)
        try   { $xml.Save($writer) }
        finally { $writer.Dispose() }

        # 2. Convert Registry.pol -> JSON (sorted, stable)
        Get-ChildItem $BackupPath -Recurse -Filter 'Registry.pol' | ForEach-Object {
            $rel = $_.FullName.Substring($BackupPath.Length).TrimStart('\','/')
            $dest = Join-Path $OutputPath ($rel + '.json')
            $null = New-Item -ItemType Directory -Path (Split-Path $dest) -Force
            ConvertFrom-RegistryPol -Path $_.FullName | ConvertTo-Json -Depth 10 |
                Set-Content -Path $dest -Encoding UTF8
        }

        # 3. Copy any .inf files (GptTmpl.inf) verbatim - already text, already stable
        Get-ChildItem $BackupPath -Recurse -Include '*.inf','*.ini' | ForEach-Object {
            $rel = $_.FullName.Substring($BackupPath.Length).TrimStart('\','/')
            $dest = Join-Path $OutputPath $rel
            $null = New-Item -ItemType Directory -Path (Split-Path $dest) -Force
            Copy-Item $_.FullName $dest
        }

        # 4. Drop Backup.xml / bkupInfo.xml - pure churn
    }

    end
    {
        Write-Verbose -Message '[ConvertTo-NormalizedGpoBackup] End'
    }
}

ConvertFrom-RegistryPol is the missing ingredient. Registry.pol is a binary format; turning it into a sorted JSON object stabilizes every dimension that matters:

function ConvertFrom-RegistryPol
{
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param([Parameter(Mandatory)] [string]$Path)

    begin
    {
        Write-Verbose -Message '[ConvertFrom-RegistryPol] Begin'
    }

    process
    {
        $bytes = [System.IO.File]::ReadAllBytes($Path)
        if ($bytes.Length -lt 8 -or
            [System.BitConverter]::ToInt32($bytes, 0) -ne 0x67655250)   # "PReg"
        {
            throw "Not a valid Registry.pol: $Path"
        }

        $entries = New-Object System.Collections.Generic.List[object]
        $i = 8      # skip signature + version

        while ($i -lt $bytes.Length)
        {
            if ($bytes[$i] -ne 0x5B) { break }   # '['
            $i++
            # Read UTF-16LE fields terminated by 0x00 0x00, separated by ';'
            $fields = @()
            $sb = New-Object System.Text.StringBuilder
            while ($i -lt $bytes.Length -and -not ($bytes[$i] -eq 0x5D -and $bytes[$i+1] -eq 0x00))   # ']'
            {
                if ($bytes[$i] -eq 0x3B -and $bytes[$i+1] -eq 0x00)   # ';'
                {
                    $fields += $sb.ToString(); $sb.Length = 0; $i += 2
                    continue
                }
                if ($bytes[$i] -eq 0x00 -and $bytes[$i+1] -eq 0x00)
                {
                    $fields += $sb.ToString(); $sb.Length = 0; $i += 2
                    continue
                }
                $ch = [System.BitConverter]::ToUInt16($bytes, $i)
                [void]$sb.Append([char]$ch)
                $i += 2
            }
            $i += 2   # skip ']' 0x00

            if ($fields.Count -ge 4)
            {
                $entries.Add([pscustomobject]@{
                    Key   = $fields[0]
                    Value = $fields[1]
                    Type  = [int]($fields[2] -as [int])
                    Size  = [int]($fields[3] -as [int])
                })
            }
        }

        # Sort for stable diffs
        $entries | Sort-Object Key, Value
    }

    end
    {
        Write-Verbose -Message '[ConvertFrom-RegistryPol] End'
    }
}

The parser is deliberately shallow it captures {Key, Value, Type, Size} per entry, which is what humans want to read in a diff. The raw bytes for REG_BINARY values still live in the original Registry.pol; we're not trying to make those round-trip in git. Apply still uses the real binary file.

What the repo looks like

repo/
├── gpos/
│   ├── Default Domain Policy/
│   │   ├── gpreport.xml
│   │   ├── DomainSysvol/GPO/Machine/Registry.pol.json
│   │   └── DomainSysvol/GPO/Machine/microsoft/windows nt/SecEdit/GptTmpl.inf
│   ├── CIS-Windows-Server-2022/
│   │   └── ...
│   └── Remote-Management-Baseline/
│       └── ...
└── src/ADOps/
    └── ...

Every file is text. Every file sorts deterministically. Two exports of an unchanged GPO produce byte-identical output.

Diff What Changed

Once the repo is in this shape, git diff is the only tool you need. A pull request that adds NtfsDisable8dot3NameCreation = 1 shows exactly one line changed:

  {
    "Key": "System\\CurrentControlSet\\Control\\FileSystem",
+   "Value": "NtfsDisable8dot3NameCreation",
+   "Type": 4,
+   "Size": 4
  }

A pull request that renames a GPO shows the folder rename. A pull request that adds a new WMI filter shows the gpreport.xml diff. What the code review shows is what the GPO does.

Apply Idempotent Import-GPO

The Get-Test-Set pattern extends to GPOs naturally:

  • Get-DesiredGpoState reads the current GPO (exists? permissions? WMI filter? links?).
  • Test-GpoCompliance compares current to the YAML alongside each GPO folder.
  • Set-GpoCompliance imports from the backup folder if content drifted, applies links, permissions, WMI filter.

Each GPO folder has a companion gpo.yaml:

id: gpo.cis-windows-server-2022
displayName: CIS Windows Server 2022
description: CIS L1 baseline
wmiFilter: wmi.windows-servers-only       # by id; see filters.yaml
links:
  - ou: ou.servers
    enabled: true
    enforced: false
    order: 10
permissions:
  - identity: Authenticated Users
    level: Apply
  - identity: Domain Admins
    level: Edit
comment: |
  Source: https://www.cisecurity.org/benchmark/microsoft_windows_server
  Version: 3.0.0

The Set-* function:

function Set-GpoCompliance
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)] [string]$GpoFolder,
        [Parameter(Mandatory)] [pscustomobject]$Desired,
        [Parameter(Mandatory)] [string]$Domain
    )

    begin
    {
        Write-Verbose -Message '[Set-GpoCompliance] Begin'
    }

    process
    {
        $ErrorActionPreference = 'Stop'

        $actions = New-Object System.Collections.Generic.List[string]
        $errors  = New-Object System.Collections.Generic.List[string]

        # 1. Find or create the GPO
        $gpo = Get-GPO -Name $Desired.DisplayName -Domain $Domain -ErrorAction SilentlyContinue

        if (-not $gpo)
        {
            if ($PSCmdlet.ShouldProcess($Desired.DisplayName, 'Create empty GPO'))
            {
                try
                {
                    $gpo = New-GPO -Name $Desired.DisplayName -Comment $Desired.Description -Domain $Domain -ErrorAction Stop
                    $actions.Add('Created GPO')
                }
                catch [System.ArgumentException]
                {
                    # Race: another runner created the GPO between Get-GPO and New-GPO.
                    $errors.Add(('GPO {0} already exists; re-reading' -f $Desired.DisplayName))
                    $gpo = Get-GPO -Name $Desired.DisplayName -Domain $Domain -ErrorAction Stop
                }
                catch
                {
                    $errors.Add(('New-GPO failed for {0}: {1}' -f $Desired.DisplayName, $_.Exception.Message))
                    throw
                }
            }
        }

        # 2. Content - diff the backup folder hash against the live GPO
        $live = Join-Path $env:TEMP "gpo-live-$($gpo.Id)"

        if (Test-Path $live)
        {
            Remove-Item $live -Recurse -Force
        }

        try
        {
            $null = Backup-GPO -Guid $gpo.Id -Domain $Domain -Path $live -ErrorAction Stop
        }
        catch
        {
            $errors.Add(('Backup-GPO failed for {0}: {1}' -f $Desired.DisplayName, $_.Exception.Message))
            throw
        }

        $liveNorm = Join-Path $env:TEMP "gpo-live-norm-$($gpo.Id)"
        ConvertTo-NormalizedGpoBackup -BackupPath (Get-ChildItem $live -Directory)[0].FullName -OutputPath $liveNorm

        if (-not (Test-GpoContentEqual -Left $GpoFolder -Right $liveNorm))
        {
            if ($PSCmdlet.ShouldProcess($Desired.DisplayName, 'Import GPO content'))
            {
                # Import-GPO needs the raw backup, not the normalized form.
                # We keep the pristine backup inside the repo under .source/ for apply.
                $source = Join-Path $GpoFolder '.source'
                $backup = Get-ChildItem $source -Directory | Select-Object -First 1
                $importParams = @{
                    BackupId       = $backup.Name
                    Path           = $source
                    TargetName     = $Desired.DisplayName
                    CreateIfNeeded = $true
                    ErrorAction    = 'Stop'
                }

                try
                {
                    $null = Import-GPO @importParams
                    $actions.Add('Imported GPO content')
                }
                catch
                {
                    $errors.Add(('Import-GPO failed for {0}: {1}' -f $Desired.DisplayName, $_.Exception.Message))
                    throw
                }
            }
        }

        # 3. Links - reconcile the link set
        Set-GpoLinkCompliance -GpoId $gpo.Id -DesiredLinks $Desired.Links -Actions $actions -Errors $errors

        # 4. Permissions
        Set-GpoPermissionCompliance -GpoId $gpo.Id -DesiredPermissions $Desired.Permissions -Actions $actions -Errors $errors

        # 5. WMI filter
        if ($Desired.WmiFilter)
        {
            Set-GpoWmiFilterCompliance -GpoId $gpo.Id -FilterId $Desired.WmiFilter -Actions $actions -Errors $errors
        }

        [pscustomobject]@{
            Name    = $Desired.DisplayName
            Changed = $actions.Count -gt 0
            Actions = $actions.ToArray()
            Errors  = $errors.ToArray()
        }
    }

    end
    {
        Write-Verbose -Message '[Set-GpoCompliance] End'
    }
}

Three patterns worth calling out:

  • Round-trip to test equality. Export the live GPO, normalize it, compare to the repo's normalized form. If equal, skip the import. This is what makes the pipeline idempotent. It also catches out-of-band edits.
  • Keep the raw backup in .source/. The repo has two representations per GPO: the normalized text for diffing, and the pristine Backup-GPO output for applying. The normalizer is one-way; you can't Import-GPO a pretty-printed gpreport.xml.
  • Split link / permission / WMI reconciliation. Each is its own Get-Test-Set triple, called only if the parent GPO is compliant on content.

GPO links live on the OU, not the GPO. Reconciling them is:

function Set-GpoLinkCompliance
{
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [guid]$GpoId,
        [Parameter(Mandatory)] [array]$DesiredLinks,
        [Parameter(Mandatory)] [System.Collections.Generic.List[string]]$Actions,
        [Parameter(Mandatory)] [System.Collections.Generic.List[string]]$Errors
    )

    begin
    {
        Write-Verbose -Message '[Set-GpoLinkCompliance] Begin'
    }

    process
    {
        $ErrorActionPreference = 'Stop'

        $gpo       = Get-GPO -Guid $GpoId
        $liveLinks = @()

        Get-ADOrganizationalUnit -Filter * -Properties gPLink | ForEach-Object {
            if ($_.gPLink -match [regex]::Escape("{$GpoId}"))
            {
                $liveLinks += [pscustomobject]@{ OuDn = $_.DistinguishedName }
            }
        }

        # Translate desired OU ids -> DNs via the AD-as-code lookup
        $desiredDn = $DesiredLinks | ForEach-Object {
            (Find-AdObjectById -Id $_.ou -SearchBase $state.searchBase).DistinguishedName
        }

        # Remove links on OUs that aren't desired
        foreach ($live in $liveLinks)
        {
            if ($live.OuDn -notin $desiredDn)
            {
                if ($PSCmdlet.ShouldProcess($live.OuDn, "Unlink GPO $($gpo.DisplayName)"))
                {
                    try
                    {
                        Remove-GPLink -Guid $GpoId -Target $live.OuDn -ErrorAction Stop
                        $Actions.Add("Unlinked from $($live.OuDn)")
                    }
                    catch
                    {
                        $Errors.Add(('Remove-GPLink failed for {0}: {1}' -f $live.OuDn, $_.Exception.Message))
                        throw
                    }
                }
            }
        }

        # Add missing links
        foreach ($d in $DesiredLinks)
        {
            $dn = (Find-AdObjectById -Id $d.ou -SearchBase $state.searchBase).DistinguishedName

            if ($dn -notin $liveLinks.OuDn)
            {
                if ($PSCmdlet.ShouldProcess($dn, "Link GPO $($gpo.DisplayName)"))
                {
                    $linkParams = @{
                        Guid        = $GpoId
                        Target      = $dn
                        LinkEnabled = if ($d.enabled) { 'Yes' } else { 'No' }
                        Enforced    = if ($d.enforced) { 'Yes' } else { 'No' }
                        Order       = $d.order
                        ErrorAction = 'Stop'
                    }

                    try
                    {
                        $null = New-GPLink @linkParams
                        $Actions.Add("Linked to $dn")
                    }
                    catch
                    {
                        $Errors.Add(('New-GPLink failed for {0}: {1}' -f $dn, $_.Exception.Message))
                        throw
                    }
                }
            }
        }
    }

    end
    {
        Write-Verbose -Message '[Set-GpoLinkCompliance] End'
    }
}

One rule: always link by ID to a DN lookup (via the AD-as-code reconciler), never hardcode DNs. Otherwise a rename of OU=Servers to OU=MemberServers un-links every GPO.

Migration Tables Crossing Domain Boundaries

Import-GPO has a powerful -MigrationTable parameter. It rewrites SIDs, UNC paths, and domain names during import. This is how the same GPO backup applies cleanly to lab and production the lab domain's SIDs aren't the prod ones.

A migration table is an XML file:

<MigrationTable xmlns="http://www.microsoft.com/GroupPolicy/GPOOperations/MigrationTable">
  <Mapping>
    <Source>CORP-LAB\Domain Admins</Source>
    <Destination>CORP\Domain Admins</Destination>
    <Type>GlobalGroup</Type>
  </Mapping>
  <Mapping>
    <Source>\\lab.corp.local\software</Source>
    <Destination>\\corp.local\software</Destination>
    <Type>UNCPath</Type>
  </Mapping>
</MigrationTable>

Generate it from YAML at apply time:

function New-MigrationTable
{
    param([Parameter(Mandatory)] [array]$Mappings, [Parameter(Mandatory)] [string]$OutFile)

    $xml = New-Object System.Text.StringBuilder
    $null = $xml.AppendLine('<?xml version="1.0" encoding="utf-8"?>')
    $null = $xml.AppendLine('<MigrationTable xmlns="http://www.microsoft.com/GroupPolicy/GPOOperations/MigrationTable">')
    foreach ($m in $Mappings)
    {
        $null = $xml.AppendLine("  <Mapping>")
        $null = $xml.AppendLine("    <Source>$($m.source)</Source>")
        $null = $xml.AppendLine("    <Destination>$($m.destination)</Destination>")
        $null = $xml.AppendLine("    <Type>$($m.type)</Type>")
        $null = $xml.AppendLine("  </Mapping>")
    }
    $null = $xml.AppendLine('</MigrationTable>')
    $xml.ToString() | Set-Content -Path $OutFile -Encoding UTF8
}

Then in Set-GpoCompliance wire it in:

$mig = Join-Path $env:TEMP "gpo-mig-$($gpo.Id).xml"
New-MigrationTable -Mappings $state.migrationMappings -OutFile $mig

$importParams = @{
    BackupId       = $backup.Name
    Path           = $source
    TargetName     = $Desired.DisplayName
    MigrationTable = $mig
    CreateIfNeeded = $true
}
$null = Import-GPO @importParams

PolicyAnalyzer Auditing Against a Baseline

Microsoft's Security Compliance Toolkit ships a tool called PolicyAnalyzer that can diff a live GPO against a published baseline (or two GPOs against each other). It's a Windows Forms app, but it can also run from the command line:

$paArgs = @(
    '/a'
    '/policyrulefiles', 'C:\Baselines\MSFT-Win11-24H2-Final.PolicyRules'
    '/outputxlsx',      "C:\drift-$(Get-Date -Format yyyyMMdd).xlsx"
)
& 'C:\Tools\PolicyAnalyzer\PolicyAnalyzer.exe' @paArgs

Run this weekly from the same runner that handles apply. If a setting drifts from the published baseline, the XLSX highlights it. For CI you can convert the XLSX to CSV with ImportExcel and fail the build on diff count > 0.

The Top-Level Runner

Bringing it together:

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)] [string]$GposRoot,       # repo/gpos
    [Parameter(Mandatory)] [string]$StateFile      # repo/state/prod.yaml
)

$ErrorActionPreference = 'Stop'
Import-Module ./src/ADOps/ADOps.psd1 -Force

$state = Get-Content $StateFile -Raw | ConvertFrom-Yaml

Get-ChildItem $GposRoot -Directory | ForEach-Object {
    $yaml  = Join-Path $_.FullName 'gpo.yaml'
    if (-not (Test-Path $yaml)) { return }

    $gpoDesired = Get-Content $yaml -Raw | ConvertFrom-Yaml
    Set-GpoCompliance -GpoFolder $_.FullName -Desired $gpoDesired -Domain $state.domain
}

Wire the same script into CI, exactly as in the AD-as-code post. A PR that adds a registry value renders cleanly in the diff, the plan job shows the exact import that will happen, the apply job commits to production.

Gotchas

  • GPMC scripting cmdlets are Windows-only. Your runner must be a Windows self-hosted CI runner, not a Linux container. Accept this; it isn't going away.
  • Replication lag between DCs means a newly-created GPO isn't immediately visible to Set-GPLink. Either target the same DC for the whole reconciliation (-Server) or add retry around the link step.
  • WMI filters are GPO-adjacent but separate AD objects. Manage them with their own Get-Test-Set triple, reusing the same stable-id scheme see the WMI filters as code post for the encoder, parser, and link reconciler.
  • Import-GPO -CreateIfNeeded silently overwrites an existing GPO of the same name. That's the behavior you want for reconciliation, but it means the GPO you just clicked together in GPMC and forgot to export disappears on the next run. Train people.
  • Don't commit the pristine backup folder to git using the live timestamped name. Rename it to a stable name (.source/backup) before commit; otherwise every export churns the path.
  • Administrative Templates (ADMX files) aren't in the backup. If a GPO references a setting from a custom ADMX, the ADMX must already exist in the PolicyDefinitions central store on the target. Handle that separately in your bootstrap.

Final Notes

Group Policy can be real code. Export → normalize → diff → apply turns a black-box system into one where a line in Registry.pol appears as a line in a pull request. The next post tackles WMI filters as code the silent half of Group Policy that decides whether your GPO actually applies and the rest of the series applies the same rhythm to certificate templates, security baselines, and cleanup of stale objects.

The hardest part isn't the code it's getting your team to make changes in a pull request instead of in GPMC. Once the first real change survives the full pipeline, the habit sticks.