Set the timezone on a Linux server
Change a Linux server's timezone with timedatectl on Ubuntu 24.04 and Rocky 9, see the one symlink it rewrites, and why cron and Docker may ignore it.
The short answer
To set the timezone on a Linux server, run sudo timedatectl set-timezone <Zone> and read it back with timedatectl and date. The command is the same on Ubuntu 24.04 and Rocky Linux 9, because both boot with systemd. The setting itself is one symlink, /etc/localtime. Before you change it, read the section on why servers stay on UTC (coordinated universal time). For most servers the right zone is the one the image shipped with, and the local time you want belongs in your terminal or your application instead.
Find the zone name first
Zones use IANA (Internet Assigned Numbers Authority) names in the form Region/City: Europe/Berlin, America/New_York, Asia/Kolkata, Australia/Sydney. Abbreviations such as CST are not zone names, because the same letters mean different offsets in different countries. A few legacy names such as EST do exist as files, but use the Region/City form. It is the only form whose DST (daylight saving time) rules are maintained for a real place. List the names the box knows and filter the list:
timedatectl list-timezones | grep -i berlin
timedatectl list-timezones | grep -c .The first line prints Europe/Berlin. The second counts the entries, several hundred of them, which is why you filter rather than scroll. The names come from the tzdata package, which has that name on both distros, and each zone is one file under /usr/share/zoneinfo/. UTC and Etc/UTC are both valid and resolve to the same rules.
Set the timezone with timedatectl
The same commands work on Ubuntu 24.04 and Rocky 9:
sudo timedatectl set-timezone Europe/Berlin
timedatectl
datetimedatectl with no arguments prints a status block. The line to read is Time zone:, which shows the zone name followed by its abbreviation and offset in brackets. Local time: and Universal time: should now differ by exactly that offset. The line RTC in local TZ: should read no. Keep it that way on a server: the RTC (real-time clock) belongs in UTC, and on a VPS it is a virtual clock the hypervisor owns anyway.
timedatectl does not edit any file itself. It sends a request over D-Bus (the system message bus) to systemd-timedated, a small service that starts on demand, checks that you are allowed to make the change, and rewrites the symlink. Two error strings come from that path.
Failed to set time zone: Interactive authentication required. You ran the command without sudo. On a desktop, polkit would open a password prompt. A headless server has no agent to show one, so the request is refused. Add sudo.
Failed to set time zone: Invalid or not installed time zone 'Europe/Berlinn'. The name has a typo, or the tzdata package is missing. The service checks that the file exists under /usr/share/zoneinfo before it links to it. Copy the name from the list-timezones output instead of typing it.
What timedatectl actually changed
Look at the file it rewrote:
readlink /etc/localtime
readlink -f /etc/localtimeOn the Ubuntu 24.04 box used to write this guide, ls -l /etc/localtime showed /etc/localtime -> /usr/share/zoneinfo/Etc/UTC before the change, and after set-timezone Europe/Berlin the two commands both printed:
/usr/share/zoneinfo/Europe/Berlin
/usr/share/zoneinfo/Europe/Berlin/etc/localtime is a symlink (a symbolic link, a file that points at another file) into /usr/share/zoneinfo/. The exact spelling of the target differs by distro. Upstream systemd-timedated writes a relative target, ../usr/share/zoneinfo/<Zone>, so a disk image mounted under another root still resolves, and that is the form you see on Rocky 9 and Fedora. Debian and Ubuntu build systemd to write the absolute path instead, matching what their tzdata package writes. readlink shows the raw target, so its output differs between the two. readlink -f resolves it to the real file, so it prints /usr/share/zoneinfo/Europe/Berlin on both and is the one to compare. Either way, that symlink is the whole setting. Every program that calls the C library's localtime() with no TZ variable set reads this file to turn the UTC clock into wall time.
If readlink prints nothing and exits 1, /etc/localtime is a regular file, a copy of a zone file rather than a link to one. Some older images were built that way. It works today, but when the tzdata package ships new DST rules, the copy keeps the old rules. Running set-timezone once replaces the copy with a symlink.
Read date with the format spelled out
Bare date output depends on the locale, so do not compare it between two boxes. On the same UTC box, date printed Fri Sep 18 02:10:24 PM UTC 2026 under LANG=en_US.UTF-8, the locale a Rocky 9 image usually ships, and Fri Sep 18 14:15:11 UTC 2026 under C.UTF-8, the locale Ubuntu cloud images ship. Same clock and same zone, but a different clock face. Ask for the fields you want:
date '+%Y-%m-%d %H:%M:%S %Z %z'That printed 2026-09-18 14:14:09 UTC +0000 here. %Z is the abbreviation and %z is the numeric offset. After a change to Europe/Berlin you expect CEST +0200 in summer and CET +0100 in winter. If you see a zone abbreviation you do not recognise together with +0000, read the Docker section below, because that is the signature of a zone name the C library could not find.
Debian and Ubuntu also keep /etc/timezone
Debian-family systems hold a second copy of the zone name as plain text in /etc/timezone. It is a Debian convention, written by dpkg-reconfigure tzdata, and some scripts and container entrypoints read it. Rocky does not have the file: cat /etc/timezone there fails with No such file or directory, and that is normal. After a timedatectl change on Ubuntu, check that the two agree:
cat /etc/timezone
readlink /etc/localtimeIf the text file still names the old zone, let the package tooling rewrite it from the current symlink:
sudo dpkg-reconfigure -f noninteractive tzdataRun cat /etc/timezone again. It should now match the link target. This matters for more than tidiness: on Ubuntu, man 8 cron documents that the daemon uses /etc/timezone when the file exists, so a stale name there hands cron the old zone even after a restart.
No D-Bus? Set the symlink yourself
Inside a Docker container or a chroot, there is no systemd as PID 1 and no system bus. timedatectl then prints:
System has not been booted with systemd as init system (PID 1). Can't operate.
Failed to connect to bus: Host is downThe client needs the service, and the service is not running. Do the one thing it would have done:
sudo ln -sfn ../usr/share/zoneinfo/Europe/Berlin /etc/localtime
date '+%Y-%m-%d %H:%M:%S %Z %z'-s makes a symlink, -f replaces the existing link, and -n stops ln from following an existing link and creating the new one inside its target. The result resolves to the same file as the symlink timedatectl produces on a full system, so it is also the fallback when systemd-timedated is masked.
Why a server stays on UTC
Logs from several boxes line up. The classic syslog timestamp, Sep 18 14:10:24, carries no zone marker. If one box is on Europe/Berlin and the next on UTC, the same event appears two hours apart in the two files and nothing in the line tells you why. With every box on UTC, the timestamps sort as plain text, and a reader in any country converts once, in their head or with TZ=.
DST makes one hour a year ambiguous. On the last Sunday of October, Berlin clocks go from 03:00 back to 02:00. Run this on any Linux box:
TZ=Europe/Berlin date -d '2026-10-25 00:30:00 UTC' '+%Y-%m-%d %H:%M:%S %Z %z'
TZ=Europe/Berlin date -d '2026-10-25 01:30:00 UTC' '+%Y-%m-%d %H:%M:%S %Z %z'2026-10-25 02:30:00 CEST +0200
2026-10-25 02:30:00 CET +0100Two instants one hour apart both display as 02:30:00. Only the abbreviation tells them apart, and most log formats do not print it. A schedule for 02:30 local time either fires twice or is skipped, depending on the scheduler. In March the problem runs the other way: 02:30 never happens at all. UTC has no DST, so none of this exists there.
Containers and cloud images default to UTC. The common Docker base images boot in UTC, and so do the Ubuntu and Rocky cloud images most VPS providers install. If your host is the one box on local time, every container on it disagrees with it.
Local time is a display problem, and display is cheap. You do not need to change the server to read a time in your zone:
TZ=Europe/Berlin date
journalctl -n 5
journalctl -n 5 --utcThe first line printed Fri Sep 18 04:10:30 PM CEST 2026 on the UTC box. journalctl shows local time by default and --utc switches it, because the journal stores every entry as microseconds since the epoch and converts at display time. A zone change re-renders old entries too. Nothing stored changes.
Where a changed timezone does not reach
The symlink is read by programs that ask the C library for local time and have not set TZ themselves. Everything else keeps its own idea of the zone.
Cron
Cron decides "is it 09:00 yet" in the daemon's own zone. The daemon is a long-running process, and whether a running copy notices a new /etc/localtime depends on how it asks the C library for the time. Do not assume either way. Restart it, then prove it.
sudo systemctl restart cron # Ubuntu 24.04
sudo systemctl restart crond # Rocky 9The service is cron from the cron package on Ubuntu and crond from the cronie package on Rocky. Now add a one-minute test job with crontab -e:
* * * * * date '+\%Y-\%m-\%d \%H:\%M \%Z' >> /tmp/cron-tz.logThe backslashes matter. In a crontab, a bare % is turned into a newline and everything after it is fed to the command on standard input, so date '+%H' would run as date '+' and log an empty line. Wait seventy seconds and cat /tmp/cron-tz.log. The %Z field is the zone cron is running in. If it still shows UTC after the restart, either /etc/timezone still names the old zone on Ubuntu, or the unit was started with a TZ of its own: check systemctl cat cron or systemctl cat crond for an Environment= line. Remove the test line when you are done.
On Rocky, cronie accepts a CRON_TZ=Europe/Berlin line at the top of a crontab, which runs that one table on Berlin time while the server stays on UTC. Check man 5 crontab on your box before relying on it elsewhere, since not every cron implementation has it. For systemd timers, OnCalendar= accepts a trailing zone name, OnCalendar=*-*-* 09:00:00 Europe/Berlin, and that is the cleanest way to run one job on local time on either distro. If a job still misses its slot after all this, the zone is rarely the cause, and the checklist for a cron job that never runs covers the causes that have nothing to do with time at all.
Docker containers
A container has its own /etc/localtime and its own environment. Changing the host zone changes neither. Check one:
docker exec <container> date '+%Y-%m-%d %H:%M:%S %Z %z'You will almost always read UTC +0000. There are two ways to change it, and they fail differently.
Passing TZ in the environment is the common one. In a compose file:
services:
app:
image: nginx:1.27
environment:
TZ: Europe/BerlinThis only works if the image contains the tzdata package. If it does not, the C library cannot find /usr/share/zoneinfo/Europe/Berlin, so it parses the string as a POSIX (Portable Operating System Interface) rule: the letters before the slash become the abbreviation and the offset is zero. The failure is silent and easy to reproduce on any box by asking for a zone that does not exist:
TZ=Nowhere/Fake dateFri Sep 18 02:11:25 PM Nowhere 2026The time is still UTC. Only the label changed. A container printing Europe +0000 has exactly this problem. Fix it in the image: RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata on a Debian or Ubuntu base, RUN dnf install -y tzdata on a Rocky base. The DEBIAN_FRONTEND variable stops the tzdata postinstall from opening an interactive zone picker inside a build, which would hang it. The package names happen to match here, but they often do not, and the dnf to apt command map saves a search when you move an image between the two families.
The other way is to mount the host's file read-only:
volumes:
- /etc/localtime:/etc/localtime:roThat makes the container follow the host, with no tzdata needed inside the image, since the mounted file is the compiled zone itself. It also means the container changes zone when the host does, which you may not want for a service that should stay on UTC. Whichever you choose, keep TZ with the rest of the service's environment rather than scattering it, and the guide to compose env files and secrets shows where that belongs.
n8n and other applications that carry their own zone
Some applications read a zone setting of their own and never look at /etc/localtime. n8n is the common example on a VPS: its Schedule and Cron nodes use the GENERIC_TIMEZONE variable, and as of September 2026 n8n documents America/New_York as the default when it is unset. A workflow that fires at 09:00 New York time on a UTC server is not a bug in the server. Set GENERIC_TIMEZONE and TZ together in the n8n environment, and the n8n timezone and schedule guide walks through the cases where the two still disagree.
Databases do the same. PostgreSQL copies the zone into postgresql.conf as timezone and log_timezone when initdb creates the cluster, so a server that was initialised on UTC stays on UTC until you edit that file and reload. Check with SHOW timezone;. MariaDB and MySQL report SELECT @@global.time_zone, @@system_time_zone;. A time_zone of SYSTEM follows the operating system, but restart the service after a change and run the query again rather than trusting that the running process noticed. PHP has date.timezone in php.ini, and Java has -Duser.timezone. The rule is the same each time: the application's own setting wins, and the operating system zone is only its fallback.
Processes that were already running
Any daemon that started before the change may keep converting with the old zone until it is restarted. For the services whose log timestamps you care about, restart them and read one fresh line with %Z in it. The journal is the exception: it stores UTC and converts on display, so journalctl is correct immediately.
Keep the clock itself right
The zone is a display rule. The clock underneath it is a separate thing, and timedatectl reports both: System clock synchronized: and NTP service: should read yes and active. NTP (network time protocol) keeps the UTC clock correct, and no zone setting can fix a clock that is minutes off. On a VPS the clock can drift after a live migration or a long pause, so if those two lines say no or inactive, work through fixing clock drift and time sync on a VPS before you touch anything else.
FAQ
Should my VPS use UTC or my local timezone?
UTC. Logs from every box then sort as plain text. There is no hour that happens twice in October or never in March. Every container on the box already agrees with the host. Read local time when you need it with TZ=Europe/Berlin date or journalctl without --utc, and give a single job local time with CRON_TZ= in a cronie crontab or a zone suffix on a systemd OnCalendar= line.
Why does cron still run at the old time after I changed the timezone?
The cron daemon is a long-running process, and a running copy is not guaranteed to re-read /etc/localtime. Restart it with sudo systemctl restart cron on Ubuntu or sudo systemctl restart crond on Rocky, then add a * * * * * test job that appends date '+\%H:\%M \%Z' to a file and check that the %Z field shows the new zone. If it still shows UTC, check that /etc/timezone matches the symlink on Ubuntu, and that the unit has no TZ in an Environment= line.
Why does my Docker container show UTC when the host is set to Europe/Berlin?
The container has its own /etc/localtime and its own environment, and the host zone reaches neither. Pass TZ: Europe/Berlin in the service environment and make sure the image contains the tzdata package, or bind-mount /etc/localtime:/etc/localtime:ro from the host. A container that prints Europe +0000 has TZ set but no tzdata, so the C library parsed the name as a zero-offset POSIX rule.
What is the difference between /etc/localtime and /etc/timezone?
/etc/localtime is the setting. It is a symlink to a compiled zone file under /usr/share/zoneinfo/, and every program that asks the C library for local time reads it. /etc/timezone is a Debian and Ubuntu convention that holds the zone name as plain text for scripts and the cron daemon to read. Rocky and other Enterprise Linux systems do not have it. If the two disagree on Ubuntu, sudo dpkg-reconfigure -f noninteractive tzdata rewrites the text file from the symlink.