This guide describes the deployment of a dedicated Stratum-1 NTP server based on:
* Raspberry Pi 4 Model B * Raspberry Pi OS Lite (Debian Trixie) * Uputronics GPS+RTC Expansion Board Rev. 6.4 * u-blox NEO-M8N GNSS receiver * RV3028 RTC * PPS synchronization via GPIO18 * Chrony * GPSD
The resulting appliance provides:
* Stratum-1 NTP service * PPS-disciplined system clock * RTC-backed startup time * IPv4 and IPv6 support * Fully headless operation
| Component | Description |
|---|---|
| SBC | Raspberry Pi 4 Model B |
| RAM | 1 GB |
| Storage | 16 GB microSD |
| GNSS Board | Uputronics GPS+RTC Expansion Board Rev. 6.4 - including: |
| - GNSS Receiver | u-blox NEO-M8N |
| - RTC | Micro Crystal RV3028-C7 |
| - PPS | GPIO18 |
Uputronics GPS+RTC Rev. 6.4 DOES NOT use a DS3231 RTC.
The board uses a Micro Crystal RV3028-C7 RTC at I²C address 0x52.
Many older online guides still reference DS3231 (0x68) and are not applicable to Revision 6.4.
Install:
* Raspberry Pi OS Lite * 64-bit version * Debian Trixie based release
Add network configuration needed and make it accessible via SSH.
Configure local timezone:
sudo timedatectl set-timezone Europe/Berlin
Verify:
timedatectl
Expected:
Time zone: Europe/Berlin (CEST, +0200) RTC in local TZ: no
RTC should remain in UTC.
Edit:
/boot/firmware/config.txt
Recommended configuration:
enable_uart=1 dtparam=i2c_arm=on dtparam=rtc=off dtoverlay=i2c-rtc,rv3028,addr=0x52 dtoverlay=pps-gpio,gpiopin=18 dtoverlay=disable-wifi dtoverlay=disable-bt gpu_mem=16 camera_auto_detect=0 display_auto_detect=0 hdmi_blanking=2
| Setting | Purpose |
|---|---|
| enable_uart=1 | Enables GPS serial interface |
| dtparam=i2c_arm=on | Enables I²C bus |
| dtoverlay=i2c-rtc,rv3028 | Loads RTC driver |
| dtoverlay=pps-gpio | Enables PPS on GPIO18 |
| disable-wifi | Disables unused Wi-Fi |
| disable-bt | Disables unused Bluetooth |
| gpu_mem=16 | Minimizes GPU memory allocation |
| hdmi_blanking=2 | Disables HDMI output |
sudo apt update sudo apt install chrony gpsd gpsd-clients pps-tools i2c-tools minicom util-linux-extra
Scan the I²C bus:
sudo i2cdetect -y 1
Expected:
40: -- -- 42 ... 50: -- -- UU ...
Meaning:
| Address | Device |
|---|---|
| 0x42 | u-blox NEO-M8N |
| 0x52 | RV3028 RTC |
Verify RTC device:
ls -l /dev/rtc*
Expected:
/dev/rtc /dev/rtc0
Read RTC:
sudo hwclock -r
Check PPS device:
ls -l /dev/pps0
Expected:
/dev/pps0
Run PPS test:
sudo ppstest /dev/pps0
Expected:
source 0 - assert ... source 0 - assert ... source 0 - assert ...
One PPS pulse per second should be visible.
Open serial console:
sudo minicom -b 115200 -o -D /dev/serial0
Expected NMEA output:
$GNRMC $GNGGA $GNGSA
Example:
$GNGGA,174143.00,... $GNRMC,174143.00,...
A valid GNSS fix should report:
* 3D FIX * Latitude / Longitude * UTC Time * Multiple satellites
Typical values observed:
Satellites visible: 24 Satellites used: 14
gpsd autodetection did not reliably establish a 3D fix.
Explicitly configuring GPSD for 115200 baud resolved the issue immediately.
Edit:
/etc/default/gpsd
Configuration:
START_DAEMON="true" USBAUTO="false" DEVICES="/dev/serial0" GPSD_OPTIONS="-n -s 115200" OPTIONS=""
| Option | Purpose |
|---|---|
| -n | Start reading GPS immediately |
| -s 115200 | Force receiver baud rate |
| /dev/serial0 | Stable Raspberry Pi serial alias |
sudo systemctl enable gpsd sudo systemctl restart gpsd
Verify:
sudo systemctl status gpsd --no-pager
Expected:
gpsd -n -s 115200 /dev/serial0
cgps -s
Expected:
3D FIX
and a valid:
* Position * UTC time * Satellite list
Backup original configuration:
sudo cp /etc/chrony/chrony.conf \ /etc/chrony/chrony.conf.orig
Edit:
/etc/chrony/chrony.conf
Example configuration:
driftfile /var/lib/chrony/chrony.drift logdir /var/log/chrony refclock SHM 0 refid GPS precision 1e-1 offset 0.0 delay 0.2 refclock PPS /dev/pps0 lock GPS refid PPS prefer server ntp1.example.net iburst server ntp2.example.net iburst server ntp3.example.net iburst allow 192.168.0.0/16 allow fd00::/8 local stratum 10 makestep 1.0 3 rtcsync leapsectz right/UTC
sudo systemctl restart chrony
Verify:
sudo systemctl status chrony --no-pager
Display active sources:
chronyc sources -v
Expected:
#- GPS #* PPS
Meaning:
rtc-gps#~: sudo chronyc sources -v .-- Source mode '^' = server, '=' = peer, '#' = local clock. / .- Source state '*' = current best, '+' = combined, '-' = not combined, | / 'x' = may be in error, '~' = too variable, '?' = unusable. || .- xxxx [ yyyy ] +/- zzzz || Reachability register (octal) -. | xxxx = adjusted offset, || Log2(Polling interval) --. | | yyyy = measured offset, || \ | | zzzz = estimated error. || | | \ MS Name/IP address Stratum Poll Reach LastRx Last sample =============================================================================== #- GPS 0 4 377 8 +51ms[ +51ms] +/- 200ms #* PPS 0 4 377 8 +689ns[ +706ns] +/- 299ns
| Symbol | Description | |
|---|---|---|
| #* PPS | Active Stratum-1 source | |
| #- GPS | GPS source used to lock PPS | |
| - | External NTP server |
chronyc tracking
Expected:
Reference ID : PPS Stratum : 1 Leap status : Normal
ss -lunp | grep :123
Expected:
udp 0.0.0.0:123 udp [::]:123
Example systemd-resolved configuration:
Create:
/etc/systemd/resolved.conf.d/internal.conf
Contents:
[Resolve] DNS=192.0.2.53 DNS=2001:db8::53 DNS=192.0.2.54 DNS=2001:db8::54 FallbackDNS=1.1.1.1 FallbackDNS=2606:4700:4700::1111 Domains=~example.lan
Apply:
sudo systemctl restart systemd-resolved
Verify:
resolvectl status
Once Chrony has synchronized to PPS:
sudo hwclock -w
Verify:
sudo hwclock -r
Measured on:
* Raspberry Pi 4 Model B (1 GB) * Raspberry Pi OS Lite (Debian Trixie) * Uputronics GPS+RTC Expansion Board Rev. 6.4
Observed values after synchronization:
Stratum : 1 Reference : PPS Satellites Seen : 24 Satellites Used : 14
Chrony source status:
#* PPS +/- 152ns #- GPS +/- 200ms
Tracking example:
System time : 0.000000020 seconds slow Root delay : 0.000000001 seconds Root dispersion : 0.000018754 seconds
After a reboot verify:
timedatectl ls -l /dev/rtc* sudo hwclock -r sudo systemctl status gpsd chronyc tracking chronyc sources -v
Expected:
* RTC available * GPSD running * PPS active * Stratum 1 restored automatically
Verify:
sudo i2cdetect -y 1
Expected:
42 UU
Remember:
Rev. 6.4 uses RV3028 at address 0x52. It does NOT use a DS3231.
Verify:
ls -l /dev/pps0 sudo ppstest /dev/pps0
Check:
dtoverlay=pps-gpio,gpiopin=18
Verify baud rate:
GPSD_OPTIONS="-n -s 115200"
Check GPS reception:
cgps -s
Verify:
chronyc sources -v
Ensure:
refclock PPS /dev/pps0 lock GPS refid PPS prefer
is present in chrony.conf.
e.g.: /usr/lib/check_mk_agent/local/ntp_chrony (make it executable: chmod +x)
#!/bin/sh PATH=/usr/sbin:/usr/bin:/sbin:/bin SERVICE_NAME="chrony" # chrony service if systemctl is-active --quiet chrony || systemctl is-active --quiet chronyd; then echo "0 chrony_service - Chrony service is running" else echo "2 chrony_service - Chrony service is NOT running" exit 0 fi TRACKING="$(chronyc tracking 2>/dev/null)" SOURCES="$(chronyc sources -v 2>/dev/null)" if [ -z "$TRACKING" ]; then echo "2 chrony_status - chronyc tracking failed" exit 0 fi STRATUM="$(echo "$TRACKING" | awk -F: '/^Stratum/ {gsub(/ /,"",$2); print $2}')" REFID="$(echo "$TRACKING" | awk -F: '/^Reference ID/ {gsub(/^ /,"",$2); print $2}')" LEAP="$(echo "$TRACKING" | awk -F: '/^Leap status/ {gsub(/^ /,"",$2); print $2}')" # Stratum if [ -z "$STRATUM" ]; then echo "2 chrony_stratum - Could not determine stratum" elif [ "$STRATUM" -le 2 ]; then echo "0 chrony_stratum stratum=$STRATUM Stratum $STRATUM, reference: $REFID" elif [ "$STRATUM" -le 4 ]; then echo "1 chrony_stratum stratum=$STRATUM Stratum $STRATUM, reference: $REFID" else echo "2 chrony_stratum stratum=$STRATUM Stratum $STRATUM too high, reference: $REFID" fi # Leap status if [ "$LEAP" = "Normal" ]; then echo "0 chrony_leap - Leap status normal" else echo "2 chrony_leap - Leap status: $LEAP" fi # Active source ACTIVE_SOURCE="$(echo "$SOURCES" | awk '$1 ~ /\*/ {print $2; exit}')" if [ -n "$ACTIVE_SOURCE" ]; then echo "0 chrony_source - Active source: $ACTIVE_SOURCE" else echo "2 chrony_source - No active chrony source selected" fi # Peer/source count GOOD_SOURCES="$(echo "$SOURCES" | awk '$1 ~ /^[#^=][*+]/ {count++} END {print count+0}')" if [ "$GOOD_SOURCES" -ge 1 ]; then echo "0 chrony_sources good_sources=$GOOD_SOURCES Good usable sources: $GOOD_SOURCES" else echo "1 chrony_sources good_sources=0 No combined/selected sources found" fi # Stratum 1 specific checks if [ "$STRATUM" = "1" ]; then # PPS expected on Stratum 1 if /dev/pps0 exists if [ -e /dev/pps0 ]; then if timeout 3 ppstest /dev/pps0 2>/dev/null | grep -q "assert"; then echo "0 pps_status - PPS pulses detected" else echo "2 pps_status - PPS device exists but no pulses detected" fi else echo "1 pps_status - Stratum 1 but /dev/pps0 not found" fi # GPS fix via gpspipe if command -v gpspipe >/dev/null 2>&1; then MODE="$(timeout 3 gpspipe -w -n 10 2>/dev/null | awk -F'"mode":' '/"class":"TPV"/ {split($2,a,","); print a[1]; exit}')" case "$MODE" in 3) echo "0 gps_fix - GPS 3D fix" ;; 2) echo "1 gps_fix - GPS 2D fix only" ;; 1) echo "2 gps_fix - GPS no fix" ;; *) echo "1 gps_fix - GPS fix status unknown" ;; esac else echo "1 gps_fix - gpspipe not installed" fi # PPS should be active source echo "$REFID" | grep -q "PPS" if [ $? -eq 0 ]; then echo "0 chrony_pps_source - PPS is active reference" else echo "2 chrony_pps_source - Stratum 1 but active reference is not PPS: $REFID" fi fi
The completed appliance provides:
* Stratum-1 NTP service * PPS-disciplined system clock * GNSS time source * RTC-backed startup time * IPv4 support * IPv6 support * Headless operation * Minimal resource usage
The system automatically restores full Stratum-1 operation after reboot without manual intervention.