cron Never Ran: Log Redirect Pointed to a Missing Directory
Ending a cron line with log redirection looks like a good habit. But if the log destination cannot be opened, the command before it will never run, and the only evidence explaining why goes somewhere nobody reads.
1-minute summary
- Symptom / problem
- A cron job never runs at all, with no log and no mail, while running it by hand works fine.
- Who should read
- Anyone who ends crontab lines with a log redirect.
- Fix in short
- The redirect target directory does not exist so the shell never runs the command. Have the job create its own log directory first, and use a checklist before releasing a cron line.
I had set up automatic publishing for a website, with cron releasing articles twice a day at 08:00 and 20:00. The first run was due at 20:00 on the day I set it up. When the time came, nothing happened. The article did not appear, no log file was created, there was no mail, and the application log had no errors. But running the same command manually from the shell worked. I ended up publishing the first article manually at 20:05, then sitting down to work out why cron had gone silent.
The cause was that the directory where I had told it to store logs did not exist. That was all. The username and paths in this article are made up, but the sequence of events and the verification steps are what actually happened.
Symptoms: silence on every channel
The site runs Laravel on DirectAdmin shared hosting. It has articles queued for publication and an artisan command that publishes the first article in the queue. I added this to the web user's crontab.
MAILTO=""
0 8,20 * * * /opt/php/bin/php /home/webuser/app/artisan articles:publish-next >> /home/webuser/private/publish-next.log 2>&1
The php path has to be written in full because the default php found by the CLI on this machine is older than the version the application uses. I added MAILTO="" because I did not want mail on every run.
What I checked after the 20:00 run was due
This kind of silence is different from a job that runs and fails. A failed job usually leaves something behind. This time, there was no trace that anything had even tried to run.
Three wrong guesses I made
First, I thought the php path was wrong, or cron was picking up an older php version, because I already knew this machine had several versions installed. But the crontab line already had the correct full path, and the same command worked manually.
Second, I thought the cron daemon was not reading this user's crontab. This machine had been migrated from the CentOS family to Debian, which changes where several things live.
| Item | CentOS family | Debian family |
|---|---|---|
| User crontab location | /var/spool/cron/<user> |
/var/spool/cron/crontabs/<user> |
| Service name | crond |
cron |
| cron log | /var/log/cron |
journalctl -u cron or /var/log/syslog |
If an old script or tool still writes files to the old location, the jobs will not be read. It was a reasonable suspicion, but that was not the problem this time. I covered the case of a crontab being written under the wrong user separately in The web user's crontab is not the crontab cron actually runs. This time, the crontab was in the right file under the right user, but the line failed before it reached the command.
Third, I thought it was a timezone issue: cron might interpret 20:00 differently from what I expected. If that were the case, though, the job should have run at another hour and left a log. There was no log at all.
All three guesses had one thing in common: I was guessing without data, because I had disabled every channel that should have provided it.
Verifying the daemon in 70 seconds
Instead of guessing further, I split the problem into two parts: whether the daemon and crontab worked, and whether my job line had a problem. The quickest way was to add a temporary line that ran every minute and wrote somewhere that definitely existed.
# เพิ่มบรรทัด probe ต่อท้าย crontab เดิม โดยไม่ต้องเปิด editor
( crontab -l; echo '* * * * * date >> /home/webuser/cron-probe.log' ) | crontab -
# รอให้ข้ามขอบนาทีแน่ ๆ แล้วดูผล
sleep 70; cat /home/webuser/cron-probe.log
# เสร็จแล้วถอดบรรทัด probe ออก และลบไฟล์
crontab -l | grep -v 'cron-probe.log' | crontab -
rm -f /home/webuser/cron-probe.log
After about 70 seconds, a timestamp appeared in the file. That meant the daemon was working, this user's crontab was being read, and the machine's clock was advancing normally. That ruled out my second and third guesses together. The problem was now narrowed down to my job line.
The only difference between the probe line and the job line, apart from the command itself, which already worked manually, was the redirect destination.
$ ls -ld /home/webuser/private
ls: cannot access '/home/webuser/private': No such file or directory
It cuts the variables in half at each step. If no timestamp appears, the problem is with the daemon, permissions, or the crontab file. If one does appear, the problem is in the job line. There is no need to wait for the next scheduled run, which in this case meant 12 hours. Remember to remove the line once you have the answer.
The mechanism: the shell opens redirects before running the command
cron does not run the command directly. It passes the whole line to sh -c, and sh handles redirects before it executes the command. If it cannot open the destination file, sh reports an error and stops. The command before the redirect never runs at all.
You can verify this on any machine without touching cron.
$ sh -c 'echo hi >> /nonexistent/x.log 2>&1; echo rc=$?'
sh: 1: cannot create /nonexistent/x.log: Directory nonexistent
rc=2
echo hi does not run, and the exit code is nonzero. The message and number vary depending on the shell behind /bin/sh. With bash, the message is No such file or directory, but the behavior is the same: a command whose redirect cannot be opened will not run.
What made it completely silent was that the 2>&1 I had added at the end of the line did not help. It was a redirect for a command that never happened. sh's own error message went to the stderr held by cron, which would normally collect it and mail it to the crontab owner. But I had set MAILTO="", so the message was discarded.
There was actually one channel left that had not been disabled: the cron daemon's own log.
# ต้องใช้สิทธิ์ root - cron บันทึกบรรทัด CMD ทุกครั้งที่ยิงงาน แม้คำสั่งจะล้มทันที
journalctl -u cron --since "20:00" --until "20:05"
grep CRON /var/log/syslog | grep webuser | tail -5
If there is a CMD line at the scheduled time, cron launched the job, and the failure happened after that. On shared hosting where the web user cannot read system logs, the probe line is therefore an option that is always available.
The fix: let the job create its own log directory
The immediate fix was to create the directory.
mkdir -p /home/webuser/private && chmod 700 /home/webuser/private
At 08:00 the next day, cron published the article without me doing anything, and the log file was created normally.
But an immediate fix does not prevent the next occurrence, because the directory could disappear again during a server migration or a restore from backup. I considered two permanent fixes.
mkdir -p in the deploy script
Add a line to create the log directory during deployment. Keep the crontab line as it is. This is simple and sufficient for jobs that are always deployed through a script. But if someone sets up a new machine manually and skips the script, the same problem returns.
A wrapper that handles its own setup
crontab calls a single script with no redirect on the line. The script creates the directory, opens the log, then executes the actual job. The crontab line gets shorter, and all the requirements live in a file that travels with the code.
The wrapper I use as a template
#!/bin/sh
# /home/webuser/bin/publish-next.sh - crontab เรียกไฟล์นี้อย่างเดียว ไม่มี redirect ในบรรทัด cron
LOG_DIR=/home/webuser/private
mkdir -p "$LOG_DIR" || exit 1
exec >> "$LOG_DIR/publish-next.log" 2>&1
echo "== $(date '+%F %T') start"
exec /opt/php/bin/php /home/webuser/app/artisan articles:publish-next
0 8,20 * * * /home/webuser/bin/publish-next.sh
exec >> file 2>&1 with no command after it changes the stdout and stderr destinations of the script itself. Every subsequent line writes to the log, including any php errors.
Before putting the new line into crontab, I tested it under conditions as close to cron as possible: through sh -c, with the environment cleared down to what cron provides. Running it from a logged-in shell can succeed because that shell has PATH and other variables that cron does not.
# จำลองสภาพของ cron: environment เกือบว่าง, shell เป็น /bin/sh, ไม่มี tty
env -i HOME=/home/webuser PATH=/usr/bin:/bin /bin/sh -c '/home/webuser/bin/publish-next.sh' < /dev/null; echo "rc=$?"
If the job line redirects somewhere it cannot open, this method shows sh's error message on screen immediately, without waiting for the scheduled run.
As for MAILTO, I have changed my habit: I no longer set it to empty until the job has run reliably for at least one actual scheduled run. I accept the mail at first, then disable it once the job is stable and has a log I can trust.
nohup and background jobs fail the same way
This mechanism is not specific to cron. Lines used to launch background jobs on remote servers run into the same problem.
# ถ้า logs/ ยังไม่มี งานไม่เริ่ม และ shell คืน prompt ตามปกติ
nohup bash job.sh > logs/out.log 2>&1 &
# สร้างโฟลเดอร์ก่อนเสมอ
mkdir -p logs && nohup bash job.sh > logs/out.log 2>&1 &
The first form is even more dangerous than cron, because I often run it and close the session to do something else. When I return, I find that a job I thought had been running for hours never started.
Another thing to add to a cron job with several steps is protection against overlapping runs. Here is an example from another system I maintain: an hourly job with two consecutive steps that takes about 31 seconds per run. I wrapped it with flock on the same line.
5 * * * * flock -n /home/webuser/private/hourly.lock -c '/home/webuser/bin/pull-data.sh && /home/webuser/bin/recompute.sh' >> /home/webuser/private/hourly.log 2>&1
-n makes the new run exit immediately if the previous run still holds the lock, without waiting in a queue. On a normal day, a 31-second run cannot overlap with the next one. But when the source is slow or the network stalls, two overlapping runs writing the same data at once can create problems that are much harder to track down. Notice that the lock file and log file are in the same directory. If that directory does not exist, this line fails silently just like the case above.
Some hosting setups do not allow PHP running on the web side to write to /tmp, and it fails silently too. If a cron job and web code need to share cache or log files, store them under the user's home directory in a directory outside the docroot so both sides behave the same way.
Checklist before adding a cron line
- Write the interpreter's full path, and use the same version that worked manually. Check by running the same line through
sh -c, rather than through a logged-in shell. - The destination directory for every redirect exists, and there is a
mkdir -pline in the deploy script or wrapper. - The user who owns the crontab has permission to write to that directory, not just root.
MAILTOis not set to empty until the job has completed at least one actual scheduled run successfully.- A
%in a cron line must be written as\%. Otherwise, cron splits the line there and sends the rest as stdin. This often comes up withdate +%F. - The last line of crontab must end with a newline, or that line may not be read.
- Wrap jobs with multiple steps or potentially slow jobs with
flock -n. - Add a probe line that runs every minute, wait 70 seconds, confirm that the daemon reads the crontab, then remove it.
The lesson this time was that I wasted time guessing at three causes that were all more complicated than the actual problem, because a job that leaves no trace always makes me think of larger causes first. The next time cron goes silent, I will start with a probe line and check the redirect destination before anything else. Use a wrapper or put mkdir -p in the deploy script, whichever suits you. Just make sure one of them is there. If I have left anything out or made a typo, I will come back and fix 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