Part of our Active Directory and Group Policy guides: every related guide in one place.
An Active Directory health check with PowerShell queries every domain controller for reachability, core services, dcdiag results, replication, FSMO role holders, SYSVOL state and time source, then flags anything unhealthy. The free script below does all of that read-only from an admin workstation with RSAT and produces a console and HTML summary.
What a healthy AD looks like
In a healthy domain:
- Every DC answers on LDAP (TCP 389).
- NTDS, DNS, Netlogon, KDC and W32Time are running.
- Key dcdiag tests pass.
repadmin /replsummaryshows 0 fails and no partner has gone hours without a successful sync.- All five FSMO roles sit on reachable DCs.
- SYSVOL replicates with DFSR in state 4 (Normal) on every DC.
- The PDC Emulator uses external NTP and other DCs sync from the domain hierarchy.
The script
Save this as Invoke-ADHealthCheck.ps1 on a domain-joined admin machine with the RSAT AD DS tools installed (the ActiveDirectory module plus dcdiag.exe and repadmin.exe). It runs on Windows PowerShell 5.1 and is strictly read-only: it only queries AD and the DCs, and writes nothing but the local HTML report. New to PowerShell? Start with the PowerShell guides.
#Requires -Version 5.1
<#
Read-only Active Directory health check for every DC in the current domain.
READ-ONLY: it only queries AD and the DCs and makes no changes to anything.
The only file it writes is the HTML report on the machine it runs from.
Needs RSAT AD DS tools (ActiveDirectory module, dcdiag, repadmin).
Run it on a domain controller, or on a member server/PC that can reach a DC
on TCP 9389 (Active Directory Web Services).
dcdiag parsing assumes English output.
#>
[CmdletBinding()]
param(
[string]$ReportFolder = 'C:\Reports\ADHealth',
[int]$MaxReplAgeHours = 24,
# Optional: a specific DC to query, e.g. -Server dc01.corp.local
[string]$Server
)
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
Write-Host 'The ActiveDirectory PowerShell module is not installed on this machine.' -ForegroundColor Red
Write-Host 'Windows Server: Install-WindowsFeature RSAT-AD-PowerShell, RSAT-ADDS-Tools'
Write-Host 'Windows 10/11: Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'
exit 2
}
Import-Module ActiveDirectory -ErrorAction Stop
$adArgs = @{ ErrorAction = 'Stop' }
if ($Server) { $adArgs.Server = $Server }
# Stop early, with a clear reason, if Active Directory cannot be queried at all.
# Carrying on would only produce misleading results for every later check.
try {
$domain = Get-ADDomain @adArgs
$forest = Get-ADForest @adArgs
$dcs = @(Get-ADDomainController -Filter * @adArgs | Sort-Object HostName)
}
catch {
Write-Host "Could not query Active Directory: $($_.Exception.Message)" -ForegroundColor Red
Write-Host ''
Write-Host 'The AD cmdlets talk to Active Directory Web Services (ADWS) on a domain controller, TCP port 9389.'
Write-Host 'Checks to run:'
Write-Host ' nltest /dsgetdc:<your domain> (can this machine find a DC?)'
Write-Host ' Test-NetConnection <dc name> -Port 9389 (is ADWS reachable?)'
Write-Host ' Get-Service ADWS -ComputerName <dc name> (is ADWS running on the DC?)'
Write-Host 'Or run this script on a domain controller, or pass -Server <dc name>.'
exit 2
}
$results = New-Object System.Collections.Generic.List[object]
function Add-Result ([string]$Check, [string]$Target, [string]$Status, [string]$Detail) {
$results.Add([pscustomobject]@{ Check = $Check; Target = $Target; Status = $Status; Detail = $Detail })
}
$reachable = @()
if ($dcs.Count -eq 0) {
Write-Host 'No domain controllers were returned by Get-ADDomainController.' -ForegroundColor Red
exit 2
}
# 1. Reachability: ping plus a TCP test on LDAP 389, because ICMP is often blocked
foreach ($dc in $dcs) {
$name = $dc.HostName
$ping = Test-Connection -ComputerName $name -Count 2 -Quiet
$ldap = $false
$tcp = New-Object System.Net.Sockets.TcpClient
try { $ldap = $tcp.ConnectAsync($name, 389).Wait(3000) }
catch { $ldap = $false }
finally { $tcp.Close() }
if ($ldap) {
$reachable += $name
Add-Result 'Reachability' $name $(if ($ping) { 'PASS' } else { 'WARN' }) "Ping: $ping, LDAP: $ldap"
}
else {
Add-Result 'Reachability' $name 'FAIL' "Ping: $ping, LDAP: $ldap. Other checks skipped."
}
}
# 2. Core DC services (Get-Service -ComputerName does not need PowerShell remoting)
$services = 'NTDS', 'DNS', 'Netlogon', 'Kdc', 'W32Time'
foreach ($name in $reachable) {
foreach ($svc in $services) {
try {
$s = Get-Service -ComputerName $name -Name $svc -ErrorAction Stop
if ($s.Status -eq 'Running') { Add-Result "Service $svc" $name 'PASS' 'Running' }
else { Add-Result "Service $svc" $name 'FAIL' "Status: $($s.Status)" }
}
catch {
# DNS is optional on a DC, so a missing DNS service is a warning, not a failure
$st = if ($svc -eq 'DNS') { 'WARN' } else { 'FAIL' }
Add-Result "Service $svc" $name $st $_.Exception.Message
}
}
}
# 3. Key dcdiag tests, one test per run so each result is clear
$dcdiagTests = 'Connectivity', 'Advertising', 'NetLogons', 'Services', 'Replications',
'SysVolCheck', 'KnowsOfRoleHolders', 'MachineAccount'
foreach ($name in $reachable) {
$connFailed = $false
foreach ($test in $dcdiagTests) {
$out = (& dcdiag.exe "/s:$name" "/test:$test" 2>&1) -join "`n"
if ($out -match "failed test\s+$test") {
if ($test -eq 'Connectivity') { $connFailed = $true }
$why = ($out -split "`n" | Where-Object { $_ -match 'error|fail|could not|unable|denied|not responding' -and $_ -notmatch 'failed test' } |
Select-Object -First 4 | ForEach-Object { $_.Trim() }) -join ' | '
if (-not $why) { $why = "failed test $test" }
Add-Result "dcdiag $test" $name 'FAIL' $why
}
elseif ($out -match "passed test\s+$test") { Add-Result "dcdiag $test" $name 'PASS' 'Passed' }
elseif ($connFailed -or $out -match 'Skipping all tests') {
# dcdiag skips every other test when it cannot connect to the DC, so these are not results
Add-Result "dcdiag $test" $name 'WARN' 'Skipped by dcdiag because the Connectivity test failed. Fix that first.'
}
else { Add-Result "dcdiag $test" $name 'WARN' "Result not found. Run: dcdiag /s:$name /test:$test" }
}
}
# 4a. repadmin /replsummary: flag any line with a non-zero fails count
$replSummary = (& repadmin.exe /replsummary 2>&1) -join "`r`n"
$badLines = $replSummary -split "`r`n" | Where-Object { $_ -match '\s[1-9]\d*\s*/\s*\d+' }
if ($replSummary -notmatch 'Source DSA|Destination DSA') {
# No summary table at all means repadmin could not run the check, which is not a pass
$first = (($replSummary -split "`r`n" | Where-Object { $_.Trim() } | Select-Object -First 2) -join ' ').Trim()
Add-Result 'repadmin /replsummary' $domain.DNSRoot 'WARN' "No summary returned. $first"
}
elseif ($dcs.Count -eq 1) {
Add-Result 'repadmin /replsummary' $domain.DNSRoot 'PASS' 'Single DC domain: no replication partners to check'
}
elseif ($badLines) {
Add-Result 'repadmin /replsummary' $domain.DNSRoot 'FAIL' (($badLines | ForEach-Object { $_.Trim() }) -join ' | ')
}
else { Add-Result 'repadmin /replsummary' $domain.DNSRoot 'PASS' 'No failing replication links' }
# 4b. Replication failures recorded by each DC
try {
$failures = @(Get-ADReplicationFailure -Target $domain.DNSRoot -Scope Domain -ErrorAction Stop |
Where-Object { $_.FailureCount -gt 0 })
if ($failures.Count -eq 0) { Add-Result 'Replication failures' $domain.DNSRoot 'PASS' 'None recorded' }
foreach ($f in $failures) {
Add-Result 'Replication failures' $f.Server 'FAIL' ("Partner: {0}, failures: {1}, first: {2}, last error: {3}" -f
$f.Partner, $f.FailureCount, $f.FirstFailureTime, $f.LastError)
}
}
catch { Add-Result 'Replication failures' $domain.DNSRoot 'WARN' "Query failed: $($_.Exception.Message)" }
# 4c. Inbound partner metadata for all partitions: last result and age of last success
try {
$cutoff = (Get-Date).AddHours(-$MaxReplAgeHours)
$meta = @(Get-ADReplicationPartnerMetadata -Target $domain.DNSRoot -Scope Domain -Partition * -ErrorAction Stop)
$problem = 0
foreach ($m in $meta) {
# Partner is the DN of the NTDS Settings object: take the server CN
$partner = (($m.Partner -split ',')[1]) -replace '^CN='
if ($m.LastReplicationResult -ne 0) {
$problem++
Add-Result 'Replication partner' $m.Server 'FAIL' ("From {0}, {1}: result {2}, {3} consecutive failures" -f
$partner, $m.Partition, $m.LastReplicationResult, $m.ConsecutiveReplicationFailures)
}
elseif ($m.LastReplicationSuccess -lt $cutoff) {
$problem++
Add-Result 'Replication partner' $m.Server 'WARN' ("From {0}, {1}: last success {2}" -f
$partner, $m.Partition, $m.LastReplicationSuccess)
}
}
if ($meta.Count -eq 0 -and $dcs.Count -eq 1) {
Add-Result 'Replication partner' $domain.DNSRoot 'PASS' 'Single DC domain: no inbound replication partners'
}
elseif ($meta.Count -eq 0) {
Add-Result 'Replication partner' $domain.DNSRoot 'WARN' "No inbound replication links returned, but the domain has $($dcs.Count) DCs"
}
elseif ($problem -eq 0) {
Add-Result 'Replication partner' $domain.DNSRoot 'PASS' "$($meta.Count) inbound links healthy within $MaxReplAgeHours hours"
}
}
catch { Add-Result 'Replication partner' $domain.DNSRoot 'WARN' "Query failed: $($_.Exception.Message)" }
# 5. FSMO role holders: each role should sit on a live, reachable DC
$fsmo = [ordered]@{
'Schema Master' = $forest.SchemaMaster
'Domain Naming Master' = $forest.DomainNamingMaster
'PDC Emulator' = $domain.PDCEmulator
'RID Master' = $domain.RIDMaster
'Infrastructure Master' = $domain.InfrastructureMaster
}
foreach ($role in $fsmo.Keys) {
$holder = $fsmo[$role]
if ($reachable -contains $holder) { Add-Result "FSMO $role" $holder 'PASS' 'Reachable' }
elseif ($dcs.HostName -contains $holder) { Add-Result "FSMO $role" $holder 'FAIL' 'Holder not reachable' }
else { Add-Result "FSMO $role" $holder 'WARN' 'Holder is in another domain' }
}
# 6. SYSVOL replication state from the DFSR WMI provider (uses WinRM via Get-CimInstance)
foreach ($name in $reachable) {
try {
$rf = Get-CimInstance -ComputerName $name -Namespace 'root\MicrosoftDFS' -ClassName 'DfsrReplicatedFolderInfo' `
-Filter "ReplicatedFolderName = 'SYSVOL Share'" -ErrorAction Stop
if (-not $rf) {
Add-Result 'SYSVOL DFSR' $name 'WARN' 'No DFSR SYSVOL folder found. Check dfsrmig /getglobalstate.'
}
else {
switch ([int]$rf.State) {
4 { Add-Result 'SYSVOL DFSR' $name 'PASS' 'State 4: Normal' }
2 { Add-Result 'SYSVOL DFSR' $name 'WARN' 'State 2: Initial sync' }
3 { Add-Result 'SYSVOL DFSR' $name 'WARN' 'State 3: Auto recovery' }
default { Add-Result 'SYSVOL DFSR' $name 'FAIL' "State $($rf.State) (0 Uninitialised, 1 Initialised, 5 In error)" }
}
}
}
catch { Add-Result 'SYSVOL DFSR' $name 'WARN' "WMI query failed: $($_.Exception.Message)" }
}
# 7. Time source: the PDC Emulator should use external NTP, other DCs the domain hierarchy
foreach ($name in $reachable) {
$src = ((& w32tm.exe /query "/computer:$name" /source 2>&1) -join ' ').Trim()
if ($src -match 'Local CMOS Clock|Free-running System Clock|error') {
Add-Result 'Time source' $name 'FAIL' $src
}
elseif ($src -match 'VM IC Time Synchronization Provider') {
Add-Result 'Time source' $name 'WARN' "$src (syncing from the hypervisor, not the domain)"
}
elseif ($name -eq $domain.PDCEmulator) {
Add-Result 'Time source' $name 'PASS' "$src (PDC Emulator: confirm this is a reliable NTP source)"
}
else { Add-Result 'Time source' $name 'PASS' $src }
}
# 8. Summary: console table plus a colour-coded HTML report
$order = @{ FAIL = 0; WARN = 1; PASS = 2 }
$sorted = $results | Sort-Object @{ Expression = { $order[$_.Status] } }, Check, Target
$fails = @($results | Where-Object { $_.Status -eq 'FAIL' }).Count
$warns = @($results | Where-Object { $_.Status -eq 'WARN' }).Count
$sorted | Format-Table -AutoSize -Wrap
Write-Host "FAIL: $fails WARN: $warns Total checks: $($results.Count)" -ForegroundColor Yellow
if (-not (Test-Path $ReportFolder)) { New-Item -Path $ReportFolder -ItemType Directory | Out-Null }
$reportPath = Join-Path $ReportFolder ('ADHealth_{0}.html' -f (Get-Date -Format 'yyyyMMdd_HHmm'))
$head = '<title>AD health check</title><style>body{font-family:Segoe UI,Arial;font-size:13px}' +
'table{border-collapse:collapse}th,td{border:1px solid #ccc;padding:4px 8px;text-align:left}' +
'.PASS{background:#e6f4ea}.WARN{background:#fff4ce}.FAIL{background:#fde7e9}</style>'
$table = $sorted | ConvertTo-Html -Fragment -Property Check, Target, Status, Detail
$table = $table -replace '<tr><td>([^<]*)</td><td>([^<]*)</td><td>(PASS|WARN|FAIL)</td>',
'<tr class="$3"><td>$1</td><td>$2</td><td>$3</td>'
$body = "<h1>AD health check: $($domain.DNSRoot)</h1>" +
"<p>Generated $(Get-Date -Format 'dd/MM/yyyy HH:mm') on $env:COMPUTERNAME. " +
"FAIL: $fails, WARN: $warns. Read-only check: no changes were made.</p>" +
($table -join "`n") +
"<h2>repadmin /replsummary</h2><pre>$([System.Net.WebUtility]::HtmlEncode($replSummary))</pre>"
"<!DOCTYPE html><html><head>$head</head><body>$body</body></html>" | Out-File -FilePath $reportPath -Encoding UTF8
Write-Host "Report saved to $reportPath"
# Exit code 1 if anything failed, for scheduled task alerting
if ($fails -gt 0) { exit 1 } else { exit 0 }
To email the report, see PowerShell reports to CSV, HTML and email.
What each check means and how to fix a failure
Reachability
The script pings each DC and opens a TCP connection to LDAP on port 389. A WARN (LDAP works, ping fails) is usually a firewall blocking ICMP. A FAIL means the DC is down or blocked. Check the server, confirm DNS resolves it to the right IP and test with Test-NetConnection -ComputerName DC01 -Port 389.
Core services
NTDS is the directory, Netlogon handles DC locator and secure channels, KDC issues Kerberos tickets, W32Time keeps clocks aligned and DNS serves the AD zones. For a stopped service, check the System and Directory Service logs for the cause before restarting it. A missing DNS service is only a WARN, as not every DC runs DNS.
dcdiag tests
Each test runs separately with dcdiag /s:DC01 /test:Name:
- Connectivity: DNS registration and LDAP/RPC reachability. Usually a DNS record problem.
- Advertising: the DC advertises its roles. Often follows a SYSVOL that is not ready.
- NetLogons and SysVolCheck: SYSVOL and NETLOGON shares are present and ready.
- Services: AD dependent services and start types.
- Replications: connection objects for all naming contexts.
- KnowsOfRoleHolders: the DC can see all FSMO holders.
- MachineAccount: the DC computer account is registered correctly.
Rerun a failing test with /v for verbose output. On a non-English OS, adjust the passed test and failed test strings.
Replication
repadmin /replsummary gives the quick view of fails per link. Get-ADReplicationFailure returns each recorded failure with partner, count and last error. Get-ADReplicationPartnerMetadata -Partition * exposes links that are silently stale through LastReplicationResult and LastReplicationSuccess.
Start with the error code: 1722 is RPC or firewall, 8453 is permissions, 8524 is usually DNS, and 8614 means a DC exceeded the tombstone lifetime. Once fixed, repadmin /syncall forces replication (a change, so not in the script). Stale DNS records are a common cause, and DNS scavenging configured properly helps prevent them.
FSMO role holders
Forest roles come from Get-ADForest and domain roles from Get-ADDomain. A FAIL means a role sits on an unreachable DC. If it is gone for good, follow the guide to transferring FSMO roles, and only seize roles when the old holder will never return. A WARN just means the role lives in another domain.
SYSVOL and DFSR
The script reads the DfsrReplicatedFolderInfo WMI class on each DC. State 4 is Normal, and states 2 and 3 should clear on their own. State 5 (in error) needs attention: check the DFS Replication event log for events 2213 and 4012 and consider a non-authoritative sync. If no SYSVOL folder is found, run dfsrmig /getglobalstate on a DC: anything other than Eliminated means SYSVOL still uses FRS. Group Policy issues often trace back here, so see the Active Directory and Group Policy guides.
Time source
w32tm /query /computer:DC01 /source shows each time source. Kerberos allows five minutes of skew by default. Local CMOS Clock or Free-running System Clock is a FAIL. VM IC Time Synchronization Provider is a WARN: a virtual DC is syncing from its host. The PDC Emulator should use external NTP and other DCs should show another DC.
Run it on a schedule
A group managed service account (gMSA) avoids password management. It needs Log on as a batch job on the admin machine, permission to retrieve its password, and rights to query services, WMI and replication on the DCs, so treat it as a tier 0 account.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Invoke-ADHealthCheck.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
$principal = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-adhealth$' -LogonType Password
Register-ScheduledTask -TaskName 'AD Health Check' -Action $action -Trigger $trigger -Principal $principal
Exit code 1 means something failed, so monitoring can alert on the last run result.
When you need more than a script
This script answers whether AD is working, not whether it is secure. For privileged account, Kerberos and delegation risks plus a client-ready report, the AD Health and Security Audit report adds security findings on top of the health checks.
FAQ
Is this script safe to run on production?
Yes. Every check is a query (Get cmdlets, dcdiag, repadmin /replsummary, w32tm /query and a WMI read). The only write is the local HTML report.
Does it need to run on a domain controller?
No. Run it from a domain-joined admin server with RSAT. The DFSR check uses Get-CimInstance, which needs WinRM on the DCs (enabled by default on Windows Server).
How often should an AD health check run?
Daily, plus before and after changes such as adding a DC, patching or moving FSMO roles.
Why use both repadmin and the PowerShell replication cmdlets?
repadmin gives a fast, familiar overview. The cmdlets return objects with error codes and timestamps that are easy to filter, and catch links that have quietly stopped replicating.
Why does it say the ActiveDirectory module is missing?
The machine does not have the RSAT Active Directory tools. On Windows Server run Install-WindowsFeature RSAT-AD-PowerShell, RSAT-ADDS-Tools. On Windows 10 or 11 run Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0. Then open a new PowerShell window.
What does “Server instance not found on the given port” mean?
The AD cmdlets could not reach Active Directory Web Services (ADWS) on TCP 9389. The script stops at this point rather than reporting misleading results. Check the machine can find a DC with nltest /dsgetdc:yourdomain, test the port with Test-NetConnection dcname -Port 9389, and make sure the ADWS service is running. It also happens on a DC that has lost its network connection.
Why are most dcdiag tests marked as skipped?
When the dcdiag Connectivity test fails, dcdiag skips every other test for that DC. Fix the Connectivity failure first (usually DNS registration or the DC’s DNS client settings), then run the script again.
Discover more from TechyGeeksHome
Subscribe to get the latest posts sent to your email.