<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ntp on Andy Little</title><link>https://andylittle.net/tags/ntp/</link><description>Recent content in Ntp on Andy Little</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 22 Jul 2026 13:00:00 -0500</lastBuildDate><atom:link href="https://andylittle.net/tags/ntp/index.xml" rel="self" type="application/rss+xml"/><item><title>Building a GPS-Disciplined NTP Server on a Raspberry Pi</title><link>https://andylittle.net/blog/2026/gps-disciplined-ntp-raspberry-pi/</link><pubDate>Wed, 22 Jul 2026 13:00:00 -0500</pubDate><guid>https://andylittle.net/blog/2026/gps-disciplined-ntp-raspberry-pi/</guid><description>How a Raspberry Pi 5, a cheap u-blox GPS module, and chrony turned into a home stratum-1 time server — the wiring mistakes, the serial-port gotcha nobody documents, and the PPS upgrade that took accuracy from ±50ms to ±350ns.</description><content:encoded><![CDATA[<p>I wanted an NTP server for the home network that doesn&rsquo;t depend on internet
access to know what time it is. The public pool servers are fine, but they&rsquo;re
one more thing that goes dark if the WAN link does — and it felt like a fun
excuse to wire a GPS module to a Raspberry Pi and let satellites do the timekeeping
instead.</p>
<p>The end state: a Pi 5 (<code>pi5-2</code>) serving NTP to the LAN, disciplined by a u-blox
NEO-series GPS module. The GPS&rsquo;s NMEA sentences over serial say <em>which</em> second
it is; a PPS (pulse-per-second) line on GPIO18 says <em>exactly when</em> that second
starts. <code>chrony</code> locks onto the PPS pulse as its primary source, and the whole
LAN gets genuine stratum-1 time.</p>
<h2 id="hardware">Hardware</h2>
<ul>
<li>Raspberry Pi 5</li>
<li>A cheap u-blox NEO-6M/7M/8M GPS breakout board</li>
<li>An outdoor active GPS antenna (added later — more on why below)</li>
</ul>
<p>Wiring to the GPIO header:</p>
<table>
	<thead>
			<tr>
					<th>Module pin</th>
					<th>Pi pin</th>
					<th>Notes</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>VCC</td>
					<td>Physical pin 2 (<strong>5V</strong>)</td>
					<td>Not 3.3V. Most of these blue breakout boards have an onboard regulator that needs 5V in; 3.3V isn&rsquo;t enough to power the chip.</td>
			</tr>
			<tr>
					<td>GND</td>
					<td>Physical pin 6</td>
					<td></td>
			</tr>
			<tr>
					<td>TX</td>
					<td>Physical pin 10 (RXD0 / GPIO15)</td>
					<td></td>
			</tr>
			<tr>
					<td>RX</td>
					<td>Physical pin 8 (TXD0 / GPIO14)</td>
					<td></td>
			</tr>
			<tr>
					<td>PPS</td>
					<td>Physical pin 12 (GPIO18)</td>
					<td>Added later, for the PPS upgrade. Most NEO breakouts label this pin; if yours doesn&rsquo;t break it out, the pad is next to the antenna connector on the u-blox chip. The module only pulses PPS once it has a fix.</td>
			</tr>
	</tbody>
</table>
<p>The first debugging lesson, before any software was involved: if the module&rsquo;s
status LED never blinks, check the power pin voltage before anything else. A
GPS module that looks &ldquo;dead&rdquo; is very often just under-powered.</p>
<h2 id="software-setup">Software setup</h2>
<p>The UART on the GPIO header defaults to being the serial console/login shell,
so that has to be freed up first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">raspi-config nonint do_serial_cons <span class="m">1</span>   <span class="c1"># disable login shell over serial</span>
</span></span><span class="line"><span class="cl">raspi-config nonint do_serial_hw <span class="m">0</span>     <span class="c1"># enable the UART hardware (adds</span>
</span></span><span class="line"><span class="cl">                                        <span class="c1"># dtparam=uart0=on on Pi 5)</span>
</span></span><span class="line"><span class="cl">reboot                                 <span class="c1"># required for the overlay to apply</span>
</span></span></code></pre></div><p>Then install the two pieces that do the actual work:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt install gpsd gpsd-clients chrony
</span></span></code></pre></div><p><code>gpsd</code> talks to the GPS module and parses its NMEA output; <code>chrony</code> is the NTP
daemon that turns that (plus PPS, later) into disciplined system time and
serves it to the LAN.</p>
<p>Point gpsd at the device in <code>/etc/default/gpsd</code>:</p>
<pre tabindex="0"><code>DEVICES=&#34;/dev/ttyAMA0&#34;
GPSD_OPTIONS=&#34;-n&#34;
</code></pre><p>And give chrony a GPS refclock in <code>/etc/chrony/conf.d/gps.conf</code>:</p>
<pre tabindex="0"><code>refclock SHM 0 refid NMEA precision 0.05 poll 3 filter 8 offset 0.142
allow 192.168.1.0/24
local stratum 10
</code></pre><p>Then <code>systemctl enable --now gpsd.socket gpsd.service chrony</code>.</p>
<p>That looks simple. Getting there wasn&rsquo;t — two gotchas ate most of the
debugging time, and neither is obvious from the standard docs.</p>
<h2 id="gotcha-1-devserial0-is-a-trap-on-the-pi-5">Gotcha #1: <code>/dev/serial0</code> is a trap on the Pi 5</h2>
<p>The conventional wisdom is &ldquo;use <code>/dev/serial0</code>, it&rsquo;s the stable alias for the
GPIO UART.&rdquo; On this Pi 5 + Bookworm image, that&rsquo;s wrong. <code>serial0</code> resolves to
<code>ttyAMA10</code> — the SoC&rsquo;s <em>internal</em> PL011 UART, not the RP1 UART actually wired
to the GPIO14/15 header pins. The header UART is <code>ttyAMA0</code>.</p>
<p>This is a nasty one to debug because every surface-level check says it should
work: the device exists, gpsd can open it without error, and <code>pinctrl</code> shows
the pins correctly muxed to TXD0/RXD0. It just silently receives zero bytes,
because it&rsquo;s listening to a UART with nothing physically connected to it.</p>
<p>I confirmed it by comparing <code>/sys/class/tty/ttyAMA*/device/of_node</code> against
<code>/proc/device-tree/aliases/serial0</code> (they pointed at different device-tree
nodes), and with a physical loopback test — a jumper wire between pins 8 and
10, then writing and reading <code>/dev/ttyAMA0</code> directly to prove the header UART
was the one actually moving bytes.</p>
<p><strong>Fix: use <code>/dev/ttyAMA0</code>, not <code>/dev/serial0</code>, on this board.</strong></p>
<h2 id="gotcha-2-gpsds-chrony-sock-integration-is-pps-only">Gotcha #2: gpsd&rsquo;s chrony <code>SOCK</code> integration is PPS-only</h2>
<p>gpsd ships wired up to feed chrony directly over a Unix socket at
<code>/run/chrony.&lt;device&gt;.sock</code> (chrony creates the socket; gpsd connects to it as
a client, which is the opposite of what the naming suggests). It looks like
the obvious modern integration path. On a module with no PPS wire yet, it just
doesn&rsquo;t work — <code>refclock SOCK /run/chrony.ttyAMA0.sock</code> sits at reachability 0
forever, with no error logged anywhere.</p>
<p>Digging through the strings in the <code>gpsd</code> binary turned up the reason:
<code>&quot;PPS:%s using chrony socket ...&quot;</code> — that path only activates once gpsd has a
PPS source. No PPS wire, no socket traffic, ever, silently.</p>
<p><strong>Fix: use the older <code>refclock SHM 0</code> interface</strong>, which gpsd always
populates with coarse NMEA/UBX-derived time regardless of whether PPS is
present. That&rsquo;s the <code>refclock SHM 0 refid NMEA ...</code> line above.</p>
<h2 id="calibrating-the-nmea-offset">Calibrating the NMEA offset</h2>
<p>Without PPS, NMEA time arrives late over the serial link by a fixed amount —
on this setup, a very stable +142ms (std dev under 2ms once the outdoor
antenna gave a solid fix). That&rsquo;s calibrated out with the <code>offset</code> parameter:</p>
<ol>
<li>Let chrony run with a good GPS fix for a few minutes.</li>
<li>Read the <code>Offset</code> column for the GPS source in <code>chronyc sourcestats</code> — its
offset relative to the NTP-disciplined system clock.</li>
<li>Put that value (in seconds, same sign) on the <code>refclock SHM</code> line&rsquo;s
<code>offset</code> parameter and restart chrony.</li>
<li>Verify with <code>chronyc sources</code>: the GPS offset should now sit within a few
milliseconds of the pool servers.</li>
</ol>
<p>The <code>precision 0.05</code> on that line claims a conservative ±50ms error bar —
small enough that chrony selects GPS as primary, large enough that if the
NMEA latency ever drifts substantially, the pool servers out-vote it and
chrony marks it a falseticker instead of quietly following bad time.</p>
<p>Indoors, the module struggled to get a fix at all. An outdoor active antenna
fixed that — a solid fix on ~10 satellites is what made everything past this
point (including PPS) practical.</p>
<h2 id="reading-chronyc-sources">Reading <code>chronyc sources</code></h2>
<p>If you haven&rsquo;t stared at chrony output before, the symbols are worth
knowing:</p>
<pre tabindex="0"><code>MS Name/IP address         Stratum Poll Reach LastRx Last sample
===============================================================================
#- NMEA                          0   3   377     2   +1234us[+1230us] +/- 50ms
^* time.pool.example.org         2   6   377    23   -123us[ -089us] +/-  12ms
^+ ntp2.pool.example.org         2   6   377    41   +456us[ +460us] +/-  15ms
</code></pre><ul>
<li><code>#</code> marks a reference clock (GPS); <code>^</code> marks a network peer.</li>
<li><code>*</code> is the currently selected source; <code>+</code> is a candidate in agreement;
<code>-</code> is reachable but not currently selected/combined.</li>
<li>The trailing <code>+/- Nms</code> is chrony&rsquo;s estimated error bound for that source —
this is what the <code>precision</code> setting on a refclock line ultimately informs.</li>
</ul>
<h2 id="the-pps-upgrade">The PPS upgrade</h2>
<p>NMEA-only gets you agreement with the pool to a couple of milliseconds, but
the true accuracy is only as good as that +142ms latency is stable — and it
can shift if gpsd, the sentence mix, or the module changes. PPS fixes this
properly: it&rsquo;s a hardware pulse on the <em>exact</em> top of each second, so
sub-second timing stops depending on a measured constant entirely. The NMEA
sentence still supplies <em>which</em> second it is; PPS supplies the precise edge
within it.</p>
<p><strong>1. Enable the PPS overlay.</strong> In <code>/boot/firmware/config.txt</code> (Bookworm&rsquo;s
path — not <code>/boot/config.txt</code>):</p>
<pre tabindex="0"><code>dtoverlay=pps-gpio,gpiopin=18
</code></pre><p><code>reboot</code> for the overlay to load. GPIO18 is just the convention
<code>dtoverlay=pps-gpio</code> defaults to; any free GPIO works if <code>gpiopin=</code> matches
the wire.</p>
<p><strong>2. Confirm the kernel sees pulses:</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt install pps-tools
</span></span><span class="line"><span class="cl">lsmod <span class="p">|</span> grep pps_gpio          <span class="c1"># pps_gpio module loaded</span>
</span></span><span class="line"><span class="cl">dmesg <span class="p">|</span> grep pps                <span class="c1"># &#34;new PPS source pps@12&#34; + &#34;Registered IRQ&#34;</span>
</span></span><span class="line"><span class="cl">ppstest /dev/pps-gps            <span class="c1"># one &#34;assert&#34; line per second, timed</span>
</span></span></code></pre></div><p>If <code>ppstest</code> prints nothing, either the module has no fix yet (most NEO
boards don&rsquo;t pulse PPS without one) or the wiring/<code>gpiopin</code> is wrong.</p>
<p><strong>3. Gotcha: <code>/dev/ppsN</code> numbering is a boot-time race.</strong> On the Pi 5, the
Ethernet controller&rsquo;s PTP clock (<code>ptp0</code>) and the GPIO PPS both register as
<code>/dev/pps*</code>, and which number each gets depends on probe order at boot. On
this box the GPIO PPS came up as <code>pps0</code> and the Ethernet PTP clock as <code>pps1</code>
— the <em>opposite</em> of what I&rsquo;d first assumed, and the opposite of how it had
been before adding PPS. Trusting a bare device number risks chrony silently
locking onto the Ethernet PTP clock after a reboot: no error, just quietly
wrong time.</p>
<p>The fix is to match on the stable device-tree <code>name</code> instead of the number,
via a udev rule (<code>/etc/udev/rules.d/10-pps-gps.rules</code>):</p>
<pre tabindex="0"><code>SUBSYSTEM==&#34;pps&#34;, ATTR{name}==&#34;pps@12.-1&#34;, SYMLINK+=&#34;pps-gps&#34;
</code></pre><p>Then <code>udevadm control --reload-rules &amp;&amp; udevadm trigger --subsystem-match=pps</code>,
and confirm <code>/dev/pps-gps</code> points at whichever <code>ppsN</code> is actually the GPIO
source. Check the <code>name</code> on your own board with <code>cat /sys/class/pps/pps*/name</code>
— the GPIO one is the <code>pps@NN</code> entry, the Ethernet one is <code>ptp0</code>.</p>
<p><strong>4. Add the PPS refclock to chrony.</strong> Keep the existing NMEA line (it still
supplies the integer second) and add a native PPS refclock locked to it, in
<code>/etc/chrony/conf.d/gps.conf</code>:</p>
<pre tabindex="0"><code>refclock SHM 0 refid NMEA precision 0.05 poll 3 filter 8 offset 0.142
refclock PPS /dev/pps-gps refid PPS lock NMEA precision 1e-7 poll 3 prefer
</code></pre><ul>
<li><code>lock NMEA</code> tells chrony to take the ambiguous PPS second-number from the
NMEA refclock — so the calibrated <code>offset 0.142</code> still matters, just for a
much looser reason: it only needs to land NMEA within ±0.2s of the pulse to
keep the lock, and 142ms is comfortably inside that.</li>
<li><code>prefer</code> makes PPS the source chrony actually steers to; the pool servers
and NMEA become agreement/sanity checks rather than the trusted source.</li>
<li><code>precision 1e-7</code> (~100ns) tells chrony this is a high-quality source, so it
gets weighted well above NMEA and the pool.</li>
</ul>
<p>Restart chrony. In <code>chronyc sources -v</code>, <code>PPS</code> should now be the selected
source (<code>#*</code>) with an estimated error in the hundreds of nanoseconds, <code>NMEA</code>
reachable but not combined (<code>#-</code>, it&rsquo;s just supplying the lock), and the pool
servers as agreement checks (<code>^-</code>). <code>journalctl -u chrony</code> logs <code>Selected source PPS</code>.</p>
<p><strong>Simpler alternative:</strong> once PPS is wired, gpsd&rsquo;s <code>SOCK</code> integration from
gotcha #2 finally has a PPS source to report, so <code>refclock SOCK /run/chrony.ttyAMA0.sock refid GPS precision 1e-7 prefer</code> works too, letting
gpsd handle the PPS plumbing. I kept the native <code>refclock PPS</code> line instead —
it&rsquo;s explicit about the NMEA lock, doesn&rsquo;t depend on gpsd&rsquo;s internal PPS
handling, and sidesteps needing gpsd to pick the correct device. SOCK is one
line if you&rsquo;d rather use it.</p>
<h2 id="where-accuracy-ended-up">Where accuracy ended up</h2>
<table>
	<thead>
			<tr>
					<th>Setup</th>
					<th>Selected source</th>
					<th>Estimated error</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>NMEA only, uncalibrated</td>
					<td>Pool servers (GPS untrusted)</td>
					<td>GPS offset unbounded/unknown</td>
			</tr>
			<tr>
					<td>NMEA only, calibrated <code>offset</code></td>
					<td>GPS (NMEA)</td>
					<td>~±50ms (agreement to 1-2ms with pool, but only as good as the +142ms latency staying stable)</td>
			</tr>
			<tr>
					<td>NMEA + PPS</td>
					<td>GPS (PPS)</td>
					<td>~±350ns</td>
			</tr>
	</tbody>
</table>
<p>The calibrated NMEA constant went from &ldquo;the thing being trusted&rdquo; to a wide
safety margin that only has to hold the PPS second-lock — a much easier job
than being the actual accuracy source.</p>
<h2 id="verifying-its-all-working">Verifying it&rsquo;s all working</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gpspipe -w -n <span class="m">20</span>        <span class="c1"># raw gpsd JSON: satellites, fix mode, position</span>
</span></span><span class="line"><span class="cl">cgps -s                  <span class="c1"># live satellite dashboard</span>
</span></span><span class="line"><span class="cl">ppstest /dev/pps-gps     <span class="c1"># confirm PPS pulses (one assert/sec) reach the kernel</span>
</span></span><span class="line"><span class="cl">chronyc sources -v       <span class="c1"># confirm PPS is selected (#*), NMEA reachable (#-)</span>
</span></span><span class="line"><span class="cl">chronyc sourcestats
</span></span></code></pre></div><p>If <code>chronyc sources</code> shows <code>#*</code> next to <code>PPS</code>, the whole chain — antenna,
module, kernel PPS, chrony — is doing its job, and every other device on the
LAN syncing against this Pi is getting time that isn&rsquo;t waiting on an internet
connection to be right.</p>
]]></content:encoded></item></channel></rss>