This guide continues the Active Directory at Scale series. It assumes a Windows host (GPMC cmdlets are Windows-only), the
GroupPolicymodule (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:
- The GPO container an AD object with a GUID, display name, permissions, WMI filter, links. Lives in
CN=Policies,CN=System,DC=…. - The GPO template the actual settings. Lives in
\\domain\SYSVOL\<domain>\Policies\{GUID}\. Subdivided intoMachine\,User\, withRegistry.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-DesiredGpoStatereads the current GPO (exists? permissions? WMI filter? links?).Test-GpoCompliancecompares current to the YAML alongside each GPO folder.Set-GpoComplianceimports 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 pristineBackup-GPOoutput for applying. The normalizer is one-way; you can'tImport-GPOa pretty-printedgpreport.xml. - Split link / permission / WMI reconciliation. Each is its own Get-Test-Set triple, called only if the parent GPO is compliant on content.
Links the usual source of drift
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 -CreateIfNeededsilently 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
PolicyDefinitionscentral 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.


