A time daemon is useful only when its operating state supports the application’s time requirements. A running process can have no usable source, and a selected source can still be wrong or insufficiently measured. Treat time service health as an observable chain: source configuration, successful observations, selection, uncertainty, and permitted clock control. A green process indicator covers only the beginning.
Read what the daemon actually reports
Chrony’s tracking report distinguishes the reference, stratum, last update, remaining system-time correction, estimated frequency, frequency skew, root delay, and root dispersion. These fields answer different questions. Offset concerns time difference; frequency concerns clock rate; skew expresses uncertainty in that frequency estimate. Root delay and dispersion contribute to the uncertainty inherited from the reference path. One small offset does not summarize them all.
The sources report identifies candidates and the selected source. In the usual display, ^* denotes the selected NTP server. Reach is an octal history register, with 377 representing successful responses in the last eight polling opportunities; it is not a percentage or a promise of future availability. LastRx helps reveal how old a received sample is. Read source state alongside tracking rather than interpreting a process uptime counter as synchronization evidence.
A daemon can continue estimating time after an upstream interruption, but uncertainty can grow. Monitor update age and error estimates against the application’s tolerances, and distinguish temporary holdover from indefinitely trustworthy time. Several server names are not necessarily independent references if they share infrastructure. Source selection reduces some failure risks without proving every upstream claim correct.
When it breaks
Containers commonly share the host’s kernel clock. Running a time daemon inside one does not give it an independent civil clock to adjust. Chronyd’s -x option deliberately disables system-clock control while retaining estimation and server operation. A client can therefore report synchronization while the system clock remains uncorrected. Verify the active mode and the layer responsible for host time.
The local directive is another source of misleading confidence. It lets chronyd appear synchronized without an external reference, which can support intentional isolated-network arrangements. Enabling it solely to silence a synchronization alarm hides the missing reference. Check the reference identity and the intended source policy before interpreting Normal as evidence of UTC accuracy.
Operational checks also need meaningful limits. Chronyc waitsync can wait for synchronization and optionally bound remaining correction and frequency skew. Zero limits disable those additional checks. A successful invocation with both limits disabled is weaker evidence than a gate enforcing application-specific tolerances. It still cannot authenticate the correctness of an arbitrary upstream clock.
Check it quickly
Use a fresh disposable Debian 12 Docker container with Python 3 and chrony installed. Prepare packages with apt-get update and apt-get install -y python3 chrony, save this as chrony-status.py, and run python3 chrony-status.py as root. Keep host networking disabled and drop SYS_TIME capability. The recorded run used chrony 4.3 and Python 3.11.2.
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import socket
import subprocess
import time
assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container'
with TemporaryDirectory(prefix='chrony-status-') as directory:
root = Path(directory)
processes = []
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
def start(name, extra):
config = root / (name + '.conf')
config.write_text(f'pidfile {root}/{name}.pid\ncmdport 0\nbindcmdaddress {root}/{name}.sock\n' + extra)
process = subprocess.Popen(['chronyd', '-x', '-n', '-u', 'root', '-f', str(config)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
processes.append(process)
deadline = time.monotonic() + 5
while not (root/(name+'.sock')).exists():
assert process.poll() is None, 'chronyd exited before command socket was ready'
if time.monotonic() > deadline:
raise AssertionError('command socket not ready')
time.sleep(0.05)
return process
def command(name, *args):
return subprocess.run(['chronyc', '-n', '-h', str(root/(name+'.sock')), *args],
capture_output=True, text=True, timeout=10)
def tracking(name):
result = command(name, 'tracking')
assert result.returncode == 0, result.stderr
return {line.split(':', 1)[0].strip(): line.split(':', 1)[1].strip()
for line in result.stdout.splitlines() if ':' in line}
try:
client = start('client', f'port 0\nserver 127.0.0.1 port {port} iburst minpoll 0 maxpoll 0\n')
before = tracking('client')
before_gate = command('client', 'waitsync', '1', '0', '0', '0.1')
assert client.poll() is None and before['Leap status'] == 'Not synchronised'
assert before_gate.returncode != 0
start('source', f'port {port}\nbindaddress 127.0.0.1\nlocal stratum 8\nallow 127.0.0.1\n')
assert command('client', 'burst', '4/4').returncode == 0
deadline = time.monotonic() + 25
while time.monotonic() < deadline:
sources = command('client', 'sources').stdout
after = tracking('client')
if '^* 127.0.0.1' in sources and after['Leap status'] == 'Normal':
break
time.sleep(0.2)
else:
raise AssertionError('local source was not selected')
assert after['Stratum'] == '9'
after_gate = command('client', 'waitsync', '1', '0', '0', '0.1')
assert after_gate.returncode == 0
source_tracking = tracking('source')
assert source_tracking['Reference ID'].startswith('7F7F0101')
print(json.dumps({'daemon_alive_while_unsynchronised': True,
'before': before, 'after': after, 'selected_sources': sources,
'waitsync_returncodes': [before_gate.returncode, after_gate.returncode],
'source_reference': source_tracking['Reference ID'],
'system_clock_adjustment_disabled': True,
'external_UTC_accuracy_tested': False}))
finally:
for process in reversed(processes):
process.terminate()
process.communicate(timeout=5)
The script first starts a client pointing to an unused loopback port. Its process stays alive, but tracking reports Not synchronised and a one-attempt waitsync gate fails. It then starts a separate local stratum-eight source, requests a burst, and waits for actual selection. The client reports ^* 127.0.0.1, stratum nine, and Normal; the same gate now returns success. Both daemons run with clock adjustment disabled and are terminated afterward.
In the recorded initial selection, frequency skew remained very large even though the synchronization gate passed. The source reference was 7F7F0101, identifying local-reference operation rather than external synchronization. The fixture prints the full observations so these limitations remain visible. All assertions passed, but no external UTC accuracy, long-term convergence, loss-of-source recovery, or actual clock correction was tested. Use this counterexample to separate process availability, synchronization status, quality bounds, and authoritative time in production monitoring.
Further reading
Sources
- chronyc 4.3 manual, tracking fields, source selection and waitsync thresholds, checked 2026-09-13.
- chronyd 4.3 manual, clock adjustment and the -x mode, checked 2026-09-13.
- chrony.conf 4.3 manual, local-reference mode and source configuration, checked 2026-09-13.
- RFC 5905: Network Time Protocol Version 4, synchronization state and uncertainty, checked 2026-09-13.