Task 07: Configure Time Synchronization (NTP)
DOCUMENT CATEGORY: Runbook SCOPE: Time synchronization PURPOSE: Configure w32tm with the NTP server from
variables.ymlso all cluster nodes synchronize to the same authoritative time source — required for Kerberos, Active Directory, and Arc registration MASTER REFERENCE: Phase 03: OS Configuration
Status: Active Estimated Time: 5 minutes per node / 10 minutes orchestrated (all nodes)
Overview
All Azure Local nodes must synchronize time from the same NTP source before AD domain join and Arc
registration. Kerberos authentication fails when clock skew exceeds five minutes. This task
configures the Windows Time service (w32tm) on each node, restarts the service, forces an
immediate resync, and validates the resulting stratum.
What the scripts do:
- Hard-fail on any
REPLACEplaceholder remaining in#region CONFIGURATION - Configure w32tm with the NTP server from
active_directory.ntp_servers - Restart the Windows Time service
- Force an immediate resync (
w32tm /resync /force) - Verify the resulting sync status and stratum — warn if stratum 16 (unsynchronized)
Prerequisites
| Requirement | Description | Source |
|---|---|---|
| Task 06 complete | DNS client verified and functional | Task 06: Verify DNS |
| NTP server address | FQDN or IP of the authoritative NTP server | variables.yml: active_directory.ntp_servers |
| Local admin rights | Required to restart Windows Time service | Node credentials |
Configuration Reference
variables.yml path | Script variable | Example |
|---|---|---|
active_directory.ntp_servers[0] | $NTPServer | 10.100.10.1 |
cluster_nodes[].management_ip | PSRemoting target (orchestrated only) | 10.100.200.11 |
cluster_nodes[].hostname | Node display name (orchestrated only) | azlocal-node01 |
active_directory.ntp_servers is a YAML list. The direct script uses the primary (first) entry.
The orchestrated script joins all entries as a space-separated manualpeerlist.
Execution Options
- Direct Script (On Node)
- Orchestrated Script (Mgmt Server)
Run on each node individually — via RDP, console, or KVM.
Toolkit script: scripts/deploy/04-cluster-deployment/phase-03-os-configuration/task-07-configure-time-synchronization-ntp/powershell/Set-NTPConfiguration.ps1
Set $NTPServer to the first entry from active_directory.ntp_servers in variables.yml.
# -- Edit before running -------------------------------------------------
$NTPServer = "REPLACE_WITH_NTP_SERVER" # active_directory.ntp_servers[0]
# ------------------------------------------------------------------------
if ($NTPServer -match "^REPLACE_") { Write-Host "[ERROR] Set NTPServer before running" -ForegroundColor Red; exit 1 }
w32tm /config /manualpeerlist:$NTPServer /syncfromflags:manual /reliable:YES /update
Restart-Service w32time -Force
w32tm /resync /force
w32tm /query /status
Run from the management server to configure all nodes in a single pass.
Toolkit script: scripts/deploy/04-cluster-deployment/phase-03-os-configuration/task-07-configure-time-synchronization-ntp/powershell/Invoke-ConfigureNTP-Orchestrated.ps1
Reads active_directory.ntp_servers and all cluster_nodes entries from
variables.yml. Connects to each node via PSRemoting and configures w32tm.
Produces a per-node summary table with stratum and sync source.
<#
.SYNOPSIS
Invoke-ConfigureNTP-Orchestrated.ps1
Configures NTP on all Azure Local nodes via PSRemoting.
.DESCRIPTION
Runs from the management server. Reads NTP server list and node IPs from
variables.yml, connects to each node over PSRemoting, and configures
the Windows Time service.
variables.yml paths used:
active_directory.ntp_servers - List of NTP servers (joined for manualpeerlist)
cluster_nodes[].management_ip - PSRemoting connection target per node
cluster_nodes[].hostname - Node hostname (display only)
.NOTES
Author: Azure Local Cloud Azure Local Cloud
Version: 1.0.0
Phase: 03-os-configuration
Task: task-07-configure-time-synchronization-ntp
Execution: Run from management server (PSRemoting outbound to nodes)
Prerequisites: WinRM enabled on all nodes, admin credentials
Run after: Task 06 - DNS verified
.EXAMPLE
.\Invoke-ConfigureNTP-Orchestrated.ps1
.\Invoke-ConfigureNTP-Orchestrated.ps1 -ConfigPath "C:\config\variables.yml"
#>
[CmdletBinding()]
param(
[string]$ConfigPath = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
#region HELPERS
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
switch ($Level) {
"SUCCESS" { Write-Host "[$ts] [PASS] $Message" -ForegroundColor Green }
"ERROR" { Write-Host "[$ts] [FAIL] $Message" -ForegroundColor Red }
"WARN" { Write-Host "[$ts] [WARN] $Message" -ForegroundColor Yellow }
"HEADER" { Write-Host "[$ts] [----] $Message" -ForegroundColor Cyan }
default { Write-Host "[$ts] [INFO] $Message" }
}
}
function Resolve-ConfigPath {
param([string]$Provided)
if ($Provided -ne "" -and (Test-Path $Provided)) { return $Provided }
$candidates = @(
"$env:USERPROFILE\variables.yml",
"C:\config\variables.yml",
"$PSScriptRoot\..\..\..\..\..\config\variables.yml"
)
foreach ($c in $candidates) {
if (Test-Path $c) { return (Resolve-Path $c).Path }
}
throw "variables.yml not found. Pass -ConfigPath or place it in a standard location."
}
function Get-YamlList {
param([string]$FilePath, [string[]]$KeyPath)
$lines = Get-Content -Path $FilePath -Encoding UTF8
$inParent = $false
$inKey = $false
$items = @()
$parentDepth = 0
foreach ($line in $lines) {
if ($line -match '^\s*#' -or $line.Trim() -eq '') { continue }
$indent = $line.Length - $line.TrimStart().Length
if (-not $inParent) {
if ($line -match '^\s*([\w][\w_-]*):\s*$' -and $Matches[1] -eq $KeyPath[0]) {
$inParent = $true; $parentDepth = $indent; continue
}
} elseif (-not $inKey) {
if ($indent -le $parentDepth -and $line -notmatch '^\s+-') { $inParent = $false; continue }
if ($line -match '^\s+([\w][\w_-]*):\s*$' -and $Matches[1] -eq $KeyPath[1]) {
$inKey = $true; continue
}
} else {
if ($indent -le $parentDepth) { break }
if ($line -match '^\s+-\s+"?([^"]+)"?\s*$') { $items += $Matches[1].Trim() }
elseif ($line -match '^\s+[\w]' -and $line -notmatch '^\s+-') { break }
}
}
return $items
}
function Get-ClusterNodes {
param([string]$FilePath)
$lines = Get-Content -Path $FilePath -Encoding UTF8
$inNodes = $false
$nodes = @()
$current = $null
foreach ($line in $lines) {
if ($line -match '^\s*#' -or $line.Trim() -eq '') { continue }
if ($line -match '^cluster_nodes:') { $inNodes = $true; continue }
if ($inNodes) {
if ($line -match '^[^\s-]') { break }
if ($line -match '^\s+-\s+hostname:\s+"?([^"]+)"?') {
if ($current -and $current.hostname -ne "") { $nodes += $current }
$current = @{ hostname = $Matches[1].Trim(); management_ip = "" }
} elseif ($line -match '^\s+hostname:\s+"?([^"]+)"?' -and $current) {
$current.hostname = $Matches[1].Trim()
} elseif ($line -match '^\s+management_ip:\s+"?([^"]+)"?' -and $current) {
$current.management_ip = $Matches[1].Trim()
}
}
}
if ($current -and $current.hostname -ne "") { $nodes += $current }
return $nodes
}
#endregion HELPERS
#region MAIN
Write-Log "=== Task 07 - Configure Time Synchronization (NTP) ===" "HEADER"
$configFile = Resolve-ConfigPath -Provided $ConfigPath
Write-Log "Config: $configFile"
$ntpList = Get-YamlList -FilePath $configFile -KeyPath @("active_directory", "ntp_servers")
$nodes = Get-ClusterNodes -FilePath $configFile
if ($ntpList.Count -eq 0) { throw "active_directory.ntp_servers not found or empty in variables.yml" }
if ($nodes.Count -eq 0) { throw "cluster_nodes not found or empty in variables.yml" }
$ntpPeerList = $ntpList -join " "
Write-Log "NTP servers : $ntpPeerList"
Write-Log "Nodes : $($nodes.Count) found"
$cred = Get-Credential -Message "Enter credentials for PSRemoting to Azure Local nodes"
$results = @()
foreach ($node in $nodes) {
$ip = $node.management_ip
$hostname = $node.hostname
if (-not $ip) {
Write-Log "[$hostname] management_ip missing -- skipping" "WARN"
continue
}
Write-Log "[$hostname] Connecting to $ip..."
try {
$r = Invoke-Command -ComputerName $ip -Credential $cred -ArgumentList $ntpPeerList -ScriptBlock {
param($peerList)
w32tm /config /manualpeerlist:$peerList /syncfromflags:manual /reliable:YES /update | Out-Null
Restart-Service w32time -Force
Start-Sleep -Seconds 3
w32tm /resync /force | Out-Null
$status = w32tm /query /status
$stratum = ($status | Select-String "Stratum:") -replace ".*Stratum:\s*(\d+).*", '$1'
$source = ($status | Select-String "Source:") -replace ".*Source:\s*(.+)", '$1'
[PSCustomObject]@{
Hostname = $env:COMPUTERNAME
Stratum = $stratum.Trim()
Source = $source.Trim()
Status = if ([int]$stratum -lt 16) { "PASS" } else { "WARN" }
}
}
$color = if ($r.Status -eq "PASS") { "SUCCESS" } else { "WARN" }
Write-Log "[$hostname] $($r.Status) Stratum: $($r.Stratum) Source: $($r.Source)" $color
$results += [PSCustomObject]@{
Node = $hostname
IP = $ip
Stratum = $r.Stratum
Source = $r.Source
Status = $r.Status
}
} catch {
Write-Log "[$hostname] PSRemoting failed: $_" "ERROR"
$results += [PSCustomObject]@{
Node = $hostname
IP = $ip
Stratum = ""
Source = ""
Status = "ERROR"
}
}
}
Write-Log ""
Write-Log "=== NTP Configuration Summary ===" "HEADER"
$results | Format-Table Node, IP, Stratum, Source, Status -AutoSize
$failCount = ($results | Where-Object { $_.Status -eq "ERROR" }).Count
if ($failCount -eq 0) {
Write-Log "All $($results.Count) node(s) NTP configured successfully." "SUCCESS"
} else {
Write-Log "$failCount node(s) failed. Review output above." "ERROR"
exit 1
}
#endregion MAIN
Validation Checklist
- NTP server configured on all nodes (
w32tm /query /configshows correct peer) - Windows Time service running (
Get-Service w32time | Select Status) - Stratum below 16 on all nodes (
w32tm /query /status) - Clock skew within 5 minutes on all nodes (Kerberos requirement)
Troubleshooting
| Issue | Root Cause | Remediation |
|---|---|---|
| Script hard-fails on startup | REPLACE placeholder value remains | Edit #region CONFIGURATION with the NTP server from variables.yml |
| Stratum 16 after resync | NTP server unreachable or DNS not resolving | Verify NTP server IP; confirm Task 06 (DNS) passed; check firewall rules |
w32tm /resync exits non-zero | Time service not yet fully started | Wait 10 seconds and re-run w32tm /resync /force manually |
Orchestrated: ntp_servers not found | Key missing or wrong path in variables.yml | Confirm active_directory.ntp_servers: list exists in yml |
| Orchestrated: node unreachable | WinRM not enabled or firewall blocking | Verify WinRM configured (Task 01); check firewall allows port 5985/5986 |
| Time keeps drifting back | VM host overriding guest time | Disable Hyper-V Time Synchronization integration service if applicable |
Navigation
| ← Task 06: Verify DNS | ↑ Phase 03: OS Configuration | Task 08: Enable ICMP → |
Version Control
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-31 | Azure Local Cloud Azure Local Cloudnology | Initial document |
| 2.0 | 2026-03-04 | Azure Local Cloud Azure Local Cloudnology | Full rewrite to standards — complete frontmatter, bare-node 2-tab structure (Direct, Orchestrated), Standalone tab removed, YAML key corrected to active_directory.ntp_servers list, full embedded scripts with Assert-ConfigValues hard-fail, Get-YamlList and Get-ClusterNodes helpers |