How-To

How to Configure NTP on CentOS Stream and RHEL with Chrony

12 min read

An out-of-sync clock breaks more than you’d expect. TLS handshakes fail for no clear reason. Kerberos rejects tickets that should be valid. Log timestamps stop lining up across your fleet, and database replication throws errors that have nothing to do with the query itself. It looks like five different problems until you check the clock. Then it’s obviously one problem.

This guide covers installing, configuring, and verifying Chrony on CentOS Stream 9 and RHEL 8/9. Chrony replaced ntpd as the default NTP client on RHEL-family systems. By the end, you’ll have chronyd running and synced against a public NTP pool or your own internal time server. You’ll also know exactly which chronyc commands to run when sync quietly stops working.

What is Chrony?

Chrony is the default NTP implementation on RHEL 8+, RHEL 9, and CentOS Stream 9. It’s two pieces. chronyd is a background daemon that checks the system clock against your configured time sources and nudges it back into line. chronyc is the command-line client; use it to check status and make live adjustments without restarting anything.

Chrony replaced ntpd starting with RHEL 8 because it handles real-world scenarios better: intermittent network connections, VMs that get paused and resumed, systems that aren’t online 24/7. It syncs faster after a cold start. It handles asymmetric network latency more gracefully. And it doesn’t need a stable, always-on connection to land on an accurate time. That makes it a better fit for cloud instances, laptops, and homelab VMs. The ntp package still floats around in some repos for legacy compatibility, but it’s not the supported path on modern RHEL-family systems. This guide skips it entirely.

Before You Begin

Make sure you have:

  • A server running CentOS Stream 9, RHEL 8, or RHEL 9 (or a compatible RHEL-family distro like Rocky Linux or AlmaLinux)
  • Root or sudo access via SSH or console
  • Outbound UDP port 123 access to reach NTP servers (public pools or internal)
  • Basic comfort with systemctl and editing text files with vi/nano
  • Firewalld installed and running (default on RHEL-family systems) if you’re opening ports
RequirementDetails
OSCentOS Stream 9 or RHEL 8/9 (tested on CentOS Stream 9)
Package managerdnf
Packagechrony
Servicechronyd
Config file/etc/chrony.conf
Firewall service namentp (UDP/123)

Step-by-Step Guide

Step 1: Check whether Chrony is already installed

Chrony ships by default on most RHEL 9 and CentOS Stream 9 minimal installs. Check before you install something you already have.

rpm -q chrony

If it’s installed, you’ll see something like:

chrony-4.5-1.el9.x86_64

If instead you get package chrony is not installed, move to the next step.

Step 2: Install Chrony

Use dnf, the package manager on RHEL 9 and CentOS Stream 9. RHEL 8 also supports dnf so yum is deprecated as the primary front end.

sudo dnf install chrony -y

Expected output ends with:

Installed:
chrony-4.5-1.el9.x86_64

Complete!

Step 3: Disable any conflicting time-sync service

Only one time-sync service should manage the clock at a time. systemd-timesyncd isn’t installed by default on RHEL-family systems. But if you’re migrating a box that had it enabled, which is common on Debian-derived images or manually configured servers, disable it first.

sudo systemctl disable --now systemd-timesyncd  || echo "systemd-timesyncd not present, skipping"

Run both chronyd and systemd-timesyncd at once and the clock jumps back and forth as each service tries to correct it independently. You’ll see erratic offsets in your logs that look like a failing hardware clock, but aren’t.

Step 4: Review the default /etc/chrony.conf

Open the config file to see what ships by default:

sudo vi /etc/chrony.conf

A stock CentOS Stream 9 config looks roughly like this:

# /etc/chrony.conf

# Use public servers from the pool.ntp.org project.
pool 2.centos.pool.ntp.org iburst

# Record the rate at which the system clock gains/loses time.
driftfile /var/lib/chrony/drift

# Allow the system clock to be stepped in the first three updates
# if its offset is larger than 1 second.
makestep 1.0 3

# Enable kernel synchronization of the real-time clock (RTC).
rtcsync

# Specify directory for log files.
logdir /var/log/chrony

Here’s what matters in that file:

  • pool points Chrony at a rotating set of NTP servers behind a single DNS name. 2.centos.pool.ntp.org resolves to multiple IPs, and Chrony picks the best-performing ones automatically. Use pool for public NTP pools. Use server (Step 6) for a single fixed host, like an internal time server.
  • iburst tells Chrony to send a burst of requests immediately instead of waiting for the normal polling interval. That speeds up the first sync a lot, which is useful right after boot or a fresh install.
  • makestep 1.0 3 lets Chrony instantly jump (step) the clock instead of slowly slewing it. This only applies for the first three updates, and only if the offset exceeds 1 second. After that, corrections happen gradually. That protects things sensitive to backward time jumps, like cron or timestamp-based logs.
  • rtcsync keeps the hardware real-time clock (RTC) in sync with the system clock via the kernel. That way time survives a reboot more accurately.
