My Mondaine SBB wall clock looks great on the wall, but it has a fundamental flaw: it treats NTP time as UTC and applies a fixed offset. It does have a manual DST toggle, but every time change I’d need to take it off the wall, connect to its Wi-Fi, and flip the setting. Cumbersome enough that I prefer spending a few hours implementing a fake NTP server to compensate :)

So I built ntp-dst: a fake NTP server that compensates for DST transitions, running as a Nanos unikernel on Oracle Cloud’s free tier. The compensated server is available at dst-ntp.n8r.ch—point your clock there and forget about DST.

Both this article and the code were heavily aided by GLM-5.1 + OpenCode (though I still manually proofread and modify the blog post!).

The Problem

The clock’s behavior is simple:

Season Correct offset Clock offset What the clock shows
CET (winter) UTC+1 +1h Correct
CEST (summer) UTC+2 +1h 1 hour behind

The Solution

Instead of fixing the clock, I fix the time it receives. The server gets the correct local time via Go’s timezone data, then serves a shifted UTC so the clock’s fixed offset still yields the right time:

zurich, err := time.LoadLocation("Europe/Zurich")
whatTheClockShouldShow = time.Now().In(zurich)
servedTime = whatTheClockShouldShow - clockOffset

For a clock configured as UTC+1:

Season Correct offset Clock offset DST correction NTP serves Clock displays
CET UTC+1 +1h 0 UTC+0 UTC+0+1h = correct
CEST UTC+2 +1h +1h UTC+1h UTC+1h+1h = correct

The server detects CET↔CEST transitions using Go’s time.LoadLocation("Europe/Zurich") and applies the correction immediately. Since the clock only syncs once per day (at midnight UTC), the new offset takes effect on the next sync.

Implementation

The whole thing is a single Go package—no sub-packages, no internal directories. Five files:

  • main.go — CLI flags, server startup, and a -query mode for testing
  • scheduler.go — polls every 10 seconds, detects CET↔CEST transitions, sets the correction
  • source.go — serves the faked time: UTC + NTP offset + DST correction + skew
  • server.go — UDP NTP server, responds to queries with the faked time
  • ntp.go — 48-byte SNTP packet marshal/unmarshal

The -query flag is handy for testing without an external NTP client:

./ntp-dst -query -query-host dst-ntp.n8r.ch -query-port 123
Time:      2026-05-23 10:15:00 UTC
Offset:    59m59.959s
Stratum:   2
Reference: 0x474F4C44

Running as a Unikernel

I’d known about unikernels but never actually tried one. This project was the perfect excuse: a single-purpose NTP server that needs to run 24/7 with zero maintenance. A unikernel is just your binary and a minimal kernel—no SSH, no shell, no package manager. The main draw is that there’s no OS to maintain. No security patches, no upgrade cycles, no drift.

Some nice side effects:

  • Fast boot — milliseconds, not seconds
  • Minimal resources — 128 MB RAM on OCI’s free tier is plenty
  • Tiny image — under 3 MB for the binary (timezone data embedded via time/tzdata import) and ~5.8 MB total as a qcow2 image including the Nanos kernel

I’m running this as a Nanos unikernel via OPS on OCI’s free tier.

Build and test locally

# Build the static binary
make build

# Test locally on a high port (no root needed)
./ntp-dst -port 1234

# Query it from another terminal
./ntp-dst -query -query-port 1234

Build the unikernel image

# x86_64
make build-unikernel
ops run -c ops.json ntp-dst

# ARM64 (Ampere A1 on OCI)
make build-unikernel-arm64
ops run -c ops.arm64.json ntp-dst-arm64

The ops.json config sets the CLI arguments, memory, and UDP ports:

{
  "Args": ["-port", "123", "-ntp", "ch.pool.ntp.org", "-clock-offset", "1h"],
  "CloudConfig": {
    "BucketName": "<your-bucket>",
    "BucketNamespace": "<your-namespace>",
    "Flavor": "VM.Standard.E2.1.Micro"
  },
  "RunConfig": {
    "Memory": "128M",
    "UDPPorts": ["123"]
  },
  "ManifestPassthrough": {
    "exec_wait_for_ip4_secs": "5"
  }
}

Note: no CA certificates, no shared libraries, no /usr/share/zoneinfo. CGO_ENABLED=0 gives us a pure-Go resolver, and the _ "time/tzdata" import embeds the timezone database directly in the binary. The only thing the unikernel needs from the outside is UDP port 123 and DNS resolution—both of which Nanos handles natively.

Deploy to OCI

# x86_64
ops image create ntp-dst -t oci -c ops.json
ops instance create ntp-dst -t oci -c ops.json

# ARM64
ops image create ntp-dst-arm64 -t oci -c ops.arm64.json --arch=arm64
ops instance create ntp-dst-arm64 -t oci -c ops.arm64.json

Open UDP 123 in your OCI security list for the instance’s VCN, and you’re done.

DNS Configuration

The Mondaine clock has no setting for custom NTP servers—it hardcodes time.pool.aliyun.com as primary and pool.ntp.org as fallback. Two ways to redirect its traffic:

  • DNS override: create CNAME entries for both domains pointing to dst-ntp.n8r.ch, so the clock resolves them to the compensated server without knowing it.
  • DNAT: on your router, DNAT all outgoing UDP 123 traffic from the clock’s IP to dst-ntp.n8r.ch. No DNS tricks needed—every NTP packet gets transparently redirected.

Configure the clock’s UTC offset to +1h (CET/winter time). The server handles the rest—the clock now shows the correct time year-round.

Source

The code is on GitHub: clementnuss/ntp-dst.