Fixing Scripts That Work Interactively but Fail Under cron
cron starts jobs with a small, non-interactive environment - make paths, interpreters, working directories, and logging explicit rather than sourcing a login shell.
A script that succeeds in a terminal but fails from cron is often depending on state that the job did not receive: an interactive PATH, a particular current directory, an alias or function, a profile side effect, an unlocked credential, or a controlling terminal. Start by separating the scheduler’s environment from application behavior; cron is not a terminal session with a shorter prompt.
Reproduce the smaller environment
Start by logging what cron actually sees, without writing secrets into a world-readable file:
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
[email protected]
15 2 * * * /home/alex/bin/backup.sh
Inside the script, send diagnostic output to a protected log or the scheduler’s mail facility. Record id, pwd, selected non-secret environment values, and resolved executable paths. Do not dump tokens, passwords, or the full environment into a broadly readable log. Do not assume cron starts in the project directory.
A temporary probe can make the differences concrete without copying an interactive environment into the job:
#!/bin/sh
printf 'user=%s home=%s shell=%s path=%s\n' \
"$(id -un)" "${HOME-}" "${SHELL-}" "${PATH-}" >&2
printf 'cwd=%s\n' "$(pwd)" >&2
command -v python3 >&2 || exit 127
exec /usr/bin/env python3 /home/alex/app/check_runtime.py
Run the probe once as the intended account and remove it after diagnosis; do not leave a diagnostic endpoint that prints environment data indefinitely. The executable lookup is part of the check: python3 may resolve to a different version in the scheduler’s PATH than in a developer’s terminal. Prefer a stable absolute interpreter when the application requires a particular runtime, and fail early if it is absent rather than continuing with a different interpreter.
Use an absolute interpreter and paths
The shebang controls which interpreter runs when the script is executed directly:
#!/bin/sh
set -eu
cd /home/alex/app || exit 1
/usr/bin/find ./exports -type f -mtime +14 -delete
An executable discovered through a version manager in .bashrc may not exist in cron’s PATH. Point at a stable installed interpreter or create a small wrapper that activates only the specific runtime environment the job needs. If a relative path is intentional, make the working directory explicit with a checked cd; otherwise use absolute paths for scripts, inputs, outputs, and configuration.
Do not source an entire interactive profile as a shortcut
Interactive startup files often print output, assume a terminal, launch agents, define aliases, or return early for non-interactive shells. Sourcing them makes an automated job fragile. Put shared, non-interactive variables in a dedicated file with restrictive permissions, and source that file explicitly.
Check non-environment differences
Cron jobs have no controlling terminal, so password prompts and interactive sudo fail. Desktop keychains may be locked or unavailable in a service session. Relative redirections land in the wrong directory. In Vixie/Cronie-style crontab syntax, an unescaped percent sign in the command field has special meaning: it is converted to a newline and the remainder is supplied on standard input. Escape it or move complicated shell syntax into a separate script.
Once fixed, test the exact cron entry or run the command with a deliberately minimal environment such as env -i HOME="$HOME" PATH=/usr/bin:/bin .... This approximates a reduced environment, but it is not a complete cron emulator: the daemon’s shell, user, time zone, PAM policy, working directory, and implementation-specific variables can still differ. Success in a richly configured terminal is not the acceptance test for unattended automation.
Separate portable expectations from implementation details
POSIX specifies crontab scheduling syntax and requires the implementation to provide a default execution environment, including standard account and shell-related variables. Details such as the exact default PATH, working directory, mail delivery, time-zone extensions, service name, and log destination vary between cron implementations and operating systems. The Linux crontab(5) manual describes Cronie/Vixie-style behavior; do not silently apply every detail from that page to BSD cron, macOS launchd, or a systemd timer.
For a Cronie-style user crontab, make the environment contract visible near the top:
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
[email protected]
15 2 * * * /home/alex/bin/backup.sh
These assignments affect subsequent entries in implementations that support this syntax. SHELL chooses the command interpreter for cron’s command line; it does not turn a non-interactive shell into a login shell or guarantee that .bashrc is read. Set PATH to the directories your job actually requires. Mail settings depend on a working local mail transport and implementation; for critical jobs, send results to an explicit monitored logging or notification path instead of assuming cron mail is delivered.
System crontabs commonly add a username field that does not appear in a per-user crontab. Editing /etc/crontab or /etc/cron.d/* with the wrong field count can shift every schedule field or command. Check the exact manual for the file you are editing, use crontab -l for the current user’s table, and install a test entry under the intended account before changing a privileged system schedule.
Make time and overlap semantics explicit
Cron schedules are wall-clock schedules. A time-zone change, daylight-saving transition, daemon restart, or host suspension can affect when an entry runs; behavior around skipped or repeated local times is implementation-specific. If the task must use UTC, verify whether the installed cron supports a CRON_TZ setting and test it on that host, or schedule a UTC-oriented service through a scheduler that explicitly supports the required calendar semantics. Do not assume a cron extension is POSIX simply because one Linux distribution accepts it.
Also decide what happens if one run lasts longer than its interval. Cron may start another copy while the earlier one is still active. Make the task idempotent, use a documented lock/single-instance mechanism, or choose a scheduler with an explicit concurrency policy. Locks themselves need stale-owner and crash recovery behavior; a leftover lock file must not silently suppress every future run forever.
For date formatting in a Vixie/Cronie crontab, avoid writing an unescaped % in the command field. Put complex commands in the script instead:
#!/bin/sh
set -eu
printf '%s\n' "$(date '+%F %T')" >>/var/log/backup-run.log
exec /usr/local/bin/backup --config /etc/backup.conf
The crontab then contains only the script path and any explicit arguments. This keeps cron’s parser from splitting the command string and makes quoting, diagnostics, and local testing much easier.
Where cron actually logs what it did
Logging is not uniform. Depending on the operating system and daemon configuration, cron messages may go to syslog, the system journal, local mail, or another configured facility. An application-level log cannot prove the scheduler launched the job, while the daemon’s log may not contain the script’s detailed error output. Check the actual service name and logging configuration on the host, and write the job’s stdout and stderr to a protected, rotated destination when that is the operational requirement.
If you are on a systemd host and need calendar timers, missed-run catch-up, or explicit service dependencies, compare a systemd timer rather than assuming cron can provide those semantics. It is a different scheduler with a different unit model; choose one deliberately and test its timezone, persistence, and concurrency behavior. On macOS, use the platform’s supported scheduling mechanism instead of assuming Linux cron service names or journal paths.
A reproducible diagnostic sequence
- Confirm the job is installed under the intended user and in the intended crontab file.
- Confirm the schedule and any implementation-specific environment assignments with that daemon’s manual.
- Capture a redacted snapshot of
id,pwd,PATH,SHELL, and the resolved executable paths. - Run the script directly with the same interpreter and a minimal environment, then compare the result with a real scheduled test entry.
- Test missing credentials, unwritable output, a nonzero child command, a second overlapping invocation, and a timezone boundary where relevant.
- Verify both scheduler-level invocation logs and the script’s own exit/error reporting.
Keep the environment allowlist small and documented. A robust scheduled job does not depend on a developer’s interactive prompt, but neither does it have to pretend every cron implementation is identical. The reliable fix is to make dependencies, paths, shell, time base, concurrency policy, and logging destination explicit at the boundary.
Related:
Sources: