Building a Stratum 1 NTP Server with GPS/PPS on Raspberry Pi
10 min readIf you already run a homelab, eventually you start wanting your time to come from inside the house instead of “some pool servers on the internet, probably.” Not because pool.ntp.org is bad. It is excellent. But because once you have Prometheus, Grafana, UPS-backed hosts, and a mild infrastructure problem, the next logical step is apparently “build a stratum 1 clock.”
The good news is that this is entirely doable with a Raspberry Pi 4, a decent GNSS receiver, and chrony. The less good news is that the obvious path through ntpd is a swamp of half-working refclock drivers, shared memory weirdness, and one especially annoying circular dependency. I tried the obvious thing so you do not have to.
This setup ended up here:
- Raspberry Pi 4 running Arch Linux ARM
- u-blox ZED-F9P GNSS module for serial time + PPS
gpsdfor GPS data and coarse timechronyfor actual clock disciplinechrony_exporter, Prometheus, and Grafana for visibility
The end result is a LAN-served stratum 1 NTP server with roughly 150-400ns offset from UTC once it has settled. Which is a mildly absurd amount of precision for a thing sitting next to a router, but that is part of the charm.
Hardware
The hardware is simple:
- Raspberry Pi 4 running 64-bit Arch Linux ARM
- u-blox ZED-F9P multi-constellation GNSS receiver
- USB serial link, with the receiver showing up as
/dev/ttyACM0 - PPS wiring exposed as
/dev/pps0
I symlinked the serial device to /dev/gps0 with udev because device names that change under you are funny exactly once.
The software stack for the working setup:
gpsd 3.26.1chrony 4.8chrony_exporter 0.11.0- Prometheus on a separate monitoring host
- Grafana on that same monitoring host
Why GPS Time Is Two Signals, Not One
The important conceptual piece is that GPS timekeeping is really two separate signals:
- NMEA or UBX messages tell you which second it is.
- PPS tells you exactly when that second starts.
Those are not the same job.
The serial GPS stream gives you timestamps, but they arrive through USB, the kernel, buffering, and userspace parsing. That makes them accurate in the “yes, it is definitely this second” sense, but jittery by tens of milliseconds.
PPS, short for pulse per second, is a hardware edge emitted exactly on the second boundary. The kernel timestamps that edge with far better precision than userspace can manage. PPS is what gets you from “roughly correct” to “this box is now taking nanoseconds personally.”
The data path looks like this:
GNSS receiver --USB serial--> gpsd --SHM NTP0--> chrony
GNSS receiver --PPS GPIO-----> kernel PPS API (/dev/pps0) --> chrony
chrony --> LAN clients
chrony_exporter --> Prometheus --> Grafana
gpsd also writes PPS into SHM, but that turns out to be the wrong place to consume it from. More on that in the ntpd war story section.
SHM segments from gpsd
If you inspect shared memory, gpsd creates segments for NTP consumers:
| Key | Segment | Permissions | Purpose |
|---|---|---|---|
0x4e545030 | NTP0 | 600 | Coarse GPS time |
0x4e545031 | NTP1 | 600 | PPS for privileged consumers |
0x4e545032 | NTP2 | 666 | PPS for unprivileged consumers |
That permission split matters. NTP0 is root-only. If your time daemon runs as an unprivileged user, life gets interesting in the bad way.
Getting gpsd To Behave
On a distro that uses /etc/default/gpsd, the config is straightforward:
START_DAEMON="true"
GPSD_OPTIONS="-n"
DEVICES="/dev/gps0 /dev/pps0"
USBAUTO="true"
Two bits matter most:
-nmakesgpsdstart polling immediately instead of waiting for a client.- You must list both the serial device and the PPS device.
Once that is in place, the debugging commands you actually care about are:
# Is gpsd alive?
systemctl status gpsd
ps aux | grep gpsd
# Do you have a fix?
cgps -s
# Raw interactive view
gpsmon
# Stream raw JSON
gpspipe -w | head -20
# Specifically look for PPS events
gpspipe -w | grep PPS
# See SHM segments
ipcs -m
What healthy gpsd output looks like
A normal TPV message looks something like this:
{"class":"TPV","device":"/dev/gps0","status":2,"mode":3,
"time":"2026-04-02T11:23:02.000Z","lat":36.42399,"lon":-5.15240,...}
The fields worth caring about:
mode=3means you have a 3D fix.status=2means DGPS-quality fix.timeis the coarse second count chrony will use as an anchor.
A PPS message looks like this:
{"class":"PPS","device":"/dev/pps0","real_sec":1775128983,"real_nsec":0,
"clock_sec":1775128982,"clock_nsec":999955940,"precision":-20,"shm":"NTP2"}
The important part here is the split between the true GPS second and the system clock’s view of when that edge happened. If the machine clock is off, those numbers will disagree. That becomes important later.
Common gpsd potholes
- No serial data at all: on Raspberry Pi, make sure the login shell is not squatting on the UART and verify you are reading the right device.
gpsmonlooks empty: often a display quirk, not a gpsd failure. Ifcgps -sworks, you are probably fine.- First fix takes forever: a cold receiver with no almanac can take a while. Outdoors with clear sky, the ZED-F9P is usually much faster.
- SHM permissions look wrong: they are wrong, just usually by design.
PPS: The Part That Actually Makes This Precise
Before touching NTP at all, verify that PPS is really arriving:
ls -la /dev/pps0
sudo ppstest /dev/pps0
Healthy ppstest output looks like this:
source 0 - assert 1775128192.913287329, sequence: 12896874 - clear 0.000000000, sequence: 0
source 0 - assert 1775128193.913286383, sequence: 12896875 - clear 0.000000000, sequence: 0
What you want:
- timestamps exactly one second apart
- steadily increasing sequence numbers
- very little jitter between pulses
What you do not want is no output at all, because that means your wiring, GPIO config, or receiver PPS output is lying to you.
If /dev/pps0 does not exist, stop there and fix the kernel/device-tree side first. No NTP daemon can save you from a missing pulse input.
The ntpd Dead End
Naturally, I started with ntpd, because that is what a lot of old GPS/NTP docs still point at. This was a mistake.
The first attempt was the classic shared-memory plus PPS refclock setup:
server 127.127.28.0 minpoll 4 maxpoll 4 prefer
fudge 127.127.28.0 refid GPS
server 127.127.22.0 minpoll 4 maxpoll 4
fudge 127.127.22.0 refid PPS
That lasted right up until the logs said:
refclock_newpeer: clock type 22 invalid
So the ATOM driver was not even compiled into the Arch Linux ARM package. Excellent start. The driver that is supposed to read PPS directly from the kernel was simply unavailable.
I then tried the GPSD JSON driver. Also bad. It connected, then mostly sulked, and eventually produced the usual clk_no_reply style misery.
That left SHM.
SHM kind of works, in the same way a chair with three legs kind of works if you are very still. The problem is this:
gpsdtimestamps PPS events for SHM using the system clock.- Before the time daemon has disciplined the system clock, that clock is wrong.
- Therefore PPS-via-SHM is wrong by exactly the amount you need PPS to fix.
In my case the PPS SHM source sat around 57ms off and would not converge. That is the circular dependency:
- you need accurate PPS to fix the clock
- but PPS through SHM depends on the clock already being accurate
Then there was the permissions issue layered on top:
NTP0is root-onlyntpdruns asntp- the daemon can attach to the segment, but that does not mean it can read it reliably enough to build a clean solution
At that point ntpd had consumed enough of my evening and earned nothing further.
chrony Saves The Day
The reason chrony works is very simple: it does not insist on walking through the swamp.
This line is the whole difference:
refclock PPS /dev/pps0 refid PPS lock GPS
chrony reads PPS directly from the kernel PPS API through /dev/pps0. No gpsd SHM timestamping, no circular dependency, no drama. The kernel timestamps the interrupt edge. That is the thing you wanted all along.
The full working chrony.conf looked like this:
refclock SHM 0 refid GPS precision 1e-1 offset 0.0 delay 0.2
refclock PPS /dev/pps0 refid PPS lock GPS
server 0.arch.pool.ntp.org iburst
server 1.arch.pool.ntp.org iburst
server 2.arch.pool.ntp.org iburst
server 3.arch.pool.ntp.org iburst
driftfile /var/lib/chrony/drift
allow 10.255.0.0/16
makestep 1 3
rtcsync
What each piece is doing:
refclock SHM 0reads coarse GPS time from gpsd. That is the “which second is this?” source.refclock PPS /dev/pps0 lock GPSreads the precise edge from the kernel and ties it to the GPS second numbering.- pool servers are there as sanity and fallback, not as the primary source.
allowlets LAN clients use the Pi as their NTP server.makestep 1 3lets chrony fix a badly wrong clock quickly at startup instead of spending half a lifetime slewing it back into reality.
The two chronyc commands that matter
First:
chronyc sources -v
Healthy output:
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
#- GPS 0 4 377 24 +54ms[ +54ms] +/- 200ms
#* PPS 0 4 377 22 -153ns[-1337ns] +/- 334ns
^- tock.espanix.net 1 6 77 26 -554us[ -555us] +/- 6323us
This is exactly what you want to see:
GPSis noisy by tens of milliseconds, because serial time is supposed to be noisy.PPSis sitting down in the nanoseconds, because that is the actual precision source.- internet peers are reachable but clearly not what the clock is following.
Then:
chronyc tracking
Example output:
Reference ID : 50505300 (PPS)
Stratum : 1
System time : 0.000000313 seconds slow of NTP time
Last offset : -0.000000397 seconds
RMS offset : 0.000004104 seconds
Frequency : 5.823 ppm fast
The headline numbers:
Reference ID : PPSmeans the Pi is locked to the pulse source, not a remote server.Stratum : 1means it is directly attached to a reference clock.System timein the sub-microsecond range means the whole thing is doing what you built it to do.
The 5-6ppm oscillator drift on a Raspberry Pi is also completely normal. It is not a fancy oven-controlled oscillator. It is a tiny board that mostly wants to run Linux and warm up slightly.
If chrony looks wrong
The usual failure modes are mercifully sane:
- PPS shows
?: chrony has no coarse time source to lock PPS to, so go fix gpsd/SHM first. - GPS offset is 50-100ms: that is normal, not a bug.
- Large startup step: normal if the box booted with stale RTC state or no RTC at all. That is what
makestepis for.
Exporting chrony To Prometheus
Once the clock itself works, you want to stop SSHing in every time you wonder whether it still works.
I used chrony_exporter 0.11.0:
curl -LO https://github.com/SuperQ/chrony_exporter/releases/download/v0.11.0/chrony_exporter-0.11.0.linux-arm64.tar.gz
tar xzf chrony_exporter-0.11.0.linux-arm64.tar.gz
sudo cp chrony_exporter-0.11.0.linux-arm64/chrony_exporter /usr/local/bin/
The systemd unit:
[Unit]
Description=Chrony Exporter
After=chronyd.service
[Service]
User=root
ExecStart=/usr/local/bin/chrony_exporter \
--collector.sources \
--collector.serverstats \
--collector.tracking \
--chrony.address=unix:///run/chrony/chronyd.sock \
--collector.chmod-socket
Restart=always
[Install]
WantedBy=multi-user.target
Why the Unix socket matters
The exporter can talk to chronyd in two ways:
- UDP command port (
127.0.0.1:323) - Unix socket (
/run/chrony/chronyd.sock)
UDP sounds simpler. It is not simpler.
With UDP, the tracking collector may work, but serverstats tends to run into authentication and UNAUTH problems. cmdallow is not enough to make the more privileged commands happy. You end up staring at chrony_up 0 and wondering why a daemon on localhost is acting like you are an intruder.
The Unix socket is the correct answer:
- no UDP command ACL weirdness
- all collectors work
- permissions are handled explicitly
If the exporter misbehaves, these are the checks worth running:
systemctl status chrony-exporter
journalctl -u chrony-exporter -n 30 --no-pager
curl -s http://localhost:9123/metrics | grep chrony_up
curl -s http://localhost:9123/metrics | grep ^chrony_
If you need more detail, add --log.level=debug and look for UNAUTH or socket-permission complaints.
Prometheus scrape config
The scrape side is unremarkable, which is how monitoring should be:
scrape_configs:
- job_name: chrony
static_configs:
- targets: ['chronos:9123']
labels:
instance: chronos
Metrics that actually matter
Tracking metrics tell you whether the machine itself is healthy:
| Metric | Meaning | Good value |
|---|---|---|
chrony_up | Exporter can talk to chronyd | 1 |
chrony_tracking_stratum | NTP stratum | 1 |
chrony_tracking_system_time_seconds | Current system offset | under 1us |
chrony_tracking_last_offset_seconds | Latest measurement | under 1us |
chrony_tracking_rms_offset_seconds | Long-term average offset | under 10us |
chrony_tracking_frequency_ppms | Local oscillator drift | stable and boring |
chrony_tracking_skew_ppms | Error bound on frequency | low |
Source metrics tell you whether individual peers and refclocks still look sane:
| Metric | Meaning |
|---|---|
chrony_sources_last_sample_offset_seconds | Offset per source |
chrony_sources_last_sample_error_margin_seconds | Error margin per source |
chrony_sources_reachability_ratio | Poll success ratio |
chrony_sources_stratum | Source stratum |
chrony_sources_state_info | sync, candidate, outlier, unreachable |
chrony_sources_polling_interval_seconds | Poll interval |
The nice thing about this combination is that it tells you both whether the local clock is good and why it is good.
Grafana Dashboard
The dashboard I ended up with has three sections:
- Status row for “is this box healthy right now?”
- Tracking row for offset, drift, and dispersion over time
- Sources row for per-source behavior, including a table that shows which source is actually syncing
The useful mental model is:
GPSshould be noisy and coarsePPSshould be boring and near zero- remote internet peers should be the backup singers, not the lead vocalist
Here is the full dashboard JSON. Paste it into Grafana’s import dialog or provision it from a file.
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 1,
"title": "Status",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"mappings": [
{ "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 1 },
"id": 2,
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "Chrony Status",
"targets": [{ "expr": "chrony_up{instance=\"chronos\"}", "refId": "A" }],
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 2 }, { "color": "red", "value": 3 }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 1 },
"id": 3,
"options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "Stratum",
"targets": [{ "expr": "chrony_tracking_stratum{instance=\"chronos\"}", "refId": "A" }],
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 3,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.001 }, { "color": "red", "value": 0.01 }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 6, "y": 1 },
"id": 4,
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "System Time Offset",
"targets": [{ "expr": "chrony_tracking_system_time_seconds{instance=\"chronos\"}", "refId": "A" }],
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 6,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.001 }, { "color": "red", "value": 0.01 }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 10, "y": 1 },
"id": 5,
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "Last Offset",
"targets": [{ "expr": "chrony_tracking_last_offset_seconds{instance=\"chronos\"}", "refId": "A" }],
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 6,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.001 }, { "color": "red", "value": 0.01 }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 14, "y": 1 },
"id": 6,
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "RMS Offset",
"targets": [{ "expr": "chrony_tracking_rms_offset_seconds{instance=\"chronos\"}", "refId": "A" }],
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 1 },
"id": 7,
"options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "Reference",
"targets": [{ "expr": "chrony_tracking_info{instance=\"chronos\"}", "legendFormat": "{{tracking_name}}", "refId": "A" }],
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"unit": "ppm",
"decimals": 3,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 10 }, { "color": "red", "value": 50 }] }
},
"overrides": []
},
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 1 },
"id": 8,
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" },
"title": "Frequency Error",
"targets": [{ "expr": "chrony_tracking_frequency_ppms{instance=\"chronos\"}", "refId": "A" }],
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
"id": 10,
"title": "Tracking",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": true, "axisLabel": "", "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "s",
"decimals": 6
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"id": 11,
"options": { "legend": { "calcs": ["mean", "lastNotNull", "min", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "System Clock Offset",
"targets": [
{ "expr": "chrony_tracking_system_time_seconds{instance=\"chronos\"}", "legendFormat": "system time", "refId": "A" },
{ "expr": "chrony_tracking_last_offset_seconds{instance=\"chronos\"}", "legendFormat": "last offset", "refId": "B" },
{ "expr": "chrony_tracking_rms_offset_seconds{instance=\"chronos\"}", "legendFormat": "RMS offset", "refId": "C" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisLabel": "ppm", "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "ppm",
"decimals": 3
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"id": 12,
"options": { "legend": { "calcs": ["mean", "lastNotNull", "min", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Frequency",
"targets": [
{ "expr": "chrony_tracking_frequency_ppms{instance=\"chronos\"}", "legendFormat": "frequency error", "refId": "A" },
{ "expr": "chrony_tracking_residual_frequency_ppms{instance=\"chronos\"}", "legendFormat": "residual frequency", "refId": "B" },
{ "expr": "chrony_tracking_skew_ppms{instance=\"chronos\"}", "legendFormat": "skew (error bound)", "refId": "C" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisLabel": "", "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "s",
"decimals": 6
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 },
"id": 13,
"options": { "legend": { "calcs": ["mean", "lastNotNull", "min", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Root Delay & Dispersion",
"targets": [
{ "expr": "chrony_tracking_root_delay_seconds{instance=\"chronos\"}", "legendFormat": "root delay", "refId": "A" },
{ "expr": "chrony_tracking_root_dispersion_seconds{instance=\"chronos\"}", "legendFormat": "root dispersion", "refId": "B" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisLabel": "", "drawStyle": "line", "fillOpacity": 0, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 },
"id": 14,
"options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Update Interval",
"targets": [
{ "expr": "chrony_tracking_update_interval_seconds{instance=\"chronos\"}", "legendFormat": "update interval", "refId": "A" }
],
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 },
"id": 20,
"title": "Sources",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": true, "axisLabel": "", "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "s",
"decimals": 6
},
"overrides": []
},
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 23 },
"id": 21,
"options": { "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "bottom", "sortBy": "Last *", "sortDesc": false }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Source Offsets",
"targets": [
{ "expr": "chrony_sources_last_sample_offset_seconds{instance=\"chronos\"}", "legendFormat": "{{source_name}}", "refId": "A" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisLabel": "", "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "s",
"decimals": 6
},
"overrides": []
},
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 23 },
"id": 22,
"options": { "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "bottom", "sortBy": "Last *", "sortDesc": false }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Source Error Margin",
"targets": [
{ "expr": "chrony_sources_last_sample_error_margin_seconds{instance=\"chronos\"}", "legendFormat": "{{source_name}}", "refId": "A" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisLabel": "", "drawStyle": "line", "fillOpacity": 30, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"min": 0,
"max": 1,
"unit": "percentunit",
"decimals": 0
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
"id": 23,
"options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Source Reachability",
"targets": [
{ "expr": "chrony_sources_reachability_ratio{instance=\"chronos\"}", "legendFormat": "{{source_name}}", "refId": "A" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisLabel": "", "drawStyle": "line", "fillOpacity": 0, "lineWidth": 1, "pointSize": 5, "showPoints": "never", "spanNulls": false },
"unit": "s"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
"id": 24,
"options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "none" } },
"title": "Source Polling Interval",
"targets": [
{ "expr": "chrony_sources_polling_interval_seconds{instance=\"chronos\"}", "legendFormat": "{{source_name}}", "refId": "A" }
],
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": { "type": "auto" },
"inspect": false
},
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": [
{
"matcher": { "id": "byName", "options": "State" },
"properties": [
{
"id": "mappings",
"value": [
{ "options": { "outlier": { "color": "orange", "text": "outlier" }, "sync": { "color": "green", "text": "sync" }, "candidate": { "color": "blue", "text": "candidate" }, "unreachable": { "color": "red", "text": "unreachable" } }, "type": "value" }
]
},
{ "id": "custom.cellOptions", "value": { "mode": "basic", "type": "color-background" } }
]
},
{
"matcher": { "id": "byName", "options": "Stratum" },
"properties": [{ "id": "custom.width", "value": 80 }]
},
{
"matcher": { "id": "byName", "options": "Mode" },
"properties": [{ "id": "custom.width", "value": 130 }]
},
{
"matcher": { "id": "byName", "options": "Reachability" },
"properties": [
{ "id": "unit", "value": "percentunit" },
{ "id": "decimals", "value": 0 },
{ "id": "custom.cellOptions", "value": { "mode": "basic", "type": "color-background" } },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "green", "value": 0.875 }] } }
]
},
{
"matcher": { "id": "byName", "options": "Offset" },
"properties": [{ "id": "unit", "value": "s" }, { "id": "decimals", "value": 6 }]
},
{
"matcher": { "id": "byName", "options": "Error Margin" },
"properties": [{ "id": "unit", "value": "s" }, { "id": "decimals", "value": 6 }]
}
]
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 40 },
"id": 25,
"options": { "showHeader": true, "footer": { "show": false } },
"title": "Source Details",
"targets": [
{ "expr": "chrony_sources_state_info{instance=\"chronos\"}", "format": "table", "instant": true, "refId": "A" },
{ "expr": "chrony_sources_stratum{instance=\"chronos\"}", "format": "table", "instant": true, "refId": "B" },
{ "expr": "chrony_sources_reachability_ratio{instance=\"chronos\"}", "format": "table", "instant": true, "refId": "C" },
{ "expr": "chrony_sources_last_sample_offset_seconds{instance=\"chronos\"}", "format": "table", "instant": true, "refId": "D" },
{ "expr": "chrony_sources_last_sample_error_margin_seconds{instance=\"chronos\"}", "format": "table", "instant": true, "refId": "E" }
],
"transformations": [
{
"id": "joinByField",
"options": { "byField": "source_name", "mode": "outer" }
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "Time 4": true, "Time 5": true,
"__name__": true, "__name__ 1": true, "__name__ 2": true, "__name__ 3": true, "__name__ 4": true,
"instance": true, "instance 1": true, "instance 2": true, "instance 3": true, "instance 4": true,
"job": true, "job 1": true, "job 2": true, "job 3": true, "job 4": true,
"source_address": true, "source_address 1": true, "source_address 2": true, "source_address 3": true, "source_address 4": true,
"source_name 1": true, "source_name 2": true, "source_name 3": true, "source_name 4": true,
"tracking_address": true, "tracking_name": true, "tracking_refid": true
},
"renameByName": {
"source_name": "Source",
"source_mode": "Mode",
"source_state": "State",
"Value #A": "",
"Value #B": "Stratum",
"Value #C": "Reachability",
"Value #D": "Offset",
"Value #E": "Error Margin"
}
}
},
{
"id": "filterByValue",
"options": {
"filters": [{ "fieldName": "Source", "config": { "id": "isNotNull" } }],
"match": "all",
"type": "include"
}
}
],
"type": "table"
}
],
"schemaVersion": 39,
"templating": {
"list": [
{
"current": { "selected": false, "text": "Prometheus", "value": "PBFA97CFB590B2093" },
"hide": 0,
"includeAll": false,
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timepicker": {},
"timezone": "",
"title": "Chrony / GPS Time Server",
"uid": "chrony-gps",
"version": 1
}
Quick Health Check
When you want to verify the full stack without thinking too hard, these are enough:
# GPS serial data arriving?
gpspipe -w -n 3 | grep TPV
# PPS pulses arriving?
sudo ppstest /dev/pps0
# gpsd seeing PPS too?
gpspipe -w -n 3 | grep PPS
# chrony locked to PPS with tiny offset?
chronyc tracking
# all time sources sane?
chronyc sources -v
# exporter up?
curl -s http://localhost:9123/metrics | grep chrony_up
# scrape works from the monitoring host?
curl -s http://chronos:9123/metrics | grep chrony_tracking_stratum
If those checks pass, the machine is doing its job.
Wrapping Up
The working recipe is surprisingly clean once you stop trying to force ntpd through it:
- use
gpsdfor coarse time and visibility - read PPS directly from
/dev/pps0with chrony - let chrony serve the LAN
- export the state so you can see when something drifts, drops, or silently stops being clever
The core lesson is that GPS plus PPS is a two-signal system, and your time daemon needs to respect that split. Let GPS tell you which second it is. Let PPS tell you exactly when it started. Let chrony do the fusion. And let ntpd enjoy retirement.