The Discovery
During the PBS deployment, we ran a quick sanity check on the fresh Debian 12 LXC:
$ timedatectl
Local time: Tue 2026-03-10 21:14:03 CST
Universal time: Wed 2026-03-11 03:14:03 UTC
RTC time: Wed 2026-03-11 03:14:03
Time zone: America/Chicago (CST, -0600)
System clock synchronized: no
NTP service: active
RTC in local time: no
System clock synchronized: no. The NTP service was running, but chrony was not
actually synchronizing with anything. We dug in:
$ chronyc tracking
Reference ID : 00000000 ()
Stratum : 0
Ref time (UTC) : Thu Jan 01 00:00:00 1970
System time : 0.000000000 seconds slow of NTP time
Last offset : +0.000000000 seconds
RMS offset : 0.000000000 seconds
Frequency : 0.000 ppm
Residual freq : +0.000 ppm
Skew : 0.000 ppm
Root delay : 1.000000000 seconds
Root dispersion : 1.000000000 seconds
Leap status : Not synchronised
Reference ID 00000000. Ref time: January 1, 1970. Chrony was up, not
synchronized to anything, and had never been.
$ chronyc sources
MS Name/IP address Stratum Poll Reach LastRx Last sample
====================================================================
^? nts.netnod.se 0 6 0 - +0ns[ +0ns] +/- 0ns
^? time.cloudflare.com 0 6 0 - +0ns[ +0ns] +/- 0ns
Reach: 0 on every source. Not a single NTP packet had been received.
Root Cause: NTS Inside LXC Containers
The default chrony configuration on Ubuntu and Debian (since approximately 2022) uses NTS — Network Time Security. NTS is authenticated NTP: it uses TLS over TCP port 4460 to bootstrap trust, then exchanges time over UDP 123.
The problem is the TCP 4460 component. Proxmox LXC containers run in a
constrained network namespace. The default Proxmox LXC configuration does not
allow TCP 4460 through the container network stack in a way that works with NTS’s
TLS handshake requirements. The containers can route UDP 123 fine. The NTS
bootstrap over TCP 4460 silently fails. Chrony logs no errors — it just never
receives any responses from the NTS servers, and Reach stays at 0 forever.
The config responsible for this is the Ubuntu/Debian default:
# /etc/chrony/sources.d/ubuntu-ntp-pools.sources (the problem)
pool ntp.ubuntu.com iburst maxsources 4 nts
pool 0.ubuntu.pool.ntp.org iburst maxsources 1 nts
pool 1.ubuntu.pool.ntp.org iburst maxsources 1 nts
pool 2.ubuntu.pool.ntp.org iburst maxsources 2 nts
The nts keyword at the end of each pool line tells chrony to use NTS
authentication. Remove it, and chrony falls back to standard UDP NTP, which
works fine.
Why Chrony Over ntpd
We use chrony across the fleet rather than the classic ntpd daemon for two reasons:
Suspend/resume handling. Proxmox VMs and LXCs can be paused, migrated, or have their clocks jump when the host system is under load. ntpd panics on large clock offsets — it has a hardcoded limit (typically 1000 seconds) beyond which it refuses to step the clock and exits. Chrony handles arbitrarily large offsets gracefully by stepping the clock on startup and slewing from there.
Fast initial convergence. With iburst, chrony sends a burst of 8 packets
immediately on start, converging to synchronized time in 2–4 seconds instead of
ntpd’s typical 5-minute wait through the initial polling interval ladder.
The Fix
Two tasks in the base role, ordered carefully:
- name: Disable default NTS sources if present
ansible.builtin.file:
path: /etc/chrony/sources.d/ubuntu-ntp-pools.sources
state: absent
notify: Restart chrony
failed_when: false
- name: Deploy chrony NTP sources (standard pools, no NTS)
ansible.builtin.template:
src: chrony-ntp-pools.sources.j2
dest: /etc/chrony/sources.d/ntp-pools.sources
owner: root
group: root
mode: "0644"
notify: Restart chrony
failed_when: false
The template is deliberately minimal:
# /etc/chrony/sources.d/ntp-pools.sources — managed by Ansible
# Uses standard NTP (UDP 123) — NTS (TCP 4460) avoided for LXC compatibility
pool 0.pool.ntp.org iburst maxsources 2
pool 1.pool.ntp.org iburst maxsources 2
pool 2.pool.ntp.org iburst maxsources 2
pool 3.pool.ntp.org iburst maxsources 2
Four pools, two sources each, iburst on all of them. No nts keyword. This
gives us up to 8 simultaneous NTP sources, 2 from each pool subdomain, which
provides clock quality comparable to a well-configured enterprise NTP setup.
We also added chrony to common_packages in group_vars/all/vars.yml to
ensure it is installed on every managed host:
common_packages:
- curl
- wget
- htop
- vim
- tmux
- git
- unzip
- sudo
- chrony
Task Ordering Matters
The remove-then-deploy ordering is deliberate. If we deployed our config first
and removed the NTS config second, there would be a window where both config
files existed simultaneously in sources.d/. Chrony processes all .sources
files in that directory. During that window, chrony would have NTS sources
and our plain sources active, and might attempt NTS connections that fail
before falling back to our pools.
Removing the NTS config first means there is never a moment with conflicting sources. The handler restarts chrony once, after both file operations complete, so the restart happens with only our clean config in place.
The failed_when: false on both tasks handles two edge cases:
- The NTS sources file may not exist on hosts where chrony was freshly installed
from our
common_packageslist (sincechronygets installed before the distro default sources are configured) - The
sources.d/directory may not exist on hosts without chrony installed yet
Deployment
We ran common.yml against the full fleet:
ansible-playbook playbooks/common.yml
Nine hosts reached: dev, pve, docker-host, caddy, n8n, db, influxdb,
openclaw, pbs.
Three of them — caddy, docker-host, and pve — had the Ubuntu NTS sources file
present and showed changed on the removal task. All three restarted chrony.
The remaining six either had chrony freshly installed (and no NTS sources) or
were already on our config from a previous run.
After the play completed, we spot-checked three hosts:
# caddy (DMZ VM — was NTS)
$ chronyc tracking | grep 'Reference ID\|Stratum\|synchronized'
Reference ID : 83EF7380 (ntp1.snet.net)
Stratum : 2
System clock synchronized: yes
# pbs (fresh LXC — was broken)
$ chronyc tracking | grep 'Reference ID\|Stratum\|synchronized'
Reference ID : C0A87B01 (10.0.0.1)
Stratum : 3
System clock synchronized: yes
# openclaw (VM — was working)
$ chronyc tracking | grep 'Reference ID\|Stratum\|synchronized'
Reference ID : D8EF2300 (time.cloudflare.com)
Stratum : 3
System clock synchronized: yes
All three synchronized. Reference IDs resolving to real NTP servers. Stratum 2 or 3, which is correct for a homelab without a local stratum 1 source.
Why This Matters
Time synchronization is invisible infrastructure. Until it breaks. The things that go wrong when clocks drift:
- TLS certificate validation fails. Certificates have
notBeforeandnotAfterfields. A clock 5 minutes ahead or behind can cause cert validation errors that look like cert issuance problems. - Log correlation becomes impossible. When correlating events across
caddy,docker-host, andpbs, timestamps need to agree. A 30-second drift makes distributed log analysis useless. - PBS backup jobs show wrong timestamps. PBS records backup times in the datastore index. A drifted clock means the “most recent backup” is not actually the most recent.
- OAuth and TOTP tokens become invalid. Time-based tokens have a tolerance window of typically 30 seconds. Drift beyond that and tokens start being rejected.
None of these failures have obvious error messages. “Your clock is wrong” is rarely the first thing you check.
Lessons Learned
1. NTS is a good idea that breaks in containers. Ubuntu and Debian defaulting
to NTS is correct behavior for physical servers and VMs with full network access.
For LXC containers on Proxmox, it silently fails. The failure mode — Reach: 0,
System clock synchronized: no — does not mention NTS or TCP 4460 anywhere.
2. sources.d/ is a directory, not a single file. Chrony reads all .sources
files in the directory. When deploying a replacement config, you must remove the
old file — putting a new one next to it does not replace it.
3. failed_when: false is not sloppiness. We use it here specifically because
the tasks need to be idempotent across hosts that may or may not have the file
being removed. The alternative — checking if the file exists before attempting
removal — is more lines of YAML for the same result.
4. Check time sync on every fresh container. Add it to the commissioning
checklist. timedatectl takes two seconds and catches this class of problem
before it causes something subtle.