THE PROBLEM
Ask a sysadmin when they last backed up Active Directory and you'll get a confident answer. Ask when they last backed up Group Policy and you'll usually get a pause.
GPOs live in two places — the policy object in AD and the template files in SYSVOL — which means they're covered by some backup somewhere, in theory. In practice, restoring a single GPO from a system state backup is a miserable process that nobody has rehearsed, and it's the last thing you want to be figuring out at 4pm on a Friday after somebody edited the wrong setting.
The failure mode is rarely dramatic. It's someone tightening a security baseline, scoping it wrong, and breaking logon scripts for one OU. Or a firewall setting that looked harmless taking out RDP to forty servers after the next policy refresh. The change was made Tuesday, the breakage surfaced Thursday, and nobody can say what the setting was before.
This week we fix both halves: nightly backups you can actually restore from, and a daily report of exactly which GPOs changed.
THE SCRIPT
Save the script below to:
C:\Scripts\GPOBackupAudit.ps1PowerShell
# Group Policy Backup & Change Detection
# Automate & Operate — automateandoperate.com
Import-Module GroupPolicy
Import-Module ActiveDirectory
# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
# Where backups are written — use a UNC path to a backed-up file server if you can
$backupRoot = "C:\GPOBackups"
$logFile = "C:\Logs\GPOBackupAudit.log"
$reportFile = "C:\Logs\GPOReport.csv"
$baselineFile = "C:\Logs\GPOBaseline.csv"
# How many days of backup folders to keep
$retentionDays = 30
# Create folders if missing
foreach ($folder in @("C:\Logs", $backupRoot)) {
if (-not (Test-Path $folder)) {
New-Item -ItemType Directory -Path $folder -Force | Out-Null
}
}
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$dateStamp = Get-Date -Format "yyyy-MM-dd"
$backupPath = Join-Path $backupRoot $dateStamp
$report = @()
$changed = @()
$newGPOs = @()
$removedGPOs = @()
$unlinked = @()
$backupStatus = "NOT ATTEMPTED"
$backupCount = 0
# ---------- BACKUP ALL GPOs ----------
try {
if (-not (Test-Path $backupPath)) {
New-Item -ItemType Directory -Path $backupPath -Force | Out-Null
}
$backups = Backup-GPO -All -Path $backupPath -Comment "Automated backup $dateStamp" -ErrorAction Stop
$backupCount = ($backups | Measure-Object).Count
$backupStatus = "SUCCESS"
Add-Content -Path $logFile -Value "$timestamp — BACKUP OK — $backupCount GPOs written to $backupPath"
} catch {
$backupStatus = "FAILED: $_"
Add-Content -Path $logFile -Value "$timestamp — BACKUP FAILED — $_"
}
# ---------- PRUNE OLD BACKUPS ----------
try {
$cutoff = (Get-Date).AddDays(-$retentionDays)
$oldFolders = Get-ChildItem -Path $backupRoot -Directory |
Where-Object { $_.CreationTime -lt $cutoff }
foreach ($folder in $oldFolders) {
Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction SilentlyContinue
Add-Content -Path $logFile -Value "$timestamp — PRUNED old backup: $($folder.Name)"
}
} catch {
Add-Content -Path $logFile -Value "$timestamp — PRUNE ERROR — $_"
}
# ---------- INVENTORY CURRENT GPOs ----------
try {
$gpos = Get-GPO -All -ErrorAction Stop
foreach ($gpo in $gpos) {
# Find where this GPO is linked by reading its XML report
$linkCount = 0
$links = "NONE"
try {
[xml]$gpoXml = Get-GPOReport -Guid $gpo.Id -ReportType Xml -ErrorAction Stop
if ($gpoXml.GPO.LinksTo) {
$linkPaths = @($gpoXml.GPO.LinksTo | ForEach-Object { $_.SOMPath })
$linkCount = $linkPaths.Count
$links = $linkPaths -join "; "
}
} catch {
$links = "ERROR READING LINKS"
}
# A GPO with zero settings on both sides is dead weight
$userVersion = $gpo.User.DSVersion
$computerVersion = $gpo.Computer.DSVersion
$isEmpty = ($userVersion -eq 0 -and $computerVersion -eq 0)
$notes = @()
if ($linkCount -eq 0) { $notes += "UNLINKED" }
if ($isEmpty) { $notes += "NO SETTINGS" }
if ($gpo.GpoStatus -eq "AllSettingsDisabled") { $notes += "ALL SETTINGS DISABLED" }
$entry = [PSCustomObject]@{
DisplayName = $gpo.DisplayName
Id = $gpo.Id.ToString()
GpoStatus = $gpo.GpoStatus
Owner = $gpo.Owner
CreationTime = $gpo.CreationTime.ToString("yyyy-MM-dd")
ModificationTime = $gpo.ModificationTime.ToString("yyyy-MM-dd HH:mm")
UserVersion = $userVersion
ComputerVersion = $computerVersion
LinkCount = $linkCount
LinkedTo = $links
Notes = if ($notes.Count -gt 0) { $notes -join "; " } else { "OK" }
}
$report += $entry
if ($linkCount -eq 0) { $unlinked += $entry }
}
Add-Content -Path $logFile -Value "$timestamp — INVENTORY — $($report.Count) GPOs found"
} catch {
Add-Content -Path $logFile -Value "$timestamp — INVENTORY FAILED — $_"
}
# ---------- COMPARE AGAINST BASELINE ----------
if (Test-Path $baselineFile) {
$baseline = Import-Csv -Path $baselineFile
foreach ($current in $report) {
$previous = $baseline | Where-Object { $_.Id -eq $current.Id }
if ($null -eq $previous) {
$newGPOs += $current
}
elseif ([int]$previous.UserVersion -ne [int]$current.UserVersion -or
[int]$previous.ComputerVersion -ne [int]$current.ComputerVersion) {
$changed += [PSCustomObject]@{
DisplayName = $current.DisplayName
Id = $current.Id
OldUser = $previous.UserVersion
NewUser = $current.UserVersion
OldComputer = $previous.ComputerVersion
NewComputer = $current.ComputerVersion
ModifiedOn = $current.ModificationTime
LinkedTo = $current.LinkedTo
}
}
elseif ($previous.LinkCount -ne $current.LinkCount) {
$changed += [PSCustomObject]@{
DisplayName = $current.DisplayName
Id = $current.Id
OldUser = "LINKS: $($previous.LinkCount)"
NewUser = "LINKS: $($current.LinkCount)"
OldComputer = $previous.LinkedTo
NewComputer = $current.LinkedTo
ModifiedOn = $current.ModificationTime
LinkedTo = $current.LinkedTo
}
}
}
# GPOs that existed yesterday and don't today
foreach ($previous in $baseline) {
if (-not ($report | Where-Object { $_.Id -eq $previous.Id })) {
$removedGPOs += $previous
}
}
} else {
Add-Content -Path $logFile -Value "$timestamp — No baseline found. Creating one now."
}
# Export current state and refresh the baseline
if ($report.Count -gt 0) {
$report | Sort-Object DisplayName | Export-Csv -Path $reportFile -NoTypeInformation
$report | Sort-Object DisplayName | Export-Csv -Path $baselineFile -NoTypeInformation
}
# ---------- BUILD EMAIL ----------
$emailBody = "Group Policy Backup & Change Report — $timestamp`n"
$emailBody += "Domain controller: $env:COMPUTERNAME`n"
$emailBody += "Backup status: $backupStatus`n"
$emailBody += "GPOs backed up: $backupCount`n"
$emailBody += "Backup location: $backupPath`n"
$emailBody += "Retention: $retentionDays days`n`n"
$emailBody += "Total GPOs in domain: $($report.Count)`n"
$emailBody += "Changed since last run: $($changed.Count)`n"
$emailBody += "New since last run: $($newGPOs.Count)`n"
$emailBody += "Removed since last run: $($removedGPOs.Count)`n"
$emailBody += "Unlinked GPOs: $($unlinked.Count)`n`n"
if ($changed.Count -gt 0) {
$emailBody += "CHANGED GPOs — REVIEW THESE:`n"
$emailBody += "=" * 60 + "`n"
foreach ($c in $changed) {
$emailBody += "GPO: $($c.DisplayName)`n"
$emailBody += "Modified: $($c.ModifiedOn)`n"
$emailBody += "User ver: $($c.OldUser) -> $($c.NewUser)`n"
$emailBody += "Computer ver: $($c.OldComputer) -> $($c.NewComputer)`n"
$emailBody += "Linked to: $($c.LinkedTo)`n"
$emailBody += "-" * 40 + "`n"
}
$emailBody += "`n"
}
if ($newGPOs.Count -gt 0) {
$emailBody += "NEW GPOs:`n"
$emailBody += "=" * 60 + "`n"
foreach ($n in $newGPOs) {
$emailBody += "GPO: $($n.DisplayName)`n"
$emailBody += "Owner: $($n.Owner)`n"
$emailBody += "Created: $($n.CreationTime)`n"
$emailBody += "Linked to: $($n.LinkedTo)`n"
$emailBody += "-" * 40 + "`n"
}
$emailBody += "`n"
}
if ($removedGPOs.Count -gt 0) {
$emailBody += "REMOVED GPOs — CONFIRM THIS WAS INTENTIONAL:`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in $removedGPOs) {
$emailBody += " !! $($r.DisplayName) (last seen linked to: $($r.LinkedTo))`n"
}
$emailBody += "`n"
}
if ($unlinked.Count -gt 0) {
$emailBody += "UNLINKED GPOs — APPLYING TO NOTHING:`n"
$emailBody += "=" * 60 + "`n"
foreach ($u in $unlinked) {
$emailBody += " $($u.DisplayName) — $($u.Notes)`n"
}
$emailBody += "`n"
}
if ($changed.Count -eq 0 -and $newGPOs.Count -eq 0 -and $removedGPOs.Count -eq 0) {
$emailBody += "No Group Policy changes detected since last run.`n`n"
}
$emailBody += "Full inventory saved to: $reportFile`n"
$emailBody += "`nTo restore a GPO from backup:`n"
$emailBody += "Restore-GPO -Name 'GPO NAME' -Path '$backupPath'`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"
$subject = if ($backupStatus -ne "SUCCESS") {
"GPO BACKUP FAILED on $env:COMPUTERNAME"
} elseif ($removedGPOs.Count -gt 0) {
"GPO ALERT: $($removedGPOs.Count) GPO(s) removed, $($changed.Count) changed"
} elseif ($changed.Count -gt 0 -or $newGPOs.Count -gt 0) {
"GPO Report — $($changed.Count) changed, $($newGPOs.Count) new"
} else {
"GPO Backup Complete — $backupCount GPOs, no changes detected"
}
Send-MailMessage `
-From $from -To $to `
-Subject $subject `
-Body $emailBody `
-SmtpServer $smtpServer -Port $smtpPort `
-UseSsl -Credential $credentialUpdate these lines:
$from/$to/$username/$password— your email details$smtpServer— your SMTP server (e.g.smtp.office365.com)$backupRoot— see the note below, don't leave this on C:\$retentionDays— 30 is a reasonable default
Requirements: Run on a domain controller, or a machine with RSAT and both the GroupPolicy and ActiveDirectory modules. The account needs GPO read and backup rights — Group Policy Creator Owners or equivalent delegation.
PUT THE BACKUPS SOMEWHERE ELSE
C:\GPOBackups is the default in the script so it runs out of the box, but leaving it there defeats half the purpose. If the DC you're backing up is the DC that dies, your backups died with it.
Point $backupRoot at a UNC path on a file server that's covered by your actual backup system:
powershell
$backupRoot = "\\FILESERVER01\Backups$\GPO"Make sure the account running the scheduled task has write access to that path, and lock the share down — GPO backups contain your full policy configuration including security settings, and in some cases GPP data. They're not something you want sitting on an open share.
HOW CHANGE DETECTION WORKS HERE
Every GPO carries two version numbers — one for the user half, one for the computer half. They increment every time the policy is edited. The script stores them in a baseline and compares on the next run.
This is more reliable than watching ModificationTime, which can shift for reasons that aren't real edits. A version bump means somebody genuinely changed a setting.
The script also tracks link count, so you'll catch the case where nobody edited a GPO but somebody linked it to a new OU — which is functionally a change to everything in that OU even though the policy itself is untouched.
First run creates the baseline and reports no changes. That's expected. From run two onward the change section is the part you read.
THE UNLINKED SECTION IS A BONUS
Unlinked GPOs apply to nothing. They're usually one of three things:
Somebody built a policy, tested it, and never linked it. Somebody unlinked a policy to troubleshoot an issue and forgot to relink it — worth checking whether that one is supposed to be live. Or it's genuine leftover from a project that ended three years ago.
That middle case is the one to care about. A security baseline that quietly stopped applying in March is exactly the kind of thing that surfaces during an audit rather than during normal operations.
SETTING UP TASK SCHEDULER
Press Windows key → type Task Scheduler → open it
Click "Create Basic Task" → name it
GPO Backup and AuditTrigger: Daily → 11:00pm
Action: Start a program
Program:
powershell.exeArguments:
-ExecutionPolicy Bypass -File "C:\Scripts\GPOBackupAudit.ps1"Click Next → Finish
Under Properties → General, check "Run with highest privileges" and set it to run under an account with GPO backup rights and write access to $backupRoot.
Daily is right for this one. GPO changes are infrequent but high impact, and a daily backup means your worst-case restore loses less than 24 hours of policy work.
TEST IT
Open PowerShell as administrator on a domain controller and run:
powershell
powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\GPOBackupAudit.ps1"Check that C:\GPOBackups\<today's date> contains a folder per GPO plus a manifest.xml. Open C:\Logs\GPOReport.csv to see the full inventory with links and notes.
To test change detection, run it once to build the baseline, then make a trivial edit to a non-production test GPO and run it again. The changed section should show the version bump.
PRACTISE THE RESTORE
A backup you've never restored from is a hope, not a backup. Do this once on a test GPO so you know the syntax works in your environment before you need it under pressure.
Restore a GPO to its existing object, overwriting current settings:
powershell
Restore-GPO -Name "Test Policy" -Path "C:\GPOBackups\2026-09-17"List what's inside a backup folder before restoring:
powershell
Get-GPO -All -Path "C:\GPOBackups\2026-09-17" -ErrorAction SilentlyContinue
Import-Clixml -Path "C:\GPOBackups\2026-09-17\manifest.xml" -ErrorAction SilentlyContinueIf the GPO was deleted entirely, create an empty one with the same name first, then restore into it, then relink it — restore brings back settings, not links. Your CSV inventory has the link paths, which is exactly why the script records them.
WHY THIS MATTERS
Group Policy is one of the few things in a Windows environment where a single mistake propagates to every machine automatically, on a timer, without any deployment step you could pause.
The change report gives you a short daily list of what moved, which turns "something broke this week and we don't know what changed" into a five-line email you already read on Tuesday. The backups give you a one-command rollback instead of a system state restore.
Neither of these is glamorous. Both of them matter enormously on the one day a year you need them.
THIS WEEK'S ACTION
Run it once today against your domain to create the first backup and baseline. Then pick one non-critical GPO and practise the restore command on it. Ten minutes now, and the next time somebody asks "can we get that policy back to how it was last Thursday" the answer is yes.
Reply to this email if you hit any issues — I read every reply.
Automate & Operate — automateandoperate.com
For product teams moving at AI speed.

AI makes it easier to ship anything, even bad ideas. The hard part is knowing which ideas are worth building.
Jira Product Discovery brings your ideas, customer insights, and priorities into one place, so your team can decide what to ship and move forward with confidence.
Capture ideas, prioritize with evidence, and build living roadmaps your team can rally around—all while staying connected to delivery in Jira, so everyone can see what’s being built and why.
Better product decisions in the AI era.