Terminal showing /etc/chrony.conf open in vi with the default pool, driftfile, makestep, and rtcsync directives visible

Step 5: Enable and start chronyd

sudo systemctl enable --now chronyd

The --now flag enables the service for boot and starts it immediately, saving you a separate systemctl start call. Confirm it’s running:

systemctl status chronyd

Expected output:

● chronyd.service – NTP client/server
Loaded: loaded (/usr/lib/systemd/system/chronyd.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-08-17 09:12:04 UTC; 5s ago
Docs: man:chronyd(8)
man:chrony.conf(5)
Main PID: 14832 (chronyd)
Tasks: 1 (limit: 10982)
Memory: 1.4M
CPU: 12ms
CGroup: /system.slice/chronyd.service
└─14832 /usr/sbin/chronyd -F 2

Look for Active: active (running) and enabled in the Loaded line; that confirms chronyd survives a reboot. If you want to understand how systemd manages services like this, unit files and dependency ordering are worth reading up on in general.

Step 6: Point at internal NTP servers instead of a public pool (optional)

If your organization runs its own NTP servers, replace the pool line with one or more server directives. This is common in larger networks; it reduces external dependencies and keeps time authority internal.

# /etc/chrony.conf

# Internal NTP servers instead of a public pool
server ntp1.internal.example.com iburst
server ntp2.internal.example.com iburst

driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
logdir /var/log/chrony

Use server (not pool) when pointing at specific, known hosts. Each server line counts as exactly one source. pool, by contrast, expects a DNS name that resolves to multiple hosts. Mixing a few server lines for internal time servers with a pool line as a fallback is also valid. It’s common in hybrid environments.

After editing, restart chronyd to apply the change:

sudo systemctl restart chronyd

Step 7: Verify sync status with chronyc tracking

This is the single most useful command for confirming Chrony is actually working:

chronyc tracking

Expected output on a healthy system:

Reference ID : C0A80105 (ntp1.internal.example.com)
Stratum : 3
Ref time (UTC) : Mon Aug 17 09:14:22 2026
System time : 0.000012309 seconds slow of NTP time
Last offset : -0.000008516 seconds
RMS offset : 0.000015234 seconds
Frequency : 12.834 ppm slow
Residual freq : +0.002 ppm
Skew : 0.089 ppm
Root delay : 0.012481 seconds
Root dispersion : 0.001834 seconds
Update interval : 64.2 seconds
Leap status : Normal

The fields that matter most:

  • Reference ID: the source Chrony is currently syncing against.
  • Stratum: distance from an authoritative time source. Stratum 1 is closest, an atomic clock, for example. Anything under stratum 10 is fine for normal use.
  • System time: how far off your clock currently is from NTP time. Anything under a few milliseconds is healthy.
  • Leap status: Normal confirms the system isn’t in an unusual leap-second state.

If Leap status says anything other than Normal, or System time shows an offset in whole seconds instead of milliseconds, something’s wrong. Check Step 8.

Step 8: Inspect individual sources with chronyc sources -v

chronyc tracking tells you the current state; chronyc sources -v tells you why.

chronyc sources -v

Expected output:

.– 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
===============================================================================
^* ntp1.internal.example.com 2 6 377 23 +0.000015 +0.000031 0.001234
^- ntp2.internal.example.com 2 6 377 45 -0.002104 -0.002089 0.003011
^x time.cloudflare.com 3 6 377 18 +1.204551 +1.204601 0.012044

Reading the columns:

  • ^, =, # in the first column show the source type (^ = server, = = peer, # = local reference clock).
  • * marks the source Chrony has selected as the current best reference, the one it’s actively using.
  • + means the source is a good candidate being combined into the final estimate.
  • - means the source is valid but not currently used in the combination.
  • x flags a falseticker, a source whose reported time doesn’t agree with your other sources. Chrony excludes falsetickers instead of trusting a possibly-broken clock.
  • ? means the source is unreachable or hasn’t been evaluated yet.
  • Reach is an octal reachability register. 377 means the last eight poll attempts all succeeded. A lower number like 1 or 3 means recent polls are failing, likely a network or firewall issue.

In the example above, time.cloudflare.com is marked x because it’s off by 1.2 seconds from the other two sources. That’s Chrony protecting your clock from a bad reading instead of blindly averaging it in, exactly the behavior you want from a time daemon.

Configuration

Common chrony.conf directives

DirectivePurposeExample
poolRotating set of NTP servers behind one DNS namepool pool.ntp.org iburst
serverSingle, specific NTP serverserver ntp1.internal.example.com iburst
iburstSpeeds up initial sync with a burst of requestsAppended to pool/server lines
allowPermits client IPs/subnets to query this box as an NTP serverallow 192.168.1.0/24
maxpollCaps the polling interval (some STIG hardening profiles require ≤16)maxpoll 16
makestepControls when Chrony is allowed to instantly step (vs. slew) the clockmakestep 1.0 3

Turning your server into an internal NTP source

If you want other machines on your network to sync against this server instead of the public internet, add an allow line for your local subnet:

# /etc/chrony.conf
allow 192.168.1.0/24

Restart chronyd, then confirm clients are connecting:

sudo firewall-cmd --permanent --add-service=ntp
sudo firewall-cmd --reload
sudo systemctl restart chronyd
chronyc clients

Expected output once clients start polling:

Hostname NTP Drop Int IntL Last Cmd Drop Int Last
===============================================================================
192.168.1.50 12 0 6 – 23 0 0 –
192.168.1.51 8 0 6 – 41 0 0 –

Firewall: opening UDP port 123

NTP uses UDP port 123. If clients can’t reach this server, or this server can’t reach its upstream sources, check firewalld first. This is the most commonly missed step in this whole guide.

sudo firewall-cmd --permanent --add-service=ntp
sudo firewall-cmd --reload

Verify the rule is active:

sudo firewall-cmd --list-services

Expected output includes ntp in the list:

cockpit dhcpv6-client ntp ssh

Tips and Troubleshooting

chronyc: command not found

Why it happens: Chrony isn’t installed.

Fix: Install it and start the service:

sudo dnf install chrony -y
sudo systemctl enable --now chronyd

chronyc tracking shows the system as not synchronized

Why it happens: No reachable sources. Usually a firewall block, a typo in /etc/chrony.conf, or chronyd just hasn’t had enough time since a restart to sync.

Fix: Confirm outbound UDP/123 isn’t blocked, double-check the pool/server hostnames resolve correctly, and give it a few minutes:

dig +short pool.ntp.org
chronyc -a burst 4/4
sleep 30
chronyc tracking

A source shows up marked with x (falseticker)

Why it happens: That server’s reported time doesn’t match your other configured sources, so Chrony is deliberately ignoring it to protect your clock’s accuracy.

Fix: If it’s a one-off, it’ll often self-correct once the flaky source updates. If a specific server is consistently flagged, pull it from /etc/chrony.conf, swap in a known-reliable pool or internal server, and restart:

sudo systemctl restart chronyd
chronyc sources -v

Clients on the network can’t sync against this server

Why it happens: Missing allow directive, or firewalld is blocking UDP/123.

Fix: Add the client subnet to /etc/chrony.conf, restart, open the firewall, and confirm with chronyc clients:

sudo firewall-cmd --permanent --add-service=ntp
sudo firewall-cmd --reload
sudo systemctl restart chronyd
chronyc clients

Large offset that never seems to correct

Why it happens: The offset is bigger than what Chrony’s default makestep settings allow it to instantly correct. It’s stuck slowly slewing a multi-second gap; that could take days to close on its own.

Fix: Temporarily force a step, or adjust makestep in /etc/chrony.conf. This works for environments where large jumps at startup are fine, typically not production database or clustered nodes:

sudo chronyc makestep
chronyc tracking

Warning: Forcing a clock step can break processes that assume monotonic time: cron jobs mid-run, active TLS sessions, distributed locks. Only force a step on a system you’re actively troubleshooting, never on a live production node without a maintenance window.

Wrapping Up

At this point chronyd is installed, enabled, and synced against a public pool or your own internal NTP server. You know how to read chronyc tracking and chronyc sources -v when something looks off. That’s really the whole job. Chrony is one of those services that, once configured correctly, you rarely touch again.

The biggest mistake I see: people skip the firewall step and blame Chrony for DNS resolution failures. Those are almost always network-layer issues wearing a Chrony costume. If you’re rolling this out across a fleet, standardize on internal server entries instead of public pools. It’s faster and more predictable. And it’s one less thing depending on the public internet being reachable.

StepActionApplies To
1Check/install chrony via dnfAll RHEL 8/9, CentOS Stream 9
2Enable and start chronydAll
3Edit /etc/chrony.conf (pool or server)Public pool or internal NTP
4Verify with chronyc tracking / sources -vAll
5Open UDP/123 in firewalldServers acting as NTP source or client behind a strict firewall

Resources