The Web User's crontab Isn't the One Running Your Jobs
A classic problem that takes an entire afternoon to track down — the admin page says the save succeeded, the database is updated, but scheduled jobs still run at exactly the old times every day. The cause is not broken code; it is writing the wrong user's crontab.
I had a system that let me change its operating schedule in two ways — through a bot running as a service, or through the web admin page. Both called the same function to rewrite the crontab automatically. One day I changed it through the web, clicked save, and saw a success message. The database was updated correctly, and the dashboard showed the new schedule, but when the time came, the jobs still ran on the old schedule. I went through everything from the code to the logs and eventually found that the problem was not in the code at all.
The root cause comes down to one short sentence: the crontab command writes the schedule for the user running that command. The web ran as www-data, while the actual jobs were in root's crontab. When the web script ran crontab ไฟล์ใหม่, it created a new schedule for www-data, while root's schedule stayed right where it was, without a single line changing.
The example paths and script names in this article are made up, but the structure and fix are what I actually use.
The symptoms — and the most misleading part
What made this hard to track down was that it was not "failing to run." It was running two sets of jobs at once.
The old schedule in root's crontab kept running normally, firing at the original times and writing logs without a problem. cron also picked up the new schedule in www-data's crontab, but ran it with www-data's permissions, which did not allow it to write to the root-owned log file in /var/log/. It failed on the first line without anyone seeing it. What users saw was "the jobs still run at the old times," while what the sysadmin saw was "nothing unusual in the logs."
Worked correctly all along
The service has User=root set, so when shell_exec calls crontab, it writes root's schedule directly — this route never had a problem, so nobody suspected the shared code could go wrong
Silently writes for the wrong user
The process runs as www-data, so the exact same command writes www-data's schedule instead. No error, no failure exit code, because the command really succeeded — just in the wrong place
The point I want to stress is that this command did not fail. If it had returned an error, I would probably have caught it on day one. But it successfully did exactly what it was told; the only mismatch was between what I told it to do and what I thought I was telling it to do.
Find it in 3 commands
First, check which user the web runs as — do not guess; ask from inside the code that actually runs:
// วางชั่วคราวในหน้าที่มีปัญหา แล้วลบทิ้งหลังตรวจเสร็จ
echo shell_exec('id -un'); // -> www-data
Second, compare the two users' crontabs directly:
sudo crontab -l -u root | grep -n 'app/cron/'
sudo crontab -l -u www-data | grep -n 'app/cron/'
If entries appear under www-data, you have hit this problem — that is where the schedule the app thought it had written ended up. Third, check the files to see which users have schedules sitting on the machine:
sudo ls -l /var/spool/cron/crontabs/ # Debian / Ubuntu
sudo ls -l /var/spool/cron/ # RHEL / AlmaLinux / Rocky
# -rw------- 1 root crontab 1204 Aug 14 09:12 root
# -rw------- 1 www-data crontab 612 Aug 14 09:12 www-data <- ตัวปัญหา
The file modification times tell the story immediately. The www-data file was touched at exactly the time I clicked save on the web, while the root file still had an old date. And to see whose jobs cron picks up on each run:
grep CRON /var/log/syslog | tail -20 # Debian / Ubuntu
journalctl -u cron --since today | tail -20
# Aug 14 10:01:01 host CRON[24518]: (root) CMD (/usr/bin/php /var/www/app/cron/pull_1.php ...)
# Aug 14 10:31:01 host CRON[24902]: (www-data) CMD (/usr/bin/php /var/www/app/cron/pull_1.php ...)
Lines with (www-data) in parentheses are the conclusive evidence — both sets really are running, at different times and with different permissions.
The fix: one narrowly scoped sudoers drop-in
Allow the web to write root's crontab through sudo, for this command only and nothing more:
# /etc/sudoers.d/app-crontab (สิทธิ์ต้องเป็น 0440 เจ้าของ root:root)
www-data ALL=(root) NOPASSWD: /usr/bin/crontab
Always check the syntax after installing it, before proceeding. A broken sudoers file means sudo will not work anywhere on the machine:
sudo install -m 0440 -o root -g root /tmp/app-crontab /etc/sudoers.d/app-crontab
sudo visudo -c -f /etc/sudoers.d/app-crontab
# /etc/sudoers.d/app-crontab: parsed OK
sudo -u www-data sudo -n /usr/bin/crontab -l | head -3 # ทดสอบว่าใช้ได้จริงโดยไม่ถามรหัส
In the code, choose the command based on the privileges it is running with, without branching on where the request came from:
// เป็น root อยู่แล้ว (เช่นถูกเรียกจาก systemd service) -> ใช้ตรงๆ
// ไม่ใช่ root (ถูกเรียกจากเว็บ) -> ผ่าน sudo -n ที่เปิดไว้เฉพาะคำสั่งนี้
$isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0;
$crontabCmd = $isRoot ? '/usr/bin/crontab' : 'sudo -n /usr/bin/crontab';
// อ่านของเดิม -> ตัดบล็อกที่ระบบเราจัดการออก -> ต่อบล็อกใหม่ท้ายไฟล์
$current = (string) shell_exec("{$crontabCmd} -l 2>/dev/null");
$current = preg_replace('/'.preg_quote(self::CRON_BEGIN, '/').'.*?'
.preg_quote(self::CRON_END, '/').'\n?/s', '', $current);
$newBlock = self::CRON_BEGIN."\n".implode("\n", $generated)."\n".self::CRON_END."\n";
$tmp = tempnam(sys_get_temp_dir(), 'app_cron_');
file_put_contents($tmp, rtrim($current, "\n")."\n".$newBlock);
$out = shell_exec("{$crontabCmd} {$tmp} 2>&1");
unlink($tmp);
if ($out !== null && trim($out) !== '') {
return ['ok' => false, 'error' => trim($out)]; // อย่ากลืน error ตรงนี้
}
-n matters. It means "do not ask for a password" — if sudoers ever goes missing or is written incorrectly, the command will fail immediately with a visible error, instead of hanging while waiting for input that will never arrive until PHP times out.
Anyone who can write root's crontab can schedule any command to run as root — put simply, the single sudoers line above amounts to giving the web user full root privileges. If the web is compromised, the intruder immediately controls the whole machine without needing another exploit. I accept this risk because this is an internal system whose admin page requires authentication and is accessible only from the internal network. Do not do this on a public website. Use the method in the next section instead
A better approach: keep the web away from crontab entirely
On reflection, the whole problem comes from keeping the schedule in two places — in the database edited by the web, and in the crontab read by cron — and having to keep them in sync all the time. The approach I consider more sound is to keep one permanent line in crontab and store the actual schedule only in the database.
# crontab ของ root — บรรทัดเดียว ไม่มีวันเปลี่ยน ไม่มีใครต้องเขียนทับ
* * * * * /usr/bin/php /var/www/app/cron/dispatcher.php >> /var/log/app/dispatch.log 2>&1
dispatcher.php wakes up every minute, reads the schedule from the DB, and decides which jobs need to run that minute. The web just updates rows in the DB as usual, with no sudo, no shell_exec, and nothing to fall out of sync because there is only one schedule in the system. The cost is one process waking up every minute, which is barely noticeable on modern machines. Personally, I would choose this approach every time when starting a new project. I reserve web-written crontabs for existing systems that are not yet worth reworking.
Keep the schedules from falling out of sync again
Three things I put in place after fixing this case:
- Wrap the block managed by the system with the markers # BEGIN APP_SCHEDULE / # END APP_SCHEDULE and regenerate the entire block. Do not edit it line by line — that way, entries others have added outside the markers will not disappear
- Set up cron to check for schedules falling out of sync every hour. If an entry belonging to this system appears in any user's crontab other than root's, send an alert immediately
- Clean up all the old entries after applying the fix — use crontab -r -u www-data to remove the leftover schedule, or it will quietly keep running in parallel indefinitely
This short script is enough to check for schedules falling out of sync:
#!/bin/bash
# /usr/local/sbin/cron-desync-check.sh — เตือนถ้างานของเราไปโผล่ใน crontab ผิด user
MARK='app/cron/'
for u in $(cut -d: -f1 /etc/passwd); do
[ "$u" = "root" ] && continue
n=$(crontab -l -u "$u" 2>/dev/null | grep -c "$MARK")
[ "$n" -gt 0 ] && echo "[WARN] เจอ $n บรรทัดของระบบใน crontab ของ $u — ต้องมีแค่ root เท่านั้น"
done
exit 0
While tracking down this case, I spent quite a while on a dashboard that still showed the old schedule even though the DB had already been updated, because I had left the tab open and the browser displayed a cached copy. The fix is for the controller to send Cache-Control: no-store for pages showing constantly changing status. Otherwise, when debugging, I cannot tell whether I am looking at the current state or an old copy
The short takeaway I want you to remember from this article is that the crontab command has no concept of "the system crontab" — it always works with a particular user's crontab. Whenever any script is going to touch it, the first question to answer is who it is running as, not what command it is running. Feel free to use the sudoers drop-in, but for an internet-facing system, I recommend having a dispatcher read the DB from the start; it is much safer. If I have missed anything in the explanation or made a typo, I will come back and correct it later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first