Why You Should Care About AD Health Checks
The Domain Controller is one of the few servers where “works” and “works for everyone” are not the same thing. A DC can answer LDAP queries, return tickets, and pass Test-Connection while quietly failing replication, sitting on a stale SYSVOL, or holding a FSMO role its owner thinks lives somewhere else. Those are the kinds of failures that surface as “weird auth issues” from the help desk, never as a clean alert in your monitoring tool.
Health checks are how you catch them before users do. Specifically, you want a current health snapshot:
- Before Windows Updates on a DC — so you know which tests were already failing and which broke because of the patch.
- After a DC promotion, demotion, or migration to a new OS — replication, FSMO transfers, and SYSVOL convergence all need a clean confirmation.
- Before any planned change to the forest — raising the functional level, renaming a site, decommissioning an old DC.
- On a schedule — daily or weekly, e-mailed automatically — so an unhealthy DC does not silently linger.
- After any incident — a network outage, a power blip, a DC reboot — to confirm everything came back the way it left.
Microsoft ships dcdiag.exe for exactly this purpose, but its raw output is dense and hard to compare across servers. The Get-ADHealth.ps1 script in this article wraps dcdiag, Test-Connection, Resolve-DnsName, Get-Service, w32tm, and CIM in one pass and emits a single colored HTML report — one row per DC, color-coded pass / warn / fail per cell. It is a single file, no modules to install, and runs in 30–60 seconds for a typical small-to-medium forest.
What the Script Tests at a Glance
Per Domain Controller, in one sweep:
- Identity & topology: Server hostname, AD Site, OS version, IPv4 address, FSMO roles held.
- Network reachability: DNS A-record lookup (
Resolve-DnsName), ICMP ping, time offset vs. PDC (w32tm). - Operating system: Uptime in hours, OS-drive free space (% and GB).
- Critical services: DNS, NTDS, Netlogon — running or not.
- 22 dcdiag tests: Connectivity, Advertising, FrsEvent, DFSREvent, SysVolCheck, KccEvent, KnowsOfRoleHolders, MachineAccount, NCSecDesc, NetLogons, ObjectsReplicated, Replications, RidManager, Services, SystemLog, VerifyReferences, CheckSDRefDom, CrossRefValidation, LocatorCheck, Intersite, FSMOCheck.
- Performance: Per-DC processing time, so you can see at a glance which controller is slow to respond.
Color thresholds are deliberately conservative: uptime ≤ 24h is a warn (recently rebooted — could be patching, could be a crash), free space ≤ 30% is a warn / ≤ 5% is a fail, time offset ≥ 1 sec is a fail, every dcdiag fail is a fail. Adjust them in the script if your environment runs hotter than that.
Setup — Prepare the Domain Controller
- Sign in to a Domain Controller with an account that holds Domain Admin (or at least the right to query every other DC remotely). The script reaches across the network with
Test-Connection,Get-Service -ComputerName,Get-CimInstance -ComputerName, andDcdiag /s:. - Create the folder
C:\scriptsif it does not exist:New-Item -ItemType Directory -Path 'C:\scripts' -Force - Save the script as
C:\scripts\Get-ADHealth.ps1. - Unblock it — if you downloaded the file, Windows tags it with the Mark of the Web and the script will not run:
Unblock-File -Path 'C:\scripts\Get-ADHealth.ps1' - Set the execution policy for the current process if it is restricted (this only affects the current PowerShell session):
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
The script depends on the ActiveDirectory module (Get-ADForest, Get-ADDomainController). On a Domain Controller this module is installed by default. On any other Windows Server, install RSAT: Active Directory Domain Services and Lightweight Directory Tools first.
The Full Get-ADHealth.ps1 Script
Copy and paste the script verbatim into C:\scripts\Get-ADHealth.ps1:
[CmdletBinding()]
Param(
[Parameter( Mandatory = $false)]
[string]$DomainName,
[Parameter( Mandatory = $false)]
[switch]$ReportFile,
[Parameter( Mandatory = $false)]
[switch]$SendEmail
)
#...................................
# Global Variables
#...................................
$now = Get-Date
$date = $now.ToShortDateString()
$allTestedDomainControllers = [System.Collections.Generic.List[PSCustomObject]]::new()
$allDomainControllers = [System.Collections.Generic.List[PSCustomObject]]::new()
$reportime = Get-Date
$reportemailsubject = "Domain Controller Health Report"
$smtpsettings = @{
To = 'email@domain.com'
From = 'adhealth@yourdomain.com'
Subject = "$reportemailsubject - $date"
SmtpServer = "mail.domain.com"
Port = "25"
}
#...................................
# Functions
#...................................
# This function gets all the domains in the forest.
Function Get-AllDomains() {
Write-Verbose "Running function Get-AllDomains"
$allDomains = (Get-ADForest).Domains
return $allDomains
}
# This function gets all the domain controllers in a specified domain.
Function Get-AllDomainControllers ($ComputerName) {
Write-Verbose "Running function Get-AllDomainControllers"
$allDomainControllers = Get-ADDomainController -Filter * -Server $ComputerName | Sort-Object HostName
return $allDomainControllers
}
# This function tests the domain controller against DNS.
Function Get-DomainControllerNSLookup($ComputerName) {
Write-Verbose "Running function Get-DomainControllerNSLookup"
try {
$domainControllerNSLookupResult = Resolve-DnsName $ComputerName -Type A | Select-Object -ExpandProperty IPAddress
$domainControllerNSLookupResult = 'Success'
}
catch {
$domainControllerNSLookupResult = 'Fail'
}
return $domainControllerNSLookupResult
}
# This function tests the connectivity to the domain controller.
Function Get-DomainControllerPingStatus($ComputerName) {
Write-Verbose "Running function Get-DomainControllerPingStatus"
if ((Test-Connection $ComputerName -Count 1 -quiet) -eq $True) {
$domainControllerPingStatus = "Success"
}
else {
$domainControllerPingStatus = 'Fail'
}
return $domainControllerPingStatus
}
# This function tests the domain controller uptime.
Function Get-DomainControllerUpTime($ComputerName) {
Write-Verbose "Running function Get-DomainControllerUpTime"
if ((Test-Connection $ComputerName -Count 1 -Quiet) -eq $True) {
try {
$W32OS = Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction SilentlyContinue
$timespan = (Get-Date) - $W32OS.LastBootUpTime
[int]$uptime = "{0:00}" -f $timespan.TotalHours
}
catch [Exception] {
$uptime = 'CIM Failure'
}
}
else {
$uptime = 'Fail'
}
return $uptime
}
# This function checks the time synchronization offset.
function Get-TimeDifference($ComputerName) {
Write-Verbose "Running function Get-TimeDifference"
if ((Test-Connection $ComputerName -Count 1 -Quiet) -eq $True) {
try {
$currentTime, $timeDifference = (& w32tm /stripchart /computer:$ComputerName /samples:1 /dataonly)[-1].Trim("s") -split ',\s*'
$diff = [double]$timeDifference
$diffRounded = [Math]::Round($diff, 1, [MidPointRounding]::AwayFromZero)
}
catch [Exception] {
$diffRounded = 'CIM Failure'
}
}
else {
$diffRounded = 'Fail'
}
return $diffRounded
}
# This function checks the DNS, NTDS and Netlogon services.
Function Get-DomainControllerServices($ComputerName) {
Write-Verbose "Running function DomainControllerServices"
$thisDomainControllerServicesTestResult = [PSCustomObject]@{
DNSService = $null
NTDSService = $null
NETLOGONService = $null
}
if ((Test-Connection $ComputerName -Count 1 -quiet) -eq $True) {
if ((Get-Service -ComputerName $ComputerName -Name DNS -ErrorAction SilentlyContinue).Status -eq 'Running') {
$thisDomainControllerServicesTestResult.DNSService = 'Success'
}
else {
$thisDomainControllerServicesTestResult.DNSService = 'Fail'
}
if ((Get-Service -ComputerName $ComputerName -Name NTDS -ErrorAction SilentlyContinue).Status -eq 'Running') {
$thisDomainControllerServicesTestResult.NTDSService = 'Success'
}
else {
$thisDomainControllerServicesTestResult.NTDSService = 'Fail'
}
if ((Get-Service -ComputerName $ComputerName -Name netlogon -ErrorAction SilentlyContinue).Status -eq 'Running') {
$thisDomainControllerServicesTestResult.NETLOGONService = 'Success'
}
else {
$thisDomainControllerServicesTestResult.NETLOGONService = 'Fail'
}
}
else {
$thisDomainControllerServicesTestResult.DNSService = 'Fail'
$thisDomainControllerServicesTestResult.NTDSService = 'Fail'
$thisDomainControllerServicesTestResult.NETLOGONService = 'Fail'
}
return $thisDomainControllerServicesTestResult
}
# This function runs the DCDiag tests and saves them in a variable for later processing.
Function Get-DomainControllerDCDiagTestResults($ComputerName) {
Write-Verbose "Running function Get-DomainControllerDCDiagTestResults"
$DCDiagTestResults = [PSCustomObject]@{
ServerName = $ComputerName
Connectivity = $null
Advertising = $null
FrsEvent = $null
DFSREvent = $null
SysVolCheck = $null
KccEvent = $null
KnowsOfRoleHolders = $null
MachineAccount = $null
NCSecDesc = $null
NetLogons = $null
ObjectsReplicated = $null
Replications = $null
RidManager = $null
Services = $null
SystemLog = $null
VerifyReferences = $null
CheckSDRefDom = $null
CrossRefValidation = $null
LocatorCheck = $null
Intersite = $null
FSMOCheck = $null
}
if ((Test-Connection $ComputerName -Count 1 -quiet) -eq $True) {
$params = @(
"/s:$ComputerName",
"/test:Connectivity",
"/test:Advertising",
"/test:FrsEvent",
"/test:DFSREvent",
"/test:SysVolCheck",
"/test:KccEvent",
"/test:KnowsOfRoleHolders",
"/test:MachineAccount",
"/test:NCSecDesc",
"/test:NetLogons",
"/test:ObjectsReplicated",
"/test:Replications",
"/test:RidManager",
"/test:Services",
"/test:SystemLog",
"/test:VerifyReferences",
"/test:CheckSDRefDom",
"/test:CrossRefValidation",
"/test:LocatorCheck",
"/test:Intersite",
"/test:FSMOCheck"
)
$DCDiagTest = (Dcdiag.exe @params) -split ('[\r\n]')
$TestName = $null
$TestStatus = $null
$DCDiagTest | ForEach-Object {
switch -Regex ($_) {
"Starting test:" {
$TestName = ($_ -replace ".*Starting test:").Trim()
}
"passed test|failed test" {
$TestStatus = if ($_ -match "passed test") { "Passed" } else { "Failed" }
}
}
if ($TestName -and $TestStatus) {
$DCDiagTestResults.$TestName = $TestStatus
$TestName = $null
$TestStatus = $null
}
}
}
else {
foreach ($property in $DCDiagTestResults.PSObject.Properties.Name) {
if ($property -ne "ServerName") {
$DCDiagTestResults.$property = "Failed"
}
}
}
return $DCDiagTestResults
}
# This function checks the free space in percentage on the OS drive.
Function Get-DomainControllerOSDriveFreeSpace ($ComputerName) {
Write-Verbose "Running function Get-DomainControllerOSDriveFreeSpace"
if ((Test-Connection $ComputerName -Count 1 -Quiet) -eq $True) {
try {
$thisOSDriveLetter = (Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction SilentlyContinue).SystemDrive
$thisOSDiskDrive = Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $ComputerName -Filter "DeviceID='$thisOSDriveLetter'" -ErrorAction SilentlyContinue
$thisOSPercentFree = [math]::Round($thisOSDiskDrive.FreeSpace / $thisOSDiskDrive.Size * 100)
}
catch [Exception] {
$thisOSPercentFree = 'CIM Failure'
}
}
else {
$thisOSPercentFree = "Fail"
}
return $thisOSPercentFree
}
# This function checks the free disk space on the OS drive in GB.
Function Get-DomainControllerOSDriveFreeSpaceGB ($ComputerName) {
Write-Verbose "Running function Get-DomainControllerOSDriveFreeSpaceGB"
if ((Test-Connection $ComputerName -Count 1 -Quiet) -eq $True) {
try {
$thisOSDriveLetter = (Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction SilentlyContinue).SystemDrive
$thisOSDiskDrive = Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $ComputerName -Filter "DeviceID='$thisOSDriveLetter'" -ErrorAction SilentlyContinue
$freeSpaceGB = [math]::Round($thisOSDiskDrive.FreeSpace / 1GB, 2)
}
catch [Exception] {
$freeSpaceGB = 'CIM Failure'
}
}
else {
$freeSpaceGB = 'Fail'
}
return $freeSpaceGB
}
# This function generates HTML code from the results of the above functions.
Function New-ServerHealthHTMLTableCell() {
param( $lineitem )
$htmltablecell = $null
switch ($($reportline."$lineitem")) {
"Success" { $htmltablecell = "<td class=""pass"">$($reportline."$lineitem")</td>" }
"Passed" { $htmltablecell = "<td class=""pass"">$($reportline."$lineitem")</td>" }
"Pass" { $htmltablecell = "<td class=""pass"">$($reportline."$lineitem")</td>" }
"Warn" { $htmltablecell = "<td class=""warn"">$($reportline."$lineitem")</td>" }
"Fail" { $htmltablecell = "<td class=""fail"">$($reportline."$lineitem")</td>" }
"Failed" { $htmltablecell = "<td class=""fail"">$($reportline."$lineitem")</td>" }
"Could not test server uptime." { $htmltablecell = "<td class=""fail"">$($reportline."$lineitem")</td>" }
default { $htmltablecell = "<td>$($reportline."$lineitem")</td>" }
}
return $htmltablecell
}
if (!($DomainName)) {
Write-Host "No domain specified, using all domains in forest" -ForegroundColor Yellow
$allDomains = Get-AllDomains
$reportFileName = 'forest_health_report_' + (Get-ADForest).name + '_' + (Get-Date -Format "yyyyMMdd_HHmmss") + '.html'
}
else {
Write-Host "Domain name specified on cmdline" -ForegroundColor Cyan
$allDomains = $DomainName
$reportFileName = 'dc_health_report_' + $DomainName + '_' + (Get-Date -Format "yyyyMMdd_HHmmss") + '.html'
}
foreach ($domain in $allDomains) {
Write-Host "Testing domain" $domain -ForegroundColor Green
$allDomainControllers = Get-AllDomainControllers $domain
$totalDCs = $allDomainControllers.Count
foreach ($domainController in $allDomainControllers) {
$stopWatch = [system.diagnostics.stopwatch]::StartNew()
Write-Host "Testing domain controller" "($($allDomainControllers.IndexOf($domainController) + 1) of $totalDCs)" $domainController.HostName -ForegroundColor Cyan
$DCDiagTestResults = Get-DomainControllerDCDiagTestResults $domainController.HostName
$thisDomainController = [PSCustomObject]@{
Server = ($domainController.HostName).ToLower()
Site = $domainController.Site
"OS Version" = $domainController.OperatingSystem
"IPv4 Address" = $domainController.IPv4Address
"Operation Master Roles" = $domainController.OperationMasterRoles
"DNS" = Get-DomainControllerNSLookup $domainController.HostName
"Ping" = Get-DomainControllerPingStatus $domainController.HostName
"Uptime (hours)" = Get-DomainControllerUpTime $domainController.HostName
"OS Free Space (%)" = Get-DomainControllerOSDriveFreeSpace $domainController.HostName
"OS Free Space (GB)" = Get-DomainControllerOSDriveFreeSpaceGB $domainController.HostName
"Time offset (seconds)" = Get-TimeDifference $domainController.HostName
"DNS Service" = (Get-DomainControllerServices $domainController.HostName).DNSService
"NTDS Service" = (Get-DomainControllerServices $domainController.HostName).NTDSService
"NetLogon Service" = (Get-DomainControllerServices $domainController.HostName).NETLOGONService
"DCDIAG: Connectivity" = $DCDiagTestResults.Connectivity
"DCDIAG: Advertising" = $DCDiagTestResults.Advertising
"DCDIAG: FrsEvent" = $DCDiagTestResults.FrsEvent
"DCDIAG: DFSREvent" = $DCDiagTestResults.DFSREvent
"DCDIAG: SysVolCheck" = $DCDiagTestResults.SysVolCheck
"DCDIAG: KccEvent" = $DCDiagTestResults.KccEvent
"DCDIAG: FSMO KnowsOfRoleHolders" = $DCDiagTestResults.KnowsOfRoleHolders
"DCDIAG: MachineAccount" = $DCDiagTestResults.MachineAccount
"DCDIAG: NCSecDesc" = $DCDiagTestResults.NCSecDesc
"DCDIAG: NetLogons" = $DCDiagTestResults.NetLogons
"DCDIAG: ObjectsReplicated" = $DCDiagTestResults.ObjectsReplicated
"DCDIAG: Replications" = $DCDiagTestResults.Replications
"DCDIAG: RidManager" = $DCDiagTestResults.RidManager
"DCDIAG: Services" = $DCDiagTestResults.Services
"DCDIAG: SystemLog" = $DCDiagTestResults.SystemLog
"DCDIAG: VerifyReferences" = $DCDiagTestResults.VerifyReferences
"DCDIAG: CheckSDRefDom" = $DCDiagTestResults.CheckSDRefDom
"DCDIAG: CrossRefValidation" = $DCDiagTestResults.CrossRefValidation
"DCDIAG: LocatorCheck" = $DCDiagTestResults.LocatorCheck
"DCDIAG: Intersite" = $DCDiagTestResults.Intersite
"DCDIAG: FSMO Check" = $DCDiagTestResults.FSMOCheck
"Processing Time (seconds)" = $stopWatch.Elapsed.Seconds
}
$allTestedDomainControllers.Add($thisDomainController)
$totalDCtoProcessCounter --
}
}
$htmlhead = "<html>
<style>
BODY { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 10pt; }
H1 { font-size: 20px; }
H2 { font-size: 16px; }
H3 { font-size: 14px; }
TABLE { border: 1px solid #ccc; border-collapse: collapse; font-size: 10pt;}
TH { border: 1px solid #ccc; background: #f2f2f2; padding: 10px; color: #000000;}
TD { border: 1px solid #ccc; padding: 10px; }
td.pass { background: #6BBF59;}
td.warn { background: #FFD966;}
td.fail { background: #D9534F; color: #ffffff;}
td.info { background: #5BC0DE;}
</style>
<body>
<h1 align=""left"">Domain Controller Health Check Report</h1>
<h3 align=""left"">Generated: $reportime</h3>"
$htmltableheader = "<h3>Domain Controller Health Summary</h3>
<h3>Forest: $((Get-ADForest).Name)</h3>
<p>
<table style=""width: 100%; border-collapse: separate; "">
<tr>
<th>Server</th><th>Site</th><th>OS Version</th><th>IPv4 Address</th><th>Operation Master Roles</th>
<th>DNS</th><th>Ping</th><th>Uptime (hours)</th><th>OS Free Space (%)</th><th>OS Free Space (GB)</th>
<th>Time offset (seconds)</th><th>DNS Service</th><th>NTDS Service</th><th>NetLogon Service</th>
<th>DCDIAG: Connectivity</th><th>DCDIAG: Advertising</th><th>DCDIAG: FrsEvent</th><th>DCDIAG: DFSREvent</th>
<th>DCDIAG: SysVolCheck</th><th>DCDIAG: KccEvent</th><th>DCDIAG: FSMO KnowsOfRoleHolders</th>
<th>DCDIAG: MachineAccount</th><th>DCDIAG: NCSecDesc</th><th>DCDIAG: NetLogons</th>
<th>DCDIAG: ObjectsReplicated</th><th>DCDIAG: Replications</th><th>DCDIAG: RidManager</th>
<th>DCDIAG: Services</th><th>DCDIAG: SystemLog</th><th>DCDIAG: VerifyReferences</th>
<th>DCDIAG: CheckSDRefDom</th><th>DCDIAG: CrossRefValidation</th><th>DCDIAG: LocatorCheck</th>
<th>DCDIAG: Intersite</th><th>DCDIAG: FSMO Check</th><th>Processing Time (seconds)</th>
</tr>"
$serverhealthhtmltable = $serverhealthhtmltable + $htmltableheader
foreach ($reportline in $allTestedDomainControllers) {
if (Test-Path variable:fsmoRoleHTML) {
Remove-Variable fsmoRoleHTML
}
if (($reportline."Operation Master Roles").Count -gt 0) {
$fsmoRoleHTML = ($reportline."Operation Master Roles" | ForEach-Object { "$_`r`n" }) -join '<br>'
}
else {
$fsmoRoleHTML = 'None<br>'
}
$htmltablerow = "<tr>"
$htmltablerow += "<td>$($reportline.Server)</td>"
$htmltablerow += "<td>$($reportline.Site)</td>"
$htmltablerow += "<td>$($reportline."OS Version")</td>"
$htmltablerow += "<td>$($reportline."IPv4 Address")</td>"
$htmltablerow += "<td>$fsmoRoleHTML</td>"
$htmltablerow += (New-ServerHealthHTMLTableCell "DNS" )
$htmltablerow += (New-ServerHealthHTMLTableCell "Ping")
if ($($reportline."Uptime (hours)") -eq "CIM Failure") {
$htmltablerow += "<td class=""warn"">Could not test server uptime.</td>"
}
elseif ($($reportline."Uptime (hours)") -eq "Fail") {
$htmltablerow += "<td class=""fail"">Fail</td>"
}
else {
$hours = [int]$($reportline."Uptime (hours)")
if ($hours -le 24) {
$htmltablerow += "<td class=""warn"">$hours</td>"
}
else {
$htmltablerow += "<td class=""pass"">$hours</td>"
}
}
$osSpace = $reportline."OS Free Space (%)"
if ($osSpace -eq "CIM Failure") {
$htmltablerow += "<td class=""warn"">Could not test server free space.</td>"
}
elseif ($osSpace -eq "Fail") {
$htmltablerow += "<td class=""fail"">$osSpace</td>"
}
elseif ($osSpace -le 5) {
$htmltablerow += "<td class=""fail"">$osSpace</td>"
}
elseif ($osSpace -le 30) {
$htmltablerow += "<td class=""warn"">$osSpace</td>"
}
else {
$htmltablerow += "<td class=""pass"">$osSpace</td>"
}
$osSpaceGB = $reportline."OS Free Space (GB)"
if ($osSpaceGB -eq "CIM Failure") {
$htmltablerow += "<td class=""warn"">Could not test server free space.</td>"
}
elseif ($osSpaceGB -eq "Fail") {
$htmltablerow += "<td class=""fail"">$osSpaceGB</td>"
}
elseif ($osSpaceGB -lt 5) {
$htmltablerow += "<td class=""fail"">$osSpaceGB</td>"
}
elseif ($osSpaceGB -lt 10) {
$htmltablerow += "<td class=""warn"">$osSpaceGB</td>"
}
else {
$htmltablerow += "<td class=""pass"">$osSpaceGB</td>"
}
$time = $reportline."Time offset (seconds)"
if ($time -ge 1) {
$htmltablerow += "<td class=""fail"">$time</td>"
}
else {
$htmltablerow += "<td class=""pass"">$time</td>"
}
$htmltablerow += (New-ServerHealthHTMLTableCell "DNS Service")
$htmltablerow += (New-ServerHealthHTMLTableCell "NTDS Service")
$htmltablerow += (New-ServerHealthHTMLTableCell "NetLogon Service")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: Connectivity")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: Advertising")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: FrsEvent")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: DFSREvent")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: SysVolCheck")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: KccEvent")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: FSMO KnowsOfRoleHolders")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: MachineAccount")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: NCSecDesc")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: NetLogons")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: ObjectsReplicated")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: Replications")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: RidManager")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: Services")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: SystemLog")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: VerifyReferences")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: CheckSDRefDom")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: CrossRefValidation")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: LocatorCheck")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: Intersite")
$htmltablerow += (New-ServerHealthHTMLTableCell "DCDIAG: FSMO Check")
$processingTime = $reportline."Processing Time (seconds)"
$htmltablerow += "<td>$processingTime</td>"
[array]$serverhealthhtmltable += $htmltablerow
}
$serverhealthhtmltable += "</table></p>"
$htmltail = "* DNS test is performed using Resolve-DnsName. This cmdlet is only available from Windows 2012 onwards.
</body>
</html>"
$htmlreport = $htmlhead + $serverhealthhtmltable + $htmltail
if ($ReportFile) {
$htmlreport | Out-File $reportFileName -Encoding UTF8
}
if ($SendEmail) {
try {
Send-MailMessage @smtpsettings -Body $htmlreport -BodyAsHtml -Encoding ([System.Text.Encoding]::UTF8) -ErrorAction Stop
Write-Host "Email sent successfully." -ForegroundColor Green
}
catch {
Write-Host "Failed to send email. Error: $_" -ForegroundColor Red
}
}
Generate the Health Report
Open Windows PowerShell as administrator, change directory to the script folder, and run with -ReportFile:
cd C:\scripts
.\Get-ADHealth.ps1 -ReportFile
You will see a sequence of colored lines as each domain and DC is tested:
The script writes the HTML report to the same directory as the script. The default filename is forest_health_report_<forest-name>_<timestamp>.html (or dc_health_report_<domain>_<timestamp>.html if you used -DomainName):
Get-ChildItem C:\scripts -Filter '*.html' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
# C:\scripts\forest_health_report_infotechninja.local_20260507_141215.html
Open it in a browser and you have a full forest-level dashboard.
Reading the Report — All Green
A healthy forest looks like a wall of green — one row per DC, every dcdiag test cell green, services green, time offset zero or low milliseconds. Here is a condensed view of what the report shows on a healthy 2-DC forest:
Domain Controller Health Check Report
Forest: infotechninja.local — Generated: 2026-05-07 14:12:15
| Server | DNS | Ping | Uptime (h) | Free % | Time | NTDS | DCDIAG: Repl | DCDIAG: SysVol | DCDIAG: FSMO |
|---|---|---|---|---|---|---|---|---|---|
dc01-2025.infotechninja.local |
Success | Success | 12 | 62 | 0.1 | Success | Passed | Passed | Passed |
dc02-2022.infotechninja.local |
Success | Success | 48 | 69 | 0 | Success | Passed | Passed | Passed |
* DNS test is performed using Resolve-DnsName. Available from Windows Server 2012 onwards.
The single yellow cell on dc01-2025 is uptime = 12 hours, which the script flags as a warn (≤ 24h could indicate an unplanned reboot or a recent patch). Every other cell is green — both DCs are reachable, services are running, dcdiag is happy, replication is current, the FSMO holder is responding to its peer.
Reading the Report — A DC Goes Down
Now shut down dc01-2025 and re-run the script. The same report layout, but the row for dc01-2025 goes mostly red. DNS still resolves — the A-record is in the zone file and the surviving DC answers for it — but ping fails, services cannot be queried, dcdiag cannot connect to the server, so every test downstream of connectivity fails:
Domain Controller Health Check Report
Forest: infotechninja.local — Generated: 2026-05-07 14:17:37
| Server | DNS | Ping | Uptime (h) | Free % | Time | NTDS | DCDIAG: Repl | DCDIAG: SysVol | DCDIAG: FSMO |
|---|---|---|---|---|---|---|---|---|---|
dc01-2025.infotechninja.local |
Success | Fail | Fail | Fail | Fail | Fail | Failed | Failed | Failed |
dc02-2022.infotechninja.local |
Success | Success | 48 | 69 | 0 | Success | Passed | Passed | Passed |
This is the right behavior. The pattern green DNS, red everything else is your fingerprint for “the DC is offline but the zone still has its records” — exactly what you would expect when one DC has been shut down or is unreachable on the network. Compare this to red DNS, red everything else, which is a different problem (the DC’s record is missing from DNS or your client cannot reach the DNS server at all).
What Each dcdiag Test Means (the Quick Glossary)
Most teams treat “dcdiag passed” as a single binary — useful, but you lose the diagnostic value of which test fails when something breaks. The 22 tests this script runs cluster naturally:
- Connectivity / Advertising / NetLogons / MachineAccount. Can the DC be reached, is it advertising itself in DNS, are the secure channel and machine account healthy?
- FrsEvent / DFSREvent / SysVolCheck / NCSecDesc. SYSVOL replication health (FRS in legacy environments, DFSR in 2008+). Failures here mean Group Policy, login scripts, and any SYSVOL-distributed asset may be stale or missing.
- KccEvent / Replications / ObjectsReplicated / Intersite. The Knowledge Consistency Checker is responsible for building the inbound replication topology. These tests cover whether the topology builds, whether updates flow through it, and whether inter-site links are healthy.
- RidManager / KnowsOfRoleHolders / FSMOCheck. Are the FSMO roles assigned, can every DC see the role holders, and is the RID Master handing out RIDs?
- Services / SystemLog. Are the AD-related services running, and are there serious errors in the System event log on this DC?
- VerifyReferences / CheckSDRefDom / CrossRefValidation / LocatorCheck. Internal AD object integrity — cross-references between partitions, security descriptor reference domain, locator records.
Failed-test patterns tell you where to look. Connectivity failed: network or DC down. SysVolCheck failed: DFSR is broken or lagging — investigate Get-EventLog -LogName 'DFS Replication' -ComputerName .... Replications failed: repadmin /showrepl is your next stop. FSMOCheck failed: a role holder is offline or unreachable from this DC.
Schedule It and Email the Report
The -SendEmail parameter triggers an SMTP send through Send-MailMessage using the $smtpsettings hashtable at the top of the script. Edit the hashtable for your environment first:
$smtpsettings = @{
To = 'adteam@infotechninja.com'
From = 'adhealth@infotechninja.com'
Subject = "$reportemailsubject - $date"
SmtpServer = 'mail.infotechninja.com'
Port = '25'
}
Then schedule a daily run with Task Scheduler so the report lands in your inbox before the workday starts. The action and trigger are:
- Program/script:
powershell.exe - Arguments:
-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Get-ADHealth.ps1 -ReportFile -SendEmail - Run as: a service account with Domain Admin (the script reaches across the network to every DC).
- Trigger: daily at 06:00 (or weekly — weekly is fine for stable forests).
- Run whether user is logged on or not.
To create the scheduled task in PowerShell:
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Get-ADHealth.ps1 -ReportFile -SendEmail'
$trigger = New-ScheduledTaskTrigger -Daily -At 6am
Register-ScheduledTask `
-TaskName 'AD Health Check (Daily)' `
-Action $action `
-Trigger $trigger `
-RunLevel Highest `
-Description 'Daily Get-ADHealth.ps1 run, e-mails the HTML report.'
The HTML files accumulate in C:\scripts\. Keep a year of them and you have a free historical health archive — useful when an auditor asks “was that DC healthy on Tuesday?”
Common Pitfalls
- Script will not run — “file is not digitally signed.” The Mark of the Web is set on downloaded files. Run
Unblock-File -Path 'C:\scripts\Get-ADHealth.ps1'once. If yourExecutionPolicyis restricted at machine scope, use-ExecutionPolicy Bypasson the command line, or set Process-scope bypass for the current session only. - w32tm fails with “The computer did not resync because no time data was available.” The DC needs to have queried the time service recently for
w32tm /stripchartto return. Runw32tm /resync /forceon the target DC, or accept that the Time offset column will reportCIM Failurefor that DC. - Resolve-DnsName not found. The cmdlet ships with Windows Server 2012 and later. If you are running the script on a Windows 7 / Server 2008 R2 jump box, run it from a 2012+ DC instead.
- Get-Service or Get-CimInstance returns access denied. The account running the script needs administrative rights on the remote DC. Domain Admin is the simplest answer; granting Remote Management Users + Performance Log Users + WMI namespace permissions is the least-privilege route.
- FSMO holder column is empty for non-holders. That is correct — only the DC that holds a FSMO role lists it. The script joins the role list with
<br>, so a DC holding all five roles shows them as a stacked list inside the cell. - Script appears to hang on a downed DC. Each
Test-Connection -Count 1waits for the ICMP timeout. The script is not stuck, just waiting. If you have many offline DCs the run gets long; consider running with-DomainNameto scope to a single domain when triaging. - Report file is overwritten every run. It is not — the filename includes a timestamp (
yyyyMMdd_HHmmss), so each run produces a new file. Old files accumulate; clean them up periodically with a scheduledRemove-Item -Path 'C:\scripts\*.html' -Force -OlderThan (Get-Date).AddDays(-90).
Conclusion
One PowerShell file, one run, every Domain Controller checked across 35 individual cells of state. The artifact is an HTML file you can read, archive, attach to a change-management ticket, or e-mail to the on-call engineer. The script’s real value is not the dcdiag wrapping — you can run dcdiag yourself — it is the shape: one row per DC, color-coded so a glance is enough to know whether anything needs attention, and the same shape every day so the diff between yesterday and today tells the story.
Drop it in C:\scripts, schedule it with the SMTP settings filled in, and you have a daily proactive health check that costs nothing but five minutes of setup. If you also want object counts, schema version, and FSMO role distribution in the same one-shot, pair it with Get-ADInfo.ps1 for a full forest profile. And before any planned change, read the functional-level pre-flight and the FSMO-roles audit — together those three reports cover almost every “is the directory ready?” question.