Writing Scheduled Scripts That Actually Work on Linux
A script that works perfectly when you run it by hand can fail silently, produce wrong output, or simply not run at all once it’s triggered by cron or a systemd timer. The gap is almost always environment: an interactive shell session sets up a lot of context that a scheduled, unattended invocation does not get.
The Environment Problem
Cron (and to a lesser extent, systemd services) run scripts with a minimal environment:
- No interactive shell profile:
.bashrc,.bash_profile, and similar files are not sourced, so any environment variables, aliases, or PATH modifications defined there simply don’t exist for the scheduled run. - A much shorter PATH: cron’s default PATH is often just
/usr/bin:/bin, missing directories like/usr/local/binwhere many tools installed outside the base system live. - No assumed working directory: the script starts in whatever directory cron happens to use (often the invoking user’s home directory), not wherever you were sitting when you tested it manually.
Fixing It: Explicit Everything
#!/bin/bash
set -euo pipefail
# explicit working directory
cd /opt/scripts || exit 1
# explicit PATH, don't rely on cron's minimal default
export PATH="/usr/local/bin:/usr/bin:/bin"
# absolute paths for anything the script reads or writes
SOURCE_DIR="/data/incoming"
LOG_FILE="/var/log/myscript.log"
echo "$(date -Iseconds) starting run" >> "$LOG_FILE"
set -euo pipefail at the top is worth adopting as a habit for any script meant to run unattended: -e exits immediately on any command failure instead of continuing past it, -u treats unset variables as an error instead of silently substituting an empty string, and -o pipefail makes a pipeline fail if any command in it fails, not just the last one.
Preventing Overlapping Runs
If a script sometimes takes longer than the scheduling interval, two copies can end up running simultaneously, which is rarely intended and can cause anything from wasted resources to actual data corruption if both instances write to the same files.
#!/bin/bash
exec 200>/tmp/myscript.lock
flock -n 200 || { echo "already running, exiting"; exit 1; }
# rest of the script
Or, more simply, wrap the invocation at the crontab level:
# crontab entry
*/15 * * * * flock -n /tmp/myscript.lock /opt/scripts/myscript.sh
flock -n attempts to acquire the lock without blocking; if another instance already holds it, the new invocation exits immediately rather than queuing up behind the running one.
Logging Correctly
For cron, redirect both stdout and stderr explicitly:
*/15 * * * * /opt/scripts/myscript.sh >> /var/log/myscript.log 2>&1
Without 2>&1, error output goes wherever cron’s default mail delivery sends it (often nowhere useful on a modern system without local mail configured), and you’ll never see why a script failed.
For systemd timers, this is handled automatically: anything the paired service writes to stdout/stderr lands in the systemd journal, viewable with:
journalctl -u myscript.service --since today
which also makes it easy to correlate failures with other system events logged around the same time.
Either way, make sure logs don’t grow unbounded. logrotate is the standard tool for this on cron-based logging; systemd’s journal has its own retention configuration (journalctl --vacuum-time= or size-based limits in journald.conf).
Making Failures Visible
A script that fails silently is worse than one that doesn’t run at all, because nobody notices until the downstream effect shows up. The script itself should:
- Check the exit status of critical commands (or rely on
set -eto do this automatically) - Exit with a nonzero status on failure
From there, the scheduling layer needs to actually do something with that failure:
# a minimal wrapper that notifies on failure via a webhook
/opt/scripts/myscript.sh || curl -X POST -d '{"text":"myscript.sh failed"}' https://hooks.example.com/webhook
For anything more than a handful of scheduled jobs, a dedicated approach (a monitoring tool expecting periodic heartbeats and alerting on a missed one, or a proper job scheduler with built-in failure handling) scales better than ad hoc webhook calls scattered across crontab entries.
Testing Before Trusting the Schedule
Before relying on a scheduled script, run it manually in an environment that matches what cron or systemd will actually use, not your interactive shell:
env -i /bin/bash -c '/opt/scripts/myscript.sh'
env -i starts with a completely empty environment, closely mimicking cron’s minimal context, which surfaces PATH and environment-variable assumptions that testing in your normal interactive shell would hide.
Frequently Asked Questions
Why does a script that works fine when I run it manually fail under cron?
Cron runs jobs with a minimal environment: no interactive shell login, a much shorter PATH than your normal shell, no working directory assumption, and none of the environment variables your interactive shell profile sets up. Scripts that rely on tools found via an interactive PATH, relative file paths, or environment variables set in .bashrc will behave differently or fail outright under cron, since none of that setup runs for a cron-triggered process.
How do I make sure a scheduled script uses absolute paths correctly?
Set an explicit working directory at the top of the script with cd /path/to/script/dir || exit 1, and use absolute paths for any files the script reads or writes rather than relative ones. Also use absolute paths for any commands not built into the shell, since cron uses a minimal PATH that may not include directories like /usr/local/bin depending on the system, or set PATH explicitly at the top of the script.
How do I prevent a scheduled script from running twice if the previous run has not finished?
Use flock to acquire an exclusive lock on a lock file at the start of the script, which causes a second invocation to exit immediately if the lock is already held rather than running concurrently. A common pattern is flock -n /tmp/myscript.lock /opt/scripts/myscript.sh in the crontab entry itself, or using flock inside the script wrapping the main logic.
What is the best way to log output from a scheduled script?
Redirect both stdout and stderr to a log file using >> /path/to/log 2>&1 in the cron entry, or configure logging inside the script itself using a proper log rotation setup so logs do not grow unbounded. For systemd timers, output sent to stdout/stderr is automatically captured by the journal and viewable with journalctl, without needing manual log file redirection.
How do I get notified when a scheduled script fails?
The script should check the exit status of critical commands and exit with a nonzero status itself on failure. From there, wrap the scheduled invocation with a notification mechanism: mailing cron output (if a mail transport is configured), a small wrapper script that curls a webhook (like a Slack or messaging integration) on nonzero exit, or a dedicated monitoring tool that expects a periodic heartbeat and alerts when one is missed.
Should I use cron or systemd timers for scheduled scripts?
Both work well; systemd timers offer better logging through the journal, dependency management (waiting for another unit before starting), and easier one-off testing with systemctl start on the associated service unit. cron is simpler to set up for straightforward periodic tasks and remains extremely widely supported across nearly every Linux system. Neither is objectively wrong; the choice often comes down to whether the rest of the system already leans on systemd unit management.