Building a Stratum 1 NTP Server with GPS/PPS on Raspberry Pi

10 min read

If 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
  • gpsd for GPS data and coarse time
  • chrony for actual clock discipline
  • chrony_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.1
  • chrony 4.8
  • chrony_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:

  1. NMEA or UBX messages tell you which second it is.
  2. 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:

KeySegmentPermissionsPurpose
0x4e545030NTP0600Coarse GPS time
0x4e545031NTP1600PPS for privileged consumers
0x4e545032NTP2666PPS 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:

  • -n makes gpsd start 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=3 means you have a 3D fix.
  • status=2 means DGPS-quality fix.
  • time is 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.
  • gpsmon looks empty: often a display quirk, not a gpsd failure. If cgps -s works, 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:

  1. gpsd timestamps PPS events for SHM using the system clock.
  2. Before the time daemon has disciplined the system clock, that clock is wrong.
  3. 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:

  • NTP0 is root-only
  • ntpd runs as ntp
  • 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 0 reads coarse GPS time from gpsd. That is the “which second is this?” source.
  • refclock PPS /dev/pps0 lock GPS reads 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.
  • allow lets LAN clients use the Pi as their NTP server.
  • makestep 1 3 lets 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:

  • GPS is noisy by tens of milliseconds, because serial time is supposed to be noisy.
  • PPS is 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 : PPS means the Pi is locked to the pulse source, not a remote server.
  • Stratum : 1 means it is directly attached to a reference clock.
  • System time in 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 makestep is 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:

MetricMeaningGood value
chrony_upExporter can talk to chronyd1
chrony_tracking_stratumNTP stratum1
chrony_tracking_system_time_secondsCurrent system offsetunder 1us
chrony_tracking_last_offset_secondsLatest measurementunder 1us
chrony_tracking_rms_offset_secondsLong-term average offsetunder 10us
chrony_tracking_frequency_ppmsLocal oscillator driftstable and boring
chrony_tracking_skew_ppmsError bound on frequencylow

Source metrics tell you whether individual peers and refclocks still look sane:

MetricMeaning
chrony_sources_last_sample_offset_secondsOffset per source
chrony_sources_last_sample_error_margin_secondsError margin per source
chrony_sources_reachability_ratioPoll success ratio
chrony_sources_stratumSource stratum
chrony_sources_state_infosync, candidate, outlier, unreachable
chrony_sources_polling_interval_secondsPoll 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:

  • GPS should be noisy and coarse
  • PPS should 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 gpsd for coarse time and visibility
  • read PPS directly from /dev/pps0 with 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.