Your VPS clock is accurate, NTP is synced, and timedatectl shows the right time - yet cron jobs fire at the wrong hour, WordPress post timestamps are off by five and a half hours, and MySQL's NOW() doesn't match what PHP just printed. That's not drift. That's timezone, and on a typical stack it's set in four or five different places that don't automatically agree with each other.
Symptom: the clock is right, but everything built on top of it isn't
This shows up in ways that don't immediately point at timezone, which is why it drags on:
- A cron job scheduled for 2:00 AM runs at 7:30 AM (or 8:30 AM) instead
- WordPress shows a post as "published" hours before or after you actually clicked Publish
- MySQL's
NOW()orCURRENT_TIMESTAMPdisagrees with PHP'sdate()on the same server - Log timestamps in Nginx/Apache access logs don't line up with the app's own logs, making it hard to correlate an error with the request that caused it
- A Docker container on the box reports a completely different time than the host
- Scheduled reports or invoices land in a customer's inbox at 3 AM their time instead of 9 AM
Run date and timedatectl status and everything looks fine. The system clock isn't the problem - the timezone each layer is configured to use is.
Cause: your stack has 4-5 independent timezone settings
A "VPS" is really an OS, a cron daemon, a web server, a database, PHP, and usually an application - and every one of those keeps its own idea of what timezone to render times in. Setting one doesn't touch the others.
| Layer | Where it's set | Default if untouched |
|---|---|---|
| Operating system | /etc/timezone, timedatectl | UTC on most fresh cloud images |
| PHP | date.timezone in php.ini | UTC, or a warning if unset |
| MySQL/MariaDB | time_zone system variable | SYSTEM (follows the OS, but only if named zone tables are loaded) |
| Cron | System tz by default, or a TZ= line in the crontab | Follows /etc/timezone unless overridden |
| WordPress | Settings → General → Timezone (stored in wp_options) | UTC+0 on a fresh install |
| Docker containers | Container's own /etc/localtime | UTC, regardless of the host |
Most of the confusing cases come from one of three patterns:
- Migration or snapshot restore - a VPS cloned from a template inherits the template's timezone, not the one you actually want, and nobody rechecks it.
- WordPress vs server disagreement - WP stores post dates in UTC internally (
post_date_gmt) and converts for display using its own Settings → General value. If that value doesn't match the server's actual timezone, cron-triggered actions and displayed times drift apart even though both are "correct" by their own setting. - Cron running as a different user or in a minimal environment - some cron daemons don't inherit the full environment, so a
TZvariable set in your shell profile never reaches the crontab.
Fix: check and align each layer explicitly
1. Confirm the OS timezone
timedatectl statusCheck three fields: Local time (compare against your phone), Time zone (what it's actually set to), and System clock synchronized: yes. To change it:
timedatectl list-timezones | grep Kolkata
sudo timedatectl set-timezone Asia/KolkataFor most production servers, set this to UTC and let the application layer handle local display - see Prevention below for why.
2. Fix PHP's timezone
php -i | grep date.timezoneIf it's blank or wrong, edit php.ini directly, or use cPanel's MultiPHP INI Editor if you don't have root:
date.timezone = Asia/KolkataAvoid patching this with date_default_timezone_set() calls scattered through the codebase - it works, but it only fixes the one script that calls it, and the next developer won't know it's there. Fix it once in php.ini and restart PHP-FPM:
sudo systemctl restart php8.2-fpm3. Fix MySQL's timezone
SELECT @@global.time_zone, @@session.time_zone;If both show SYSTEM, MySQL is just following the OS setting from step 1 - which is fine as long as you've confirmed that's correct. To pin it explicitly instead (recommended, since it survives OS-level timezone changes without silently shifting your data):
SET GLOBAL time_zone = '+00:00';Make it persistent by adding to my.cnf:
[mysqld]
default-time-zone = '+00:00'If you want to use named zones like Asia/Kolkata instead of a raw offset, load the timezone tables first - a bare MySQL install often has them empty:
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql4. Fix cron's timezone
System cron normally follows /etc/timezone, but you can pin an individual crontab regardless of what the OS is set to - useful when one script genuinely needs to run on IST while the server itself runs on UTC:
crontab -e
# add this as the first line
TZ=Asia/Kolkata
0 2 * * * /usr/bin/php /home/user/scripts/nightly.phpNot every cron implementation honors a TZ= line the same way, so after adding it, confirm with a one-off test job that echoes date to a log file before you trust the real schedule.
5. Fix WordPress's timezone
Go to Settings → General → Timezone and pick your actual city (not just a UTC offset - city-based zones handle daylight saving automatically, offsets don't). Verify from the CLI if you have WP-CLI:
wp option get timezone_string
wp option update timezone_string "Asia/Kolkata"This setting only affects display and scheduling logic in WP - the database still stores post_date_gmt in UTC, which is correct behavior. The mismatch happens when this value doesn't match what your server-side cron or reports assume.
6. Fix Docker containers
Containers default to UTC no matter what the host is set to. Either mount the host's timezone data in read-only, or set the TZ environment variable (simpler, no volume mount needed on most modern base images):
docker run -e TZ=Asia/Kolkata your-image
# or in docker-compose.yml
environment:
- TZ=Asia/KolkataPrevention: standardize on UTC at the infrastructure layer
The fix that actually stops this from recurring isn't "set everything to IST" - it's the opposite. Keep the OS, MySQL, and PHP on UTC, and let the one layer that talks to humans - WordPress's timezone setting, your application's user-locale conversion, or your reporting tool - handle the conversion to local time for display. That way:
- Log correlation across web server, PHP, and database always lines up, since every timestamp is in the same zone
- Database replication and backups restored on a server in a different region don't silently shift your data
- Daylight saving transitions can't cause a cron job to run twice or not at all, because UTC never observes DST
A few habits that catch this early instead of six months later:
- Check
timedatectl statusas a standard step right after provisioning a new VPS or restoring a snapshot - don't assume it inherited the setting you expect - Put the timezone check in your deployment or provisioning script so it's enforced, not remembered
- When something is "off by a fixed number of hours," check timezone before you check anything else - a fixed offset almost never means drift
If you're on Getwebup VPS hosting and want a hand auditing which layer is misconfigured, our support team can pull the actual values from your server rather than guessing from symptoms - open a ticket with the exact behavior you're seeing (which cron job, what time it ran vs. expected) and we'll trace it end to end.