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 prefer

Breaking down each option:

OptionMeaning
/dev/pps0The device exposed by the pps-gpio kernel module
lock GPSOnly 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 2Interval between samples: 2² = 4 seconds
filter 16Statistical 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-7Precision of this refclock, in decimal seconds of the GPS receiver, here rounded to 100 ns
maxlockage 10Tolerates up to 10 missing GPS confirmations before “unlocking”
preferThis source is favored by the selection algorithm when available
refclock SHM 0 refid GPS offset 0.125 noselect poll 4

This 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 xleave

Even 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.999

This “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 8

Without 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 an allow directive 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). ratelimit sits 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 a RATE Kiss code back to the offending client.

NTS: encrypting the protocol#

ntsport 4460
ntsservercert /etc/chrony/fullchain.pem
ntsserverkey  /etc/chrony/privkey.pem

NTS (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 iburst

keyfile 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 chrony

Never add After=gpsd.service to this drop-in. It would create a startup dependency cycle between chrony and gpsd: 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.timer

Three 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 that poll 4 (16 s) would actually fill it, hence, in theory, better noise rejection. Tested under real conditions on this project: poll 2 measurably gave a better RMS offset than poll 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 that filter 16 averages, something a purely theoretical argument cannot anticipate.

  • Trailing comments on server directives. Some chrony versions don’t accept a # comment after a server directive on the same line, the parser may behave unexpectedly. Put explanatory comments above the line instead.
  • chronyc failing silently under a hardened systemd service. If chronyd runs inside a restrictive systemd sandbox (recommended, see the hardening chapter), the default chronyc command (which uses a local UDP socket) can fail without a clear message.
  • Confusing noselect with “disabled”. refclock SHM 0 ... noselect stays active and feeds data into lock GPS, noselect only means “do not use directly as a time source”, not “disabled”.
  • Restarting chronyd to reload an NTS certificate. A systemctl restart breaks the ongoing discipline loop (the PLL loses its state). To reload a renewed certificate, sending a SIGHUP to 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     : Normal
  • Reference ID = PPS0 confirms 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] +/- 8549us

Real 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 for GPS, 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 29m shows that a source spaced at Poll 10 (2¹⁰ ≈ 17 min) can display an old last sample without that being a fault.
$ chronyc -N authdata

makes 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.