The hardest cron bug to find is not the one where the schedule is wrong. It is the one where the schedule is correct, cron does fire the job at the expected minute, and the job exits silently without doing any of its actual work. Logs show the command was invoked. Stdout is empty. Stderr is empty. The downstream consumer sees no new output. Nothing is broken in a way that paged anyone.
This bug is almost always a difference between the environment cron gives the command and the environment the developer had when they tested it by hand. Three ingredients matter: PATH, the working directory, and the shell. Get any of them wrong and the command finds different binaries, looks for files in different places, or interprets the script with a shell that does not support the syntax you wrote.

Photo by Γmer Derinyar on Pexels
Why "it works in my shell" stops working under cron
When you run a command in your shell, the shell has spent years being configured. Your .bashrc or .zshrc has prepended /usr/local/bin, $HOME/.local/bin, your language version manager's shim directories, and whatever else. Your virtualenv is active. Your environment variables are populated from .env files via direnv or similar. The shell knows about your aliases. It expands ~ to your home directory. It resolves python to the specific interpreter you expect.
Cron runs the command in a much smaller world. By default, the inherited environment is essentially empty. PATH is typically /usr/bin:/bin or whatever the cron daemon was started with. HOME is set to the user's home but no shell startup files are sourced. The working directory is usually / or the user's home, not the directory where you tested the command. The shell that interprets the command line is whatever SHELL cron decides to use, which in most distributions is /bin/sh, not your interactive shell.
Everything that depended on your interactive configuration breaks. The Python interpreter is the system one, not the virtualenv one. The aws binary is not on PATH because it was installed to /usr/local/bin. The script is interpreted by dash, not bash, so the array syntax you used silently produces nothing. None of this prints an error that says "cron's environment is different." The command just exits zero (or with whatever code its fallback path produces) and moves on.
The thirty-second reproduction
The fastest way to reproduce cron's environment in your own shell is to strip yours down to roughly what cron sees:
env -i HOME=$HOME LOGNAME=$USER PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c "<your command>"
This runs the command with an empty environment except for the four variables cron usually sets, with /bin/sh as the interpreter. If your command fails here, it will fail under cron. If it succeeds, you have ruled out the environment as the cause.
If you want to be even more precise, look at the actual environment cron used the last time it ran your job. Many distributions log the cron environment if you add env > /tmp/cron-env-$$.log as the first thing in your script. Run the job once, look at /tmp/cron-env-*, and you have a verbatim copy of what to reproduce.
The four fixes, ranked by reliability
Once you have reproduced the bug, the fix is some combination of these four moves.
Fix 1: Use absolute paths in the command itself. Instead of python myscript.py, write /opt/myapp/venv/bin/python /opt/myapp/myscript.py. Instead of aws s3 cp ..., write /usr/local/bin/aws s3 cp .... This is the most reliable fix because it does not depend on environment at all. It is also ugly to read.
Fix 2: Set PATH at the top of the crontab. Add PATH=/usr/local/bin:/usr/bin:/bin (or whatever your real path is) at the top of the crontab file. This makes every entry below use that PATH. This is cleaner than per-command absolute paths and works well when most jobs need the same PATH.
Fix 3: Wrap the command in a login shell. Write the crontab entry as bash -lc '<your command>'. The -l flag tells bash to behave like a login shell, which sources /etc/profile, ~/.bash_profile, and so on. Your interactive PATH and aliases now apply. The downside is that you are now coupled to whatever those files do, which makes the job harder to reason about and changes its behavior when someone edits your shell config.
Fix 4: Move the work into a script with an explicit shebang and an explicit cd. The crontab calls /opt/myapp/run.sh. The script starts with #!/bin/bash, sets PATH explicitly, runs cd /opt/myapp, sources the virtualenv activation script, and then runs the work. The crontab line is short, the script is the source of truth for the environment, and any future engineer can read the script and see exactly what assumptions the job makes. This is the option I would push for in any system that needs to be maintained by more than one person.
The discipline around containerized scheduled jobs is similar. The container's CMD is the entry point, the image bakes in the right PATH and working directory, and the cron-equivalent (Kubernetes CronJob, AWS EventBridge with ECS, systemd timer in a sidecar) only handles the scheduling. The same separation of "what environment does the job run in" from "when does the job run" is what makes the system debuggable.

Photo by Florent Bertiaux on Pexels
Capture stdout, stderr, and exit code
Even with the environment fixed, a cron job needs to be observable. The default behavior of cron is to email stdout and stderr to the user if mail is configured, and to silently discard them if not. On most modern hosts mail is not configured, so the output goes nowhere.
The minimum-effort fix is to redirect both streams to a known file:
0 * * * * /opt/myapp/run.sh >> /var/log/myapp.log 2>&1
The better fix is to send the output to your existing logging infrastructure (journald, a syslog forwarder, a centralized logging service) and to record the exit code separately. Systemd timers, documented across the systemd project, capture both stdout and stderr in the journal automatically and surface the exit code via systemctl status. If you are starting a new scheduled job today, this is one of the better reasons to prefer a systemd timer over a raw crontab entry.
For the schedule itself, the Cron Expression Builder gives you both the English translation and the next ten firing times in your local timezone, so you can verify the schedule before you start debugging the environment. The widely used crontab.guru reference is the canonical syntax explainer when you need to look up a less common expression.
Build a "did the job run" alert before you add the next job
The silent-exit class of bug survives because nothing in the system notices that the job stopped doing its work. Add a dead-man's-switch monitor for every scheduled job: a heartbeat the job writes when it succeeds, and an alert that fires when the heartbeat has not been written in N minutes. The IANA-maintained tz database is the source of truth for figuring out exactly when the heartbeat is expected after a DST boundary, which is one of the few times the "expected" interval shifts.
A heartbeat is twenty lines of code. The first time it catches a silently-failing job, it pays for itself ten times over.
For more on the schedule-side of cron failures (skipped runs, day-of-month and day-of-week OR-bugs, timezone gotchas), the companion guide on cron expressions goes field by field through the most common bugs and how to read an expression you inherited. The EvvyTools homepage lists the rest of the small utilities aimed at the same kind of low-glamour, high-impact debugging problem.
Top comments (0)