During my holiday break, I built a Stratum 1 NTP server in my home lab with a Raspberry Pi 5. Ordinarily, we get our time from internet-based NTP servers which is usually Stratum 2 or 3. With Stratum 1, I can get significantly more accurate time directly from the Stratum 0 GPS satellites orbiting above me. Aboard these satellites are atomic clocks which are the most accurate timekeeping devices humanity has ever created. Therefore, this makes the Raspberry Pi 5 a Stratum 1 NTP server (with a caveat explained below).
First off, time tracking that's this precise and reliable is overkill for my needs, but it's a fun project and a great learning experience. For this project I used a Raspberry Pi 5 (which I already had), a GPS module, GPIO wires, and a better antenna. I followed some guidance from a colleague, but much of this has been based off of a post on NetworkProfile.org. However, what I've put together below has some differences that I felt was worth documenting. For instance, I'm using a newer Raspberry Pi 5 and AlmaLinux 10.1 as opposed to a Raspberry Pi 3 and RaspberryPiOS (Debian). As such, some of the instructions, commands, and insights are different, but the original article is well worth the read.
In terms of hardware, I bought this GPS module receiver, this set of ribbon cables, and GPS antenna. However, there are a ton of different modules and antennas available that should work. For everything, I spend about $25.
During this project, I learned that although micro-USB is an option on the GPS module, it will not be anywhere near as accurate as going through the GPIO pins on a Raspberry Pi because there is a pin on the module for PPS. PPS stands for Pulse-Per-Second and is a hardware timing signal output used by many GPS receivers that provide an extremely accurate “tick” every second - accurate to the millisecond (and even nanoseconds). Even though the GPS module can be connected over USB and can deliver NMEA sentences and GPS time, USB cannot carry PPS timing as it requires a dedicated electrical pin. So if we want a true Stratum-1 NTP server, we must use something like a Raspberry Pi that has GPIO pins to carry PPS and we need a time synchronization service like chrony to discipline the clock from this signal.
The first step is to solder the header to the GPS module. I hadn't soldered anything in quite a while, so I picked up a soldering gun for about $10. I'll be up front, I shorted the PPS and TX pins on the GPS module with a little too much solder which caused me about 2 hours of troubleshooting before I realized that was the reason why I wasn't picking up any satellites.
In regards to connections, the RPI5 GPIO pins 4, 6, 8, 10, and 12, need to be connected to VCC, GND, RXD, TXD, and PPS on the module respectively. Below is a diagram of the RPI5 GPIO pin diagram and a screenshot from the module product page.
| RPI5 Pin | GPS Pin |
|---|---|
| 4 - SV | VCC |
| 6 - GND | GND |
| 8 - GPIO 14 TXD | RXD |
| 10 - GPIO 15 RXD | TXD |
| 12 - GPIO 18 - CLK/PCM | PPS |
Below is what my RPI5 looks like with the GPS module attached. You'll notice that I mounted the GPS module to a Deskpi KL-P24 board. I attached this to my RPI5 because 1) I already had two of them that were included when I bought my minirack, 2) it's cleaner in that all cables run in the same direction behind the RPI5, and 3) I was able to easily mount the GPS module to it. In terms of the excessive wire length, I'll shorten them another day.
On AlmaLinux 10.1, install the following packages: sudo dnf install gpsd gpsd-clients tcpdump. The original guide also suggested installing chrony and jq but they were already installed, and pps-tools are not currently in the AlmaLinux 10.1 repos or EPEL at this time (however, pps-tools is not needed).
To enable the serial port on AlmaLinux, run sudo vim /boot/cmdline.txt and remove the line console=serial0,115200 so it will not interfere with GPS data and PPS timing. Below is what mine looks like. Note that I have pcie_aspm=off which I only added to correct some NVMe issues and it's not needed for this project.
console=tty1 root=PARTUUID=f773372a-b398-4536-abc4-b86776f6e1c9 rootfstype=ext4 rootwait pcie_aspm=off
Now we configure the GPIO pins for PPS with sudo vim /boot/config.txt and add the following lines:
# The following 3 lines are for GPS PPS signals
dtoverlay=pps-gpio,gpiopin=18
enable_uart=1
init_uart_baud=57600
I already had enable_uart=1 present in mine, just ensure it exists in the config.
Now we can add the PPS module: sudo bash -c "echo 'pps-gpio' >> /etc/modules".
Create the file /etc/default/gpsd and added these lines:
START_DAEMON="true"
USBAUTO="true"
DEVICES="/dev/ttyAMA0 /dev/pps0"
GPSD_OPTIONS="-n"
In order to make gpsd start at boot, run this command: sudo ln -s /lib/systemd/system/gpsd.service /etc/systemd/system/multi-user.target.wants/. Now reboot the machine.
If everything went smoothly, we should start seeing the LED on the GPS module blinking every one second.
From here we can see if things are working correctly by running cgps -s and gpsmon. If satellites are being picked up, they will be displayed in this output. If we don't see any after around 15 minutes, the configuration is wrong or there's an issue with the hardware (such as the antenna not being a good spot or you shorted the connections as was my case). You can also run gpspipe -w | jq ".uSat| select( . != null )" which will provide the value of how many satellites are being picked up, updating every second. It's important to note that at least 4 satellites are needed to get accurate time.
For reference, below is my output from cgps -s. Note that my GPS coordinates, Grid Square, and ECEF coordinates are obfuscated with #'s.
┌───────────────────────────────────────────┐┌────────────────Seen 19/Used 11──┐
│ Time 2026-01-01T03:00:48.000Z (18)││GNSS S PRN Elev Azim SNR Use│
│ Latitude 45.6####### N ││GP 5 5 47.0 136.0 43.0 Y │
│ Longitude 122.4####### W ││GP 11 11 43.0 54.0 39.0 Y │
│ Alt (HAE, MSL) 60.985, 83.138 m ││GP 12 12 40.0 161.0 29.0 Y │
│ Speed 0.09 km/h ││GP 18 18 14.0 235.0 38.0 Y │
│ Track (true, var) 84.9, 15.0 deg ││GP 20 20 51.0 80.0 41.0 Y │
│ Climb -4.32 m/min ││GP 21 21 45.0 88.0 36.0 Y │
│ Status 3D FIX (12 secs) ││GP 25 25 66.0 217.0 36.0 Y │
│ Long Err (XDOP, EPX) 0.66, +/- 9.9 m ││GP 28 28 20.0 284.0 28.0 Y │
│ Lat Err (YDOP, EPY) 0.72, +/- 10.8 m ││GP 29 29 60.0 302.0 36.0 Y │
│ Alt Err (VDOP, EPV) 1.60, +/- 36.8 m ││GP 31 31 14.0 314.0 31.0 Y │
│ 2D Err (HDOP, CEP) 0.98, +/- 18.6 m ││QZ 2 194 7.0 295.0 26.0 Y │
│ 3D Err (PDOP, SEP) 1.87, +/- 35.5 m ││GP 6 6 2.0 54.0 0.0 N │
│ Time Err (TDOP) 1.01 ││GP 9 9 1.0 28.0 0.0 N │
│ Geo Err (GDOP) 2.13 ││GP 26 26 4.0 321.0 0.0 N │
│ Speed Err (EPS) +/- 77.8 km/h ││SB133 46 37.0 189.0 0.0 N │
│ Track Err (EPD) n/a ││SB135 48 37.0 183.0 0.0 N │
│ Time offset 0.145824081 s ││SB138 51 35.0 159.0 0.0 uN │
│ Grid Square ########## ││QZ 1 193 n/a 0.0 0.0 uN │
│ ECEF X, VX -#######.### m 0.050 m/s││QZ 5 197 n/a 0.0 0.0 uN │
│ ECEF Y, VY -#######.### m 0.030 m/s││ │
│ ECEF Z, VZ #######.### m -0.050 m/s││ │
│ ││ │
│ ││
Now that we've confirmed that the GPS module is working correctly, we need to configure Chrony. Run sudo vim /etc/chrony.conf and add these two lines:
refclock SHM 0 refid NMEA offset 0.000 precision 1e-3 poll 3 noselect
refclock PPS /dev/pps0 refid PPS lock NMEA poll 3
Then uncomment log measurements statistics tracking to enable logging. Restart chrony with sudo systemctyl restart chronyd.service.
In order to true up the clock, we need to see the "Est offset" number so we can revise our chrony configuration so that our clock is more accurate. Run sudo cat /var/log/chrony/statistics.log | sudo head -2; sudo cat /var/log/chrony/statistics.log | sudo grep NMEA and you'll see some data. After around 15 minutes, run it again. Copy and paste this data into a text file, save it, then import it into a spreadsheet and set the delimiter to spaces. The only column we're interested in is Column R (Est). I had to change the values to in this column from "Scientific" to "Number" and increase the decimal to the ten-thousandths place so that 4 values would show after the decimal. Take the average of this column - in my case, it's 0.1412. For more detailed instructions on working with the spreadsheet, I suggest the NetworkProfile.org blog post.
Open the chrony config with sudo vim /etc/chrony.conf and update the line refclock SHM 0 refid NMEA offset 0.000 precision 1e-3 poll 3 noselect with the offset value we just obtained. In my case, the line is now refclock SHM 0 refid NMEA offset 0.1412 precision 1e-3 poll 3 noselect. Also, comment out the line we uncommented earlier log measurements statistics tracking, then save and close. We can also remove the old log files with sudo rm /var/log/chrony/statistics.log. Finally, restart chrony: sudo systemctl restart chrony.
If the tuning we did earlier worked, then PPS will be deemed as the most accurate and will take over. We can take a look at chrony's source with the command watch -n 1 chronyc sources (you can also add the "-v" option for verbose to this command if you want more details on what each column means. The output looks something like this:
Every 1.0s: chronyc sources mercury: Wed Dec 31 19:31:58 2025
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
#? NMEA 0 3 377 7 +6670us[+6670us] +/- 1000us
#* PPS 0 3 377 6 +704ns[ +834ns] +/- 161ns
^- dns-e.ns4v.icu 2 6 177 25 +468us[ +468us] +/- 48ms
^- 159.203.82.102 3 6 177 24 -858us[ -857us] +/- 37ms
^- 45.12.52.138 3 6 177 24 -3299us[-3299us] +/- 42ms
^- 173.208.172.164 3 6 177 26 -2429us[-2429us] +/- 151ms
As you can see, in the far right column, PPS is showing 142ns, whereas the best networked-based NTP is 37ms (or 37,000,000ns!). This value is chrony's estimate of the uncertainty (error bound) of that time source. Obviously, the difference in accuracy is substantial.
The symbols in the front are chrony's selection and status markers that tell you which time sources are usable, trusted, and actually discipling the clock. #'s are local reference clocks, whereas ^'s are time sources over the network (most likely the internet). Of the second characters, * means the source is currently controlling the system clock, "?" means unusable/not selected, and "-" indicates the source is a candidate, but it's not selected. Here's a summary:
| Symbol | Meaning |
| ------ | ------------------------------------------- |
| `#` | Local reference clock (GPS/PPS) |
| `^` | Network time source |
| `*` | Selected (currently disciplining the clock) |
| `?` | Seen but unusable / not selectable |
| `-` | Valid candidate, but not chosen |
If satisfied with the accuracy, run sudo vim /etc/chrony.conf again and add "prefer" to the end of PPS line (refclock PPS /dev/pps0 refid PPS lock NMEA poll 3 prefer).
To allow clients to connect to chrony, add the line: allow 0.0.0.0/0, or whatever your home network is (ie the common 192.168.1.0/24). We also we must add a line which changes who is allowed to set the system clock and when. All we need to do is add the line manual to chrony.conf. It enables runtime support for the settime command, and at the same time disables chrony’s automatic stepping behavior. Chrony will not step automatically. A clock step can occur only if you explicitly request it.
We also will add the line local stratum 1 which tells connected clients that even if there is no internet connection, this is a Stratum 1 time server.
While editing the chrony config, it's also not a bad idea to review and even add NTP servers to your chrony file to verify your clock is accurate, or as a fallback option. By default, the only one in AlmaLinux's chrony file is pool 2.almalinux.pool.ntp.org iburst. I also added server time.nist.gov and server tick.usno.navy.mil. Once you're satisfied, save and close, then restart chrony sudo systemctl restart chrony.
One final step on AlmaLinux is to open the firewall. Running sudo firewall-cmd --add-service=ntp --permanent and sudo firewall-cmd --reload will allow NTP connections to be made.
It's fairly simple to add the new NTP server as a source. On Linux machines, we first need to determine what service we're running, which can be done by running systemctl status systemd-timesyncd and systemctl status chronyd. In my case, I see chronyd is active so I'll run sudo vim /etc/chrony.conf and add the line server 10.0.0.40 iburst which is the IP address of the NTP server I just built. Run sudo systemctl restart chronyd and verify it's using the server with chronyc sources. As you can see below, 10.0.0.40 has been selected and identified as a Stratum 1 server.
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^* 10.0.0.40 1 6 377 42 +25us[ +33us] +/- 138us
^- 172-232-15-202.ip.linode> 3 10 377 374 +3078us[+3085us] +/- 79ms
^- fairy0.mattnordhoffdns.n> 3 10 377 378 +72us[ +79us] +/- 72ms
^- 198-12-95-197-host.coloc> 3 10 377 17 -1027us[-1027us] +/- 56ms
^- 23.186.168.132 2 10 367 193 +3436us[+3451us] +/- 25ms
Thanks for reading. Feel free to send comments, questions, or recommendations to hey@chuck.is.