Chrony: the heart of the NTP/NTS system#
Why#
Everything covered so far in this guide, the wired PPS, the real-time
kernel, IRQs pinned to dedicated cores, exists only to feed one
program: chronyd. It’s the one receiving PPS pulses and GNSS frames,
filtering out measurement noise, disciplining the system clock, and
serving time to clients over NTP and NTS. Everything else in this setup is
plumbing in service of this daemon.
chrony (as opposed to the historical ntpd) was chosen here for several
concrete reasons: faster convergence under imperfect conditions (reboot,
intermittent connectivity), native NTS support (NTP protocol encryption,
RFC 8915), a notably smaller and more auditable codebase (~30,000 lines vs
~120,000 for ntpd), and seccomp sandboxing enabled by default on recent
distributions.
How#
The PPS source: the core of the core#
refclock PPS /dev/pps0 lock GPS poll 2 filter 16 precision 1e-7 \
rate 1 width 0.5 maxlockage 10 preferBreaking down each option:
| Option | Meaning |
|---|---|
/dev/pps0 | The device exposed by the pps-gpio kernel module |
lock GPS | Only use the PPS if the GPS source (see below) confirms a valid fix, avoids disciplining the clock off a pulse with no time reference |
poll 2 | Interval between samples: 2² = 4 seconds |
filter 16 | Statistical median over the last 16 samples, this filter is what turns several microseconds of raw jitter into an effective RMS offset around a microsecond |
precision 1e-7 | Precision of this refclock, in decimal seconds of the GPS receiver, here rounded to 100 ns |
maxlockage 10 | Tolerates up to 10 missing GPS confirmations before “unlocking” |
prefer | This source is favored by the selection algorithm when available |
refclock SHM 0 refid GPS offset 0.125 noselect poll 4This second line receives GNSS frames (position, date) via gpsd, through
shared memory (channel 0). It exists only to resolve the PPS’s second
ambiguity (lock GPS above), the noselect directive explicitly excludes
it from ever acting as a time source in its own right: clock precision
relies solely on the PPS. offset 0.125 compensates for the average delay
between the actual GNSS fix instant and the arrival of the matching NMEA
sentence on the serial port (a text sentence takes a non-zero time to be
transmitted and parsed), a value determined empirically, with no impact on
final precision since this source is never selected for timestamping
itself.
Fallback sources#
server ntp-p1.obspm.fr minpoll 6 iburst xleave
server ptbtime4.ptb.de minpoll 6 iburst xleave
server ntp1.npl.co.uk minpoll 6 iburst xleave
server ntp1.sp.se minpoll 6 iburst xleave
server ntp2.oma.be minpoll 6 iburst xleave
server time.nist.gov minpoll 6 iburst xleaveEven a GNSS Stratum 1 keeps classic upstream sources, for two reasons:
detecting a silent PPS drift (a GPS that keeps emitting regular pulses but
slightly offset, for instance due to an antenna issue), and providing a
safety net in case of total GNSS failure. xleave (interleaved mode, RFC
5905) reduces bias from network asymmetry; minpoll 6 sets the minimum
polling interval to 2⁶ = 64 seconds, the etiquette to respect for any
institutional source (Performance chapter), no more than one request per
minute. The choice of the six lines above favors national metrology
laboratories over an anonymous pool:
ntp-p1.obspm.fr(France, LNE-SYRTE / Paris Observatory) provides an institutional anchor directly tied to the French realization of legal time.ptbtime4.ptb.de(Germany, Physikalisch-Technische Bundesanstalt).ntp1.npl.co.uk(United Kingdom, National Physical Laboratory).ntp1.sp.se(Sweden, RISE / SP).ntp2.oma.be(Belgium, Royal Observatory of Belgium).time.nist.gov(United States, National Institute of Standards and Technology): the only non-European reference on this list, it diversifies the geographic dependency zone.
Each of these six servers is a well-known institutional Stratum 1, whose offset from UTC is published monthly in the BIPM’s Circular T (the Performance chapter lists the full set of national metrology laboratories exposing a public NTP service, well beyond these six).
How many sources? For a machine that gets its time from the network (an ordinary client, or this server as a GNSS fallback), a minimum of 3 servers is recommended: with 2, a disagreement cannot be settled; from 3 upward, the majority isolates an outlier source, and 4 keep that majority even while one source is down (selection algorithm, Overview chapter). At the other end, 5 to 6 sources are a reasonable maximum: beyond that, the statistical gain becomes negligible while the load imposed on public servers grows for nothing. The example above totals 6 sources, five European laboratories and one North American, the top of the range.
local stratum 14 orphan distance 0.999This “orphan” mode only activates if no upstream source is selectable (GNSS failure and network down): the server keeps serving time (at stratum 14, clearly degraded) instead of stopping entirely.
Serving clients: allow and ratelimit#
Everything above configures what this server queries upstream. It still needs to be allowed to answer its own clients, without which this whole guide would produce nothing but a perfectly disciplined, mute clock.
allow
ratelimit interval 2 burst 8
ntsratelimit interval 3 burst 8Without
allow, chrony refuses every client request by default. This is the most deceptive trap in this chapter: a server perfectly configured on the upstream side (PPS, GNSS, network fallback) but missing anallowdirective will discipline its own clock with exemplary precision, and answer no external client whatsoever. Nothing in the logs flags this as an error: incoming requests are just silently ignored.allow(with no argument) permits all clients; it can also be restricted to a range (allow 203.0.113.0/24).
ratelimit limits NTP request throughput at the protocol level
itself, interval 2 corresponds to a minimum interval of 2² = 4
seconds between tolerated requests from the same IP, burst 8 allows an
initial burst of 8 requests before the limit kicks in (compatible with
client-side iburst). ntsratelimit does the same specifically for
NTS-KE negotiations, with its own values: interval 3 raises the minimum
interval to 2³ = 8 seconds rather than 4, a full NTS-KE handshake (TLS 1.3
negotiation) costs noticeably more to compute than a plain NTP packet, so
the limit is logically more conservative.
This limit is a third layer of protection, distinct from the two others already seen in this guide: network-level rate-limiting via nftables (nftables chapter, blind to the protocol, acts before the packet even reaches chrony) and Kiss-o’-Death (Anatomy of the NTP packet chapter, a cooperative protocol-level retaliation).
ratelimitsits between the two: chrony itself decides, at the application level, whether to drop an excess request, and it’s precisely this mechanism that triggers sending aRATEKiss code back to the offending client.
NTS: encrypting the protocol#
ntsport 4460
ntsservercert /etc/chrony/fullchain.pem
ntsserverkey /etc/chrony/privkey.pemNTS (Network Time Security, RFC 8915) adds a cryptographic authentication layer to the historically plaintext NTP protocol. The client first establishes a TLS session on TCP port 4460 (NTS-KE, Key Establishment) to obtain single-use keys, then exchanges normal NTP packets signed with those keys. In practice, this reuses the same TLS certificate as the website (Let’s Encrypt), nothing extra to manage on the certificate side.
The older alternative: pre-shared symmetric keys#
NTS isn’t the only way to authenticate NTP, nor even the first. chrony can also authenticate a specific server via a pre-shared symmetric key (a mechanism native to NTP itself, described in RFC 5905), an old mechanism, but still supported and actively used today, including alongside NTS.
# /etc/chrony/chrony.keys, demo key, test use only
# Key 42 generated on 2026-02-26 on time.example.org
42 SHA256 HEX:A4E57E0554C9860BD5E2C1384768FBA42E305E3617C83085FB655E480879A29F# /etc/chrony/chrony.conf
keyfile /etc/chrony/chrony.keys
server time.example.org key 42 iburst xleave minpoll 6
pool pool.ntp.org iburstkeyfile tells chrony where to find the key file above, without this
directive, the key 42 reference on a server line would resolve to
nothing and chrony would refuse to start the authenticated association.
Every packet exchanged with that specific server is then signed with an
HMAC using the pre-shared key 42, both client and server must know this
key in advance. minpoll 6 sets the minimum polling interval to 2⁶ = 64
seconds, a conventional value for a remote NTP server over this kind of
trusted link, plenty once initial convergence is reached and lighter on
requests than a shorter default minpoll.
Don’t confuse three distinct mechanisms:
- Symmetric key (above, RFC 5905): simple, still used today, but key distribution is manual: it has to be shared out-of-band with every authorized client, which doesn’t scale to an anonymous public server.
- Autokey (RFC 5906, designed by D. Mills): an attempt to automate this key distribution via public-key cryptography. Cryptographic weaknesses identified by academic research (vulnerability to denial-of-service and spoofing attacks) led the community to abandon it: it’s Autokey specifically that disappeared, not symmetric key authentication.
- NTS (RFC 8915): the modern answer to the same automation problem, building on TLS 1.3 rather than Autokey’s own scheme.
On this project, the symmetric key remains useful for a trusted point-to-point link where both parties can exchange a key out-of-band (testing, an internal link); NTS remains the right answer for a public server answering anonymous clients that have no way to obtain a key in advance.
Confining chronyd to real-time scheduling on Core 1#
chronyd continuously computes and applies the system clock’s
frequency correction from the PPS: a late preemption would directly
degrade the precision of this disciplining loop. A systemd override
places it under real-time FIFO scheduling, just below the PPS IRQ’s
priority (99), on the same core as phc2sys.
mkdir -p /etc/systemd/system/chrony.service.d/
cat > /etc/systemd/system/chrony.service.d/override.conf <<'EOF'
[Unit]
After=network-online.target
Wants=network-online.target
[Service]
# Real-time FIFO scheduling, priority 98 (just below the PPS IRQ at 99)
CPUSchedulingPolicy=fifo
CPUSchedulingPriority=98
CPUAffinity=1
# Write access to the NTS tmpfs
ReadWritePaths=/var/nts/chrony
# Delay to let the PPS driver and PHC clock come up
ExecStartPre=/bin/sleep 5
Restart=on-failure
RestartSec=10s
EOF
systemctl daemon-reload
systemctl restart chronyNever add
After=gpsd.serviceto this drop-in. It would create a startup dependency cycle betweenchronyandgpsd: both services would then wait on each other indefinitely.
Dynamic temperature compensation: tempcomp#
A quartz oscillator drifts with its temperature: the Pi 5 only carries a
bare, uncompensated crystal, and the slopes measured on this server range
from 0.06 to 0.2 ppm/°C depending on the zone (From bare crystal to
pseudo-TCXO chapter), on top of the passive stabilization already
described (Thermal stabilization chapter). chrony provides the tempcomp
directive to actively compensate for this drift, from a temperature probe
read periodically:
tempcomp /sys/class/thermal/thermal_zone0/temp 20 /var/lib/chrony/chrony.tempcomp/sys/class/thermal/thermal_zone0/temp is the same SoC probe already
driving the fan curve (Firmware chapter): no probe dedicated to the quartz
exists without a hardware modification. The point table (rather than a
simple quadratic formula) allows it to fit a real curve that isn’t a clean
parabola.
A script (tempcomp-refit, run by a daily systemd timer) rebuilds this
table from the observed history (tempcomp.log + tracking.log), rather
than freezing a single calibration. To do this, it splits the temperature
range into 0.5 °C slices (a bin, in the statistical sense: a range
that groups every sample falling within it) and computes, for each bin
with enough samples, the average compensation observed at that
temperature:
systemctl enable --now tempcomp-refit.timerThree points are worth knowing for anyone reproducing this mechanism:
- chrony only rereads the points file at startup. No hot reload, not
even via
SIGHUP(verified in chrony’s source code). A new table has no effect until chrony is restarted. The script never restarts chrony itself: it writes the table and logs that it’s ready, restarting remains a manual step chosen by the operator. - chrony extrapolates with no ceiling beyond the table. A temperature never observed (heatwave, fan failure) would extend the slope of the last measured segment, potentially noisy. The script therefore always adds a flat margin on both ends of the observed range, so the compensation stays pinned to the last reliable value instead of diverging.
- a measurement taken during a transient regime isn’t reliable. A real forced-cooling test on this server showed that the frequency measured right after a sharp thermal shock can far exceed what steady-state behavior would predict, before gradually returning to normal. Hence the minimum data-volume thresholds before any automatic deployment.
- a bin already deployed is rejected if it drifts too far. A bin can keep refining from one run to the next, but if the new value differs too much from the one currently in place, the update is rejected and logged rather than written. Goal: track the quartz’s slow natural aging, while still blocking a sharp jump caused by a sample contaminated by a transient.
Pitfalls to avoid#
Theory isn’t always confirmed by measurement: only measurement settles it. A seemingly solid a priori argument suggested that with a 1 Hz PPS,
poll 2(sampling every 4 s) only fills 4 out of 16 slots in the median filter (filter 16), and thatpoll 4(16 s) would actually fill it, hence, in theory, better noise rejection. Tested under real conditions on this project:poll 2measurably gave a better RMS offset thanpoll 4. The lesson: on a system with this many coupled factors (IRQ load, filter behavior, thermal drift), a theoretical hypothesis needs to be validated with a 24 h before/after measurement before being adopted permanently. Some of these factors aren’t simple parameters that could be replayed identically: they are, in part, intrinsically non-deterministic. Kernel interrupt jitter is a concrete example: it varies stochastically from one PPS sample to the next (processor cache state, memory bus contention at the exact moment of the interrupt), which alters the correlation structure of the samples thatfilter 16averages, something a purely theoretical argument cannot anticipate.
- Trailing comments on
serverdirectives. Some chrony versions don’t accept a#comment after aserverdirective on the same line, the parser may behave unexpectedly. Put explanatory comments above the line instead. chronycfailing silently under a hardened systemd service. Ifchronydruns inside a restrictive systemd sandbox (recommended, see the hardening chapter), the defaultchronyccommand (which uses a local UDP socket) can fail without a clear message.- Confusing
noselectwith “disabled”.refclock SHM 0 ... noselectstays active and feeds data intolock GPS,noselectonly means “do not use directly as a time source”, not “disabled”. - Restarting
chronydto reload an NTS certificate. Asystemctl restartbreaks the ongoing discipline loop (the PLL loses its state). To reload a renewed certificate, sending aSIGHUPto the process is enough and preserves synchronization.
Verify#
$ chronyc tracking
Reference ID : 50505330 (PPS0)
Stratum : 1
RMS offset : 0.000000071 seconds
Skew : 0.007 ppm
Leap status : NormalReference ID=PPS0confirms the PPS is actually driving the clock, not a fallback network source.RMS offset: the standard deviation of the measured error, around a microsecond here, which is the project’s target.Skew: the local oscillator’s frequency drift, in ppm (parts per million). A stable, low value (< 0.05 ppm) indicates a well-disciplined, thermally stable system.
# Effective RT confinement: class FF (FIFO), priority 98, core 1
ps -eLo psr,cls,rtprio,comm | grep chronyd
# Expected: 1 FF 98 chronyd$ chronyc sources -v
.-- Source mode '^' = server, '=' = peer, '#' = local clock.
/ .- Source state '*' = current best, '+' = combined, '-' = not combined,
| / 'x' = may be in error, '~' = too variable, '?' = unusable.
|| .- xxxx [ yyyy ] +/- zzzz
|| Reachability register (octal) -. | xxxx = adjusted offset,
|| Log2(Polling interval) --. | | yyyy = measured offset,
|| \ | | zzzz = estimated error.
|| | | \
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
#* PPS0 0 2 377 3 +18ns[ +16ns] +/- 802ns
#? GPS 0 4 377 13 -1082us[-1082us] +/- 277us
^- ntp-p1.obspm.fr 1 9 377 244 -278us[ -250us] +/- 1289us
^- ptbtime4.ptb.de 1 9 377 301 -462us[ -418us] +/- 10ms
^- ntpsvr1.npl.co.uk 1 8 377 170 -552us[ -536us] +/- 5236us
^- 193.11.166.8 1 10 377 185 -1208us[-1190us] +/- 19ms
^- 129.6.15.25 1 8 377 39 -330us[ -330us] +/- 44ms
^- heppen.be 1 10 377 29m +116us[ +462us] +/- 8549usReal output from this server (the -v option adds the legend):
#*: currently selected reference source (#denotes a local reference clock, not a network server). Here the PPS, at ±802 ns of estimated uncertainty.#?: currently not selectable (expected forGPS, which only serves a disambiguation role; its millisecond-scale offset is harmless).^-: network source, validated but not selected (expected as long as the PPS is answering correctly). The six upstream sources of this server are themselves institutional Stratum 1 servers (Paris Observatory, the German PTB, the British NPL, the Swedish RISE, the American NIST, plus a Belgian community server), consistent with the 5-6 source range of the Fallback sources section.Reach 377(octal): the last 8 polls all answered;LastRx 29mshows that a source spaced atPoll 10(2¹⁰ ≈ 17 min) can display an old last sample without that being a fault.
$ chronyc -N authdatamakes it possible to verify that a client successfully authenticated via NTS.
📚 Going further
On-wire protocol (offset computation). For each exchange, four timestamps are collected (T1 client request departure, T2 server reception, T3 server response departure, T4 client reception):
offset = ((T2 - T1) + (T3 - T4)) / 2 delay = (T4 - T1) - (T3 - T2)Implicit assumption: network symmetry in both directions. Any asymmetry introduces a bias equal to half the asymmetry (D. Mills, Computer Network Time Synchronization, 2006, ch. 3).
Source selection. When multiple servers are queried, the algorithm derives from Marzullo’s algorithm (K. Marzullo, PhD thesis, Stanford, 1984), adapted by Mills for NTP: each source provides a confidence interval
[offset - delay, offset + delay], and the algorithm looks for the largest consistent intersection across intervals to exclude outlier sources (falsetickers). This is why multiple upstream sources are kept even on a Stratum 1: detecting at least one falseticker requires a minimum of 4 sources.Normative references:
- RFC 5905, Network Time Protocol Version 4: Protocol and Algorithms Specification, D. Mills et al., 2010.
- RFC 8915, Network Time Security for the Network Time Protocol, D. Franke, D. Sibold et al., 2020.