- Python 53.2%
- Shell 42.8%
- HTML 4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| docs | ||
| etc | ||
| web | ||
| .gitignore | ||
| README.md | ||
| setup.sh | ||
| verify.sh | ||
rpi-ntp-server
Repeatable process for building a stratum-1 NTP server from a blank microSD card, a Raspberry Pi 4, and a Waveshare NEO-M8T GNSS Timing HAT. Going from blank card to verified server takes about 20 minutes plus GPS lock time. (A Pi 5 also works — the config block covers both models automatically, and on a Pi 5 the setup additionally serves PTP from the NIC's hardware clock and hardware-timestamps NTP packets.)
How it works: the NEO-M8T sends NMEA sentences over the Pi's UART
(GPIO14/15 → /dev/ttyAMA0) and a pulse-per-second signal on GPIO18
(→ /dev/pps0). gpsd reads both and feeds them to chrony, which
disciplines the system clock to the PPS edge (tens of microseconds accuracy
or better) and serves NTP to the LAN.
On the Pi 4 the setup disables Bluetooth: the only timing-grade UART (the PL011) is wired to the Bluetooth radio by default, and the mini-UART alternative is unusable for a refclock because its clock varies with the core frequency. An NTP server has no use for Bluetooth, so it loses.
0. One-time hardware prep
Only needed the first time you assemble the hardware — not per SD card.
- Set the HAT's jumpers to the B (Pi ↔ GNSS) position so the module talks to the Pi's GPIO UART, not the micro-USB port.
- Screw the GNSS antenna onto the u.FL/SMA pigtail and place it with sky view — a windowsill works, outdoors or roofline is better. Antenna side without the sticker faces up.
- Seat the HAT on the Pi 5's 40-pin header.
1. Flash the SD card
Use Raspberry Pi Imager on your workstation:
- Device: Raspberry Pi 4 → OS: Raspberry Pi OS Lite (64-bit) (Bookworm) → your SD card.
- In Edit settings before writing:
- Hostname:
ntp-pi(or your choice) - Enable SSH, public-key auth only; paste your public key
- Set username (e.g.
andy) — skip the password if using keys - Wi-Fi: leave unconfigured if using Ethernet (recommended for an NTP server)
- Locale/timezone as appropriate (NTP itself is UTC; this only affects logs)
- Hostname:
- Write, then move the card to the Pi and power on.
Give it a static IP or a DHCP reservation on your router — clients will reference this address.
2. Provision
SSH in and run the setup script:
ssh andy@ntp-pi.local
sudo apt-get install -y git
git clone https://code.a10r.net/beezly/rpi-ntp-server.git
cd rpi-ntp-server
sudo ./setup.sh
sudo reboot
setup.sh is idempotent — re-run it any time. It:
- installs
gpsd,gpsd-clients,chrony,pps-tools - adds a model-conditional block to
/boot/firmware/config.txt: on a Pi 4,enable_uart=1+dtoverlay=disable-bt(frees the PL011 UART from Bluetooth); on a Pi 5,dtparam=uart0=on; on both,dtoverlay=pps-gpio,gpiopin=18,capture_clear - strips any serial console from
cmdline.txtso nothing else claims the UART, and disables thehciuartBluetooth service on a Pi 4 - installs
etc/default/gpsd(fixed devices/dev/ttyAMA0+/dev/pps0,-nso it polls without clients) - installs
etc/chrony/conf.d/gps-pps.conf(NMEA as the labelling source, PPS as the preferred refclock, LANallowrules, monitoring logs) - installs Netdata from the official repo (web dashboard; the Debian
archive build lacks the chrony collector) with a bounded metrics-DB
retention (
etc/netdata/netdata.conf) - adds national-lab stratum-1 NTP servers (NPL, PTB, Netnod) as
cross-check and fallback sources
(
etc/chrony/conf.d/external-servers.conf) - on a Pi 5 only: installs
linuxptp, serves PTP from the NIC's hardware clock, and enables hardware timestamping of NTP packets (see PTP grandmaster) - installs log2ram (RAM-backed
/var/log, synced to the card hourly and at shutdown), caps the journal at 32 MB, and rotates chrony's logs hourly (etc/logrotate.d/chrony) — SD-card wear protection for an appliance that runs unattended for years - installs the clock-accuracy collector
(
bin/clock-stats.py) that feeds the status page's charts - enables
gpsdandchrony
The reboot is required for the device-tree (UART/PPS) changes.
3. Verify
After reboot, wait a few minutes for a GPS fix, then:
cd rpi-ntp-server
./verify.sh
All checks should pass. Useful commands while waiting:
| Command | What you should see |
|---|---|
cgps -s |
3D fix, satellites in view |
sudo ppstest /dev/pps0 |
one assert line per second |
chronyc sources -v |
#* PPS selected, #? NMEA marked noselect |
chronyc tracking |
Reference ID = PPS, offsets in the µs range |
From another machine: ntpdate -q ntp-pi.local or add
server ntp-pi.local iburst prefer to its chrony/ntp config.
4. Receiver tuning (automatic)
gnss-tune.service runs bin/tune-receiver.sh at
every boot: it sets the M8T's stationary dynamic model, configures
GPS+GLONASS (Galileo is deliberately disabled — enabling it on TIM 1.10
firmware corrupted the receiver clock model until all measurements were
rejected; SBAS is disabled as useless for timing), and starts
a survey-in (≥ 1 h, to 5 m), after which the receiver holds its position
fixed and produces PPS from as little as one satellite — the "T" in M8T.
Check survey progress / result with (gpsd must release the port — polls
routed through a running gpsd don't get answered):
sudo systemctl stop gpsd.socket gpsd
sudo env UBXOPTS="-P 18 -w 6" ubxtool -f /dev/ttyAMA0 -s 9600 -p TIM-SVIN
sudo systemctl start gpsd.socket gpsd
Two related tweaks are part of the build: force_turbo=1 pins the CPU
clocks (frequency scaling adds jitter to the PPS interrupt timestamp), and
eee-off.service disables Energy-Efficient Ethernet, whose sleep/wake
transitions add jitter to served packets. On clients, use chrony's
xleave option against this server for best results.
PTP grandmaster (Pi 5 / CM5 only)
The Pi 5's RP1 Ethernet controller has a PTP hardware clock (PHC) with
hardware packet timestamping — the Pi 4's NIC has neither. On a Pi 5 or
CM5, setup.sh adds two things on top of the NTP service:
GPS PPS ──GPIO18──▶ chrony (system clock)
│
├─ hwtimestamp eth0 ──▶ NTP, hardware-timestamped
│
└─ phc2sys ──▶ NIC PHC ──▶ ptp4l ──▶ PTP on the LAN
etc/chrony/conf.d/hwtimestamp.confmakes chrony timestamp NTP packets in the NIC instead of the kernel, removing network-stack scheduling jitter from what clients see.phc2sys.servicesteers the PHC from the GPS-disciplined system clock (note: the reverse of the distro unit's direction, which assumes a PTP client), andptp4l.service(etc/linuxptp/ptp4l.conf) serves it as a master-only grandmaster with hardware timestamps. The PHC runs on the PTP (TAI) timescale; ptp4l announces the UTC offset.
phc2sys runs with -S 1 (step whenever more than a second out) and this
is not optional. Its default is to step once at startup and slew from
then on, and the RP1's PHC does not survive an eth0 link flap: a 39-second
link-down window during boot on 2026-08-10 left the hardware clock
777,778 s out, whereupon phc2sys pinned itself at the maximum 64000 ppm
slew — a correction that would have completed some time in the 25th
century — while ptp4l went on announcing itself as a grandmaster serving
that time. It ran that way for 20 hours. The netdata alarm below was
CRITICAL throughout, which is what alarms are for; there was simply nobody
subscribed to it.
Which PHC eth0 timestamps against (CM5)
On the CM5 IO board this needs pinning down explicitly. The board wires a
PHY interrupt that the Pi 5 Model B does not (ethernet-phy@0 in
bcm2712-rpi-cm5l-cm5io.dtb against ethernet-phy@1 in
bcm2712-rpi-5-b.dtb), and with CONFIG_BCM_NET_PHYPTP=y the BCM54213PE
PHY then registers a PHC of its own. Being probed first it takes
provider index 0, demoting the MAC's gem-ptp-timer to ptp1:
ptp0 → …ethernet-ffffffff:00 (MDIO PHY) = bcm_phy_ptp
ptp1 → eth0 (MAC) = gem-ptp-timer
The PHY timestamper never returns a TX timestamp, so ptp4l cycles
MASTER → FAULTY → LISTENING indefinitely ("timed out while polling for
tx timestamp") and chrony falls back to TX-only timestamping. It is also
reached over MDIO rather than memory-mapped registers, which costs about
four orders of magnitude in transfer quality:
| phc2sys | PHY (ptp0) |
MAC (ptp1) |
|---|---|---|
delay |
214,423 ns | 37 ns |
steering rms |
2,195–12,854 ns | 25–41 ns |
eth0-hwtstamp.service runs
bin/set-hwtstamp-provider.sh before
ptp4l, phc2sys and chrony start, selecting the PHC whose sysfs parent is
the net device itself — the MAC's. The selection is runtime-only and does
not survive a reboot, which is why it is a unit and not a one-off.
Selecting a provider needs ethtool ≥ 7.0 (--set-hwtimestamp-cfg);
trixie ships 6.14.2, which can only show it. setup.sh therefore
builds 7.x as /usr/local/sbin/ethtool-7, leaving the distro binary
alone. Drop that once Debian stable ships ≥ 7.0.
Accuracy: the grandmaster is bounded by the system-clock → PHC transfer over PCIe (sub-µs), not by the ~150 ns GPS discipline.
Disciplining the PHC directly from the PPS with ts2phc, for tens-of-ns
accuracy, is not possible on this hardware — a hoped-for benefit of
moving to a CM5 that does not survive contact with the driver. ts2phc
needs an external-timestamp channel to capture the PPS edge, and the
MAC's clock advertises none:
$ cat /sys/class/ptp/ptp1/n_external_timestamps # gem-ptp-timer
0
$ ls /sys/class/ptp/ptp1/pins/ # (empty)
The only PHC here with n_external_timestamps=1 is the PHY's, whose
sole pin is SYNC_OUT (an output) and whose TX timestamping is broken
anyway — see above. Whatever RP1 exposes in silicon, Linux offers no
PPS-in path to either clock today, so the software steering chain stands.
Check it:
ethtool -T eth0 # hardware-transmit/receive listed;
# provider index = the MAC's PHC
sudo pmc -u -b 0 'GET PORT_DATA_SET' # portState MASTER
sudo phc_ctl /dev/ptp1 cmp # PHC vs system: ~37 s (TAI-UTC;
# printed as CLOCK_REALTIME minus
# PHC, so it reads about -37 s).
# ptp1 on the CM5, ptp0 on a
# Model B -- check which is which
chronyc serverstats # NTP still served as before
A Linux PTP client needs linuxptp with the distro-default direction:
ptp4l -i eth0 -s plus stock phc2sys (PHC → system clock).
Monitoring: ptp-monitor.service
(bin/ptp-monitor.py) tails phc2sys/ptp4l and polls
pmc, feeding a ptp dashboard section (steering error rms/max, PHC
frequency correction, port state, missed TX timestamps) with alarms
(etc/netdata/health.d/ptp.conf) for
steering error above 500 ns/2 µs, loss of MASTER state, and TX timestamp
timeouts. The status page grows a "PTP: serving (grandmaster), NIC clock
within N ns" row — and, above 10 µs of steering error, a "NIC clock
unsteered — do not use" row instead, because a grandmaster that is up and
wrong is worse than one that is down. phc2sys logs 10-second rms/max
summaries (-u 10) rather than per-second lines, keeping the capped
journal quiet. The end-to-end truth remains a client-side measurement: a
linuxptp client's own master_offset against this server includes the
network path.
Board LEDs
The two onboard LEDs are repurposed as a status display you can read without logging in:
| LED | Shows |
|---|---|
PWR |
solid = every check passed; slow 2 s/2 s = only survey-in outstanding (converging); fast 500/500 = something needs a human |
ACT |
lit, blinking dark for 100 ms once per GPS PPS pulse |
Together they read at a glance as: is the second still arriving (ACT) and is everything else still true (PWR).
PWR needs setting explicitly — the CM5 IO board ships it with trigger
none and brightness 0, i.e. dark. Both are set by dtparam lines in
the managed config.txt block, and ACT is taken off SD-card activity
so pps-led.service can own it.
PWR as a health indicator
health-led.timer runs the same
verify.sh a human would, once a minute, and
bin/health-led.sh turns PWR solid only when
every check passes. Anything else leaves it blinking — at one of two
rates, because "not ready yet" and "broken" are different situations and
should not look alike:
| PWR | Meaning |
|---|---|
| solid | all checks pass |
| slow, 2 s on / 2 s off | only the GNSS survey-in check fails — the receiver is still converging |
| fast, 500 ms | anything else failed |
tune-receiver.sh restarts survey-in at every boot, so a freshly
rebooted box is legitimately not right yet for up to an hour, and that
is not a fault. It is not nothing either: mid-survey the receiver is
still solving for position every epoch rather than running
fixed-position time mode, and PPS jitter roughly doubles (measured
2026-08-11: ~100 ns mid-survey against a ~46 ns settled baseline). So it
is shown, logged as converging: rather than verify:, and
distinguished from a real fault. Making the two look identical would
train you to ignore the light.
Rate changes compare delay_on as well as the trigger — the trigger is
already timer in both blinking states, so comparing only that would
silently ignore a change of rate.
Blinking is the default, not the alarm state, and that asymmetry is
the point: config.txt sets dtparam=pwr_led_trigger=timer so the LED
blinks from the moment the kernel brings it up (the timer trigger's own
defaults are already 500 ms on/off, so no delays need setting), and it
takes a passing run to make it solid. A health timer that never fires, a
script that dies, or a box that never finishes booting all leave the
light blinking. Solid is a positive assertion, never an absence of bad
news — the only safe polarity for an indicator meant to be believed.
The check costs ~2.4 s per run, about a 4% duty cycle (verify.sh's
timeout values are ceilings, not durations). It is niced with idle I/O
priority. It logs the names of failing checks and stays silent when
passing, because once a minute into a 32 MB journal on log2ram's tmpfs
would otherwise be pure noise; for the same reason the unit sets
LogLevelMax=notice to drop systemd's own per-run start/stop lines.
The script exits 0 even when the checks fail — its job is to set the
LED, and it did. So systemctl --failed will not show ill health; the
LED and the logged FAIL lines are the signal.
Re-selecting the timer trigger restarts its blink phase, so a run that
finds the box still unhealthy leaves the trigger alone rather than
rewriting it — otherwise the LED would stutter once a minute instead of
blinking evenly.
ACT is driven by bin/pps-led.sh from ppstest's
event stream: this kernel has no PPS LED trigger (nor a netdev one),
and because the overlay sets capture_clear both edges are timestamped,
so the LED mirrors the real pulse instead of an invented blink. It idles
lit and blanks for the pulse (PPS_LED_INVERT=0 in the unit
reverses this), so a dark LED means the second stopped arriving — the
failure worth noticing reads clearly against a lit background, and a
stopped daemon leaves it dark too.
The daemon opens /dev/pps0 a second time alongside chrony's refclock,
which is permitted and does not perturb kernel timestamping; userspace is
confined to CPUs 0–2, so its once-a-second wakeup stays off the PPS
interrupt's isolated CPU3. It is cosmetic, so the unit runs it at
Nice=10 with idle I/O priority.
Monitoring
The chrony config enables full logging to /var/log/chrony/:
| File | Contents | Watch for |
|---|---|---|
tracking.log |
every clock update: offset, frequency, stratum | offset drifting out of the µs range |
refclocks.log |
every raw PPS and NMEA sample | gaps (signal loss), rising jitter |
statistics.log |
per-source regression stats | growing std-dev on PPS |
measurements.log |
fallback NTP server measurements | disagreement with GNSS time |
These logs are rotated hourly, not daily
(etc/logrotate.d/chrony, which replaces the
package's config, plus an hourly override on logrotate.timer). At 1 Hz,
refclocks.log alone runs ~85 MB/day, and /var/log is log2ram's 128 MB
tmpfs: with daily rotation the rotated copy sat next to the growing live
one, the filesystem hit 100%, and chrony's log writes then failed
silently — 0-byte logs and no measurement history at all, for two days,
with nothing in the journal to say so (2026-08-09 to 2026-08-11).
verify.sh now checks the headroom and that tracking.log is non-empty.
The trade is history: roughly the last six hours of per-sample data,
compressed, rather than a day.
log2ram itself flushes tmpfs to disk only on a clean stop or on its
own timer, so that timer's period is the window an unclean shutdown
destroys. Upstream ships it daily (23:55), which put up to 24 hours of
measurement history at risk of a power cut — precisely the evidence you
want after one. log2ram-daily.timer.d/hourly.conf
caps that at an hour, at ten past, so it lands after logrotate has
compressed and syncs the small files rather than the large live ones.
The unit keeps its upstream name, so "log2ram-daily" now runs hourly.
sudo log2ram write forces a sync on demand — worth running before
anything that risks the power.
Watchdog
A headless time server that wedges is off the air until somebody
notices. systemd pets /dev/watchdog0 (the BCM2835 timer) every 30 s
against a 1 min hardware timeout, and resets the board if PID 1 stops
responding; RebootWatchdogSec covers the shutdown path, where the
units that would otherwise notice a hang have already stopped.
Raspberry Pi OS enables all of this by default, which is exactly why
system.conf.d/watchdog.conf
pins it and verify.sh asserts it: a distro default that changed
underneath us would otherwise disappear silently. The timeout is
deliberately not tightened — a spurious reset on a momentarily busy box
costs more than a slightly slower recovery, and the reset is itself
unclean, bypassing log2ram's flush and forfeiting up to an hour of logs.
logchange 0.1 additionally writes a syslog warning whenever chrony has to
adjust the clock by more than 100 ms — after startup that should never
happen on this hardware, so any occurrence is worth an alert.
Live queries via chronyc: tracking (current offset/stratum), sources -v and sourcestats -v (per-source state), serverstats (NTP packets
served, per-client stats with clients).
SSD wear
Installing nvme-cli is enough to light up netdata's go.d nvme
collector (it shells out via the setuid ndsudo), giving 16 charts
without configuration — endurance used, data units read/written,
temperature, unsafe shutdowns, media errors, thermal throttling.
Worth watching because the write load is not the time service. Sampled
idle — SSH plus NTP/PTP, nothing else — the disk takes roughly
5–15 KiB/s, essentially all of it netdata's own metrics database
(/var/cache/netdata, tier-0 retention 256 MiB), with ext4's jbd2
journal thread committing behind it. Nothing in the timing path writes
continuously: /var/log is log2ram tmpfs so chrony's per-sample logs and
the journal stay in RAM until the periodic sync, swap is on zram, and
/ is noatime.
Beware the drive's lifetime average as a guide to any of this — a
second-hand SSD carries someone else's history. Sample
nvme.device_nvme0_io_transferred_count over a window instead, or diff
Data Units Written (1 unit = 512,000 bytes). If the writes ever need to
approach zero, netdata's dbengine is the only real lever, at the cost
of history across reboots.
GNSS telemetry and spoofing/jamming detection
gnss-monitor.service (bin/gnss-monitor.py) taps
gpsd's raw stream read-only (chrony untouched) and feeds a gnss
dashboard section: u-blox spoofing-detection state, RF jamming indicator
and noise floor, antenna supervisor, satellites and C/N0 per
constellation, survey-in state, receiver clock accuracy, PPS quantisation
error, leap-second announcements, and the median offset of the NTP
comparison servers (a systematic drift there implicates the GNSS).
Alarms in etc/netdata/health.d/gnss.conf
cover spoofing indicated, sustained jamming, satellite loss, loss of
fixed-position timing mode, antenna faults, pending leap seconds, and
GNSS-vs-NTP divergence.
Four charts exist to answer questions that otherwise need a manual
ubxtool poll at the moment you least want one:
| Chart | From | Answers |
|---|---|---|
gnss.dop |
NAV-DOP |
tDOP — the geometry term behind time_accuracy. A jitter excursion that tracks tDOP is the sky; one that doesn't is the receiver or antenna |
gnss.satellites_tracked |
NAV-SAT |
tracked vs used — the gap is what the elevation mask is discarding |
gnss.cno_by_elevation |
NAV-SAT |
whether low-elevation signals really are worse here, i.e. whether the mask still earns its keep |
gnss.orbit |
NAV-ORB |
ephemeris/almanac/AOP held — why a boot was slow, without inferring it |
The C/N0-by-elevation pair is the one that settles arguments. The 12°
CFG-NAV5 mask was set because low-elevation multipath at the old
antenna site stalled survey convergence. Measured after the antenna moved
on 2026-08-11: 37.1 dB-Hz below 15° against 37.2 dB-Hz from 15–45°. When
those converge the mask is discarding satellites on geometry alone, and
is worth re-testing rather than inheriting.
gnss.orbit also reads aop, the AssistNow Autonomous predictions. It
sits at 0 and will stay there: AOP data lives in battery-backed RAM, this
is a ROM module (MOD=NEO-M8T-0) with no flash to fall back on, and no
V_BCKP backup is fitted — so it is lost on exactly the power-cut
restarts where it would have helped. Fit backup power before enabling it.
Undervoltage and thermal throttling are alarmed separately in
etc/netdata/health.d/rpi.conf. With
force_turbo=1 pinning the clocks, a marginal supply shows up as
throttling rather than as a quiet slowdown, and rpi.throttled_sticky
latches an event that happened while nobody was watching.
Raw observables (post-processed position)
The NEO-M8T is the raw-data variant: it can emit RXM-RAWX carrier
phase and pseudorange, which a plain M8 cannot.
bin/log-raw-observables.sh captures those
for a chosen number of hours so they can be post-processed with RTKLIB
(convbin → RINEX, rnx2rtkp against a nearby CORS station) into a
centimetre-level position. That position can then be programmed as a
fixed TMODE2 position, removing survey-in altogether — no hour of
degraded jitter after every boot, no boot-to-boot position variation.
It is deliberately not enabled in tune-receiver.sh. At 1 Hz with
~25 satellites RXM-RAWX is ~800 bytes per epoch plus SFRBX, against a
115200-baud UART shared with the NMEA the refclock depends on — 10–15% of
the link for a measurement wanted once, not forever. It logs to
/var/lib/rpi-ntp-server/raw on the NVMe, since a 24-hour capture is far
larger than log2ram's 128 MB tmpfs, and disables itself on exit.
Status page
ntp-status.timer renders a curated, public-safe status page every
minute (stratum, offsets, error bound, satellites, receiver mode,
external-server agreement, and a live server-synced clock). It contains
no internal IPs, versions, or security telemetry.
Three charts go with it, drawn as inline SVG by the generator itself. Client-side charting is not an option: the public hostname is allowed only the status page and the clock-sync endpoint, so browser code cannot reach netdata's data API at all. Server-side rendering also keeps the page one self-contained file with no chart library.
| Chart | Window | Says |
|---|---|---|
| Offset from UTC | 24 h | mean line inside the min/max envelope — how tightly the clock is held, and whether it drifts |
| Cross-check vs national labs | 7 d | the only measurement here not marked by its own homework; bounded by internet path asymmetry (hundreds of µs), so it can confirm the absence of a gross error, nothing finer |
| Allan deviation | τ = 10 s … ~4 h | stability against averaging time; falling to the right means the error averages away as white noise, a floor or upturn would mean drift |
The data behind them comes from clock-stats.service
(bin/clock-stats.py), which polls chronyc every
10 s and pushes integer nanosecond gauges to netdata's statsd, plus a
rolling 24-hour overlapping Allan deviation written to
/var/lib/rpi-ntp-server/clock-stats.json. Netdata's own chrony
collector cannot be used for this: its offsets pass through the data API
as seconds rounded to seven decimals, so a clock living at ±100 ns comes
back quantised to a three-level staircase of -1e-7 / 0 / 1e-7.
Gaps in the data are drawn as gaps — bridging a collector outage with a
straight line would render it as perfect behaviour.
A Caddy front proxy on the Pi owns :19999 and routes by Host header
(etc/caddy/Caddyfile): requests arriving via
the public hostname get the status page at / and can reach only
/status.html and /api/v1/alarms (the clock-sync probes) — the
netdata dashboard and API are unreachable through the public name. Any
other Host (LAN IP, localhost) passes straight through to netdata,
which is bound to 127.0.0.1:19998. The external reverse proxy
therefore needs nothing but reverse_proxy <pi>:19999 — no path rules.
Web dashboard (Netdata)
setup.sh installs Netdata; browse to http://ntp-pi:19999. Its
chrony collector autodetects the daemon on 127.0.0.1:323 — no
configuration needed — and charts stratum, current/RMS offset, frequency
error, and root delay/dispersion under the chrony section, alongside
system health (CPU, temperature, network). The dashboard is read-only but
visible to the whole LAN by default; bind it to localhost via
/etc/netdata/netdata.conf if you'd rather tunnel in.
If you later want long-term history and alerting instead, the Prometheus
chrony_exporter or Telegraf's
chrony input plugin read the same data.
Keeping it current
unattended-upgrades installs from all four archives this box draws
from, not just Debian security, and reboots at 04:00 when an upgrade
requires it.
| File | Does |
|---|---|
20auto-upgrades |
arms the apt-daily timers |
52rpi-ntp-unattended |
origins, reboot window, kernel retention |
Both failure modes here are silent, which is why verify.sh asserts
against the effective configuration rather than the files:
- The
apt-dailytimers ship enabled on a stock image but install nothing untilAPT::Periodicsays so. A box can look automated and quietly fall behind for months — this one had both timers running andunattended-upgradesnot even installed, sitting on an uninstalledcaddysecurity update while serving that proxy to the internet. - An
Origins-Patternentry that matches no archive is a no-op. The strings come fromapt-cache policy; confirm withunattended-upgrade --dry-run --debug | grep 'Allowed origins', which prints what will actually be upgraded.
Upgrading everything, including the kernel, is defensible here for two
specific reasons. Nothing in this repo pins a kernel-version-specific
detail — the PPS interrupt is found by name and the PTP clock by sysfs
parent, both of which already survived a hardware change that renumbered
them. And verify.sh runs every minute driving the PWR LED, so a broken
upgrade shows as a blinking light within 60 seconds rather than as
mysteriously bad time.
Old kernels are not removed (Remove-Unused-Kernel-Packages "false"). Hardware timestamping and PPS on this platform have a
regression history (raspberrypi/linux#5904), so the previous kernel is
the rollback path; the disk space is worth nothing by comparison.
Automatic-Reboot-WithUsers is true on purpose. The safer-sounding
alternative is worse: one forgotten SSH session blocks every reboot
indefinitely, leaving an upgraded kernel installed and never running.
Documentation
- docs/performance.md — measured accuracy, the error budget, and the reasoning behind non-obvious configuration decisions.
- docs/roadmap.md — staged upgrade path: enclosure, IRQ pinning, MSF/DCF77 radio time, LF carrier-phase tracking, ionospheric modelling, disciplined oscillators, multi-receiver ensembles, and the Pi 5 hardware-timestamping route.
Rebuilding / disaster recovery
The SD card holds no unique state worth backing up — the whole point of this repo. To rebuild: repeat steps 1–3 with the same hostname and IP reservation, and clients never notice. The chrony drift file regenerates itself within an hour.
Troubleshooting
- No
/dev/pps0— the overlay didn't load; check the# BEGIN rpi-ntp-serverblock survived in/boot/firmware/config.txtand that you rebooted. /dev/ttyAMA0exists but no NMEA (gpspipe -rsilent) — check the HAT jumpers are on B, and thatcmdline.txthas noconsole=serial0entry.- PPS test times out — the M8T only emits PPS with a satellite fix;
check
cgps -sfirst, improve antenna placement. - chrony shows PPS but won't select it — the NMEA and PPS sources must
agree; if NMEA's offset is wildly off, adjust the
offsetvalue in/etc/chrony/conf.d/gps-pps.conf(measure withchronyc sourcestats -v).