Skip to content

Cron Beyond the Basics: Environment, Locking, and Logging

Make cron jobs production-grade: reproduce cron's minimal environment, escape % signs, stop overlapping runs with flock, bound runtime, and log to the journal properly.

●By 10 min read

Getting the five time fields right is the easy part of cron, and our complete cron guide covers it. The jobs that cause incidents are the ones with a correct schedule: the script that works from your shell and silently fails at 02:15, the backup that started again before the last one finished, the job whose only log is an email nobody reads.

This article is about the three things that separate a crontab line from a reliable scheduled job: the environment cron gives you, locking so runs never overlap, and logging you can actually search. It ends with a small wrapper that applies all three to any job.

What Cron Gives You: An Almost Empty Environment

Cron does not run your login shell and does not read ~/.bashrc or ~/.profile. A job gets a handful of variables and /bin/sh. The quickest way to see exactly what it gets is to ask it:

# Temporary entry: dump the environment once a minute

* * * * * env > /tmp/cron-env.txt

$ cat /tmp/cron-env.txt

HOME=/home/deploy

LOGNAME=deploy

PATH=/usr/bin:/bin

SHELL=/bin/sh

PWD=/home/deploy

# Reproduce that environment interactively to debug a failing job

$ env -i HOME="$HOME" LOGNAME="$USER" PATH=/usr/bin:/bin SHELL=/bin/sh \

/bin/sh -c '/opt/scripts/backup.sh'

SymptomCause in cronFix
command not foundPATH is only /usr/bin:/binSet PATH at the top of the crontab, or use absolute paths
Syntax errors on [[ or sourceSHELL is /bin/sh β€” dash on Debian/UbuntuSHELL=/bin/bash, or a #!/bin/bash script
date output cut off% is special in crontabdate +\%F
File not found with relative pathsWorking directory is $HOMEcd /srv/app || exit 1
SSH or git push failsNo ssh-agent, no SSH_AUTH_SOCKDedicated key referenced with -i or in ~/.ssh/config
UnicodeEncodeError, odd sort orderLANG often unset, so the C locale appliesLANG=C.UTF-8 in the crontab

No expansion in variable lines β€” a crontab line like PATH=$PATH:/opt/bin does not expand $PATH; the value is taken literally. Write the full list out.

Time zones β€” cron uses the system time zone. cronie (RHEL, Fedora) also honours a CRON_TZ= line for the entries that follow it; check man 5 crontab on other implementations before relying on it.

The trailing newline β€” the last line of a crontab must end with a newline. Without one, crontab -e refuses to install the file or the final entry is ignored, depending on the implementation.

The Percent Sign Trap

In a crontab command, an unescaped % is turned into a newline, and everything after the first one is fed to the command on standard input. Date formats are the usual victim:

# Broken: cron runs "tar czf /backup/app-" and pipes "F.tgz /srv/app" to stdin

0 3 * * * tar czf /backup/app-$(date +%F).tgz /srv/app

# Fixed: escape every percent sign

0 3 * * * tar czf /backup/app-$(date +\%F).tgz /srv/app

# Better: keep the logic in a script, where % needs no escaping

0 3 * * * /opt/scripts/backup-app.sh

The same applies to random jitter, which spreads load when many hosts share a schedule. $RANDOM is a bash feature, so it also needs SHELL=/bin/bash:

SHELL=/bin/bash

# Start within 15 minutes after 04:00 instead of exactly on the minute

0 4 * * * sleep $((RANDOM \% 900)); /opt/scripts/report.sh

Stop Overlapping Runs with flock

Cron starts a job on schedule whether or not the previous run has finished. A five-minute job that sometimes takes seven will eventually run twice in parallel, and two copies of a sync or a database job rarely end well. flock from util-linux takes a kernel lock on a file that is released automatically when the process exits β€” no stale pidfiles.

# Skip this run if the previous one still holds the lock

*/5 * * * * flock -n /run/lock/sync.lock /opt/scripts/sync.sh

# Wait up to 60 seconds for the lock, then give up with exit code 75

*/5 * * * * flock -w 60 -E 75 /run/lock/sync.lock /opt/scripts/sync.sh

# Or take the lock inside the script itself, on file descriptor 9

#!/bin/bash

exec 9>/run/lock/sync.lock

if ! flock -n 9; then

echo "previous run still active, skipping" >&2

exit 0

fi

# ...rest of the job runs while fd 9 holds the lock

OptionMeaning
-nNon-blocking: fail immediately if the lock is held (exit 1 by default)
-w SECONDSWait up to this long for the lock, then fail
-E CODEExit code to use when the lock could not be taken, so skips are distinguishable from job failures
-oClose the lock descriptor before running the command, so children that outlive it do not hold the lock
-s / -xShared or exclusive (default) lock

Where to put lock files β€” /run/lock is on tmpfs and cleared at boot, which is what you want. It is usually world-writable on Debian and Ubuntu but root-only on RHEL and Fedora, so non-root jobs need a directory the job user owns.

Avoid /tmp for long-held locks β€” systemd-tmpfiles deletes old files from /tmp. If the lock file is removed while held, the next run creates a new file, locks that, and you have two instances again.

Inherited descriptors keep the lock β€” anything the script starts in the background inherits fd 9 and holds the lock after the script exits. Close it for such children with 9>&-.

sponsored

Bound the Runtime with timeout

Locking stops overlap, but a job that hangs forever now blocks every future run. Put an upper bound on it with timeout from coreutils:

# SIGTERM after 2 hours; SIGKILL 60 seconds later if it ignores that

0 1 * * * flock -n /run/lock/backup.lock timeout -k 60s 2h /opt/scripts/backup.sh

$ timeout 2s sleep 10; echo "exit=$?"

exit=124

Exit code 124 β€” means the time limit was hit. With -k, a process that needed SIGKILL exits 137. Log these distinctly β€” a job that times out every night is failing, even if nothing else complains.

Order matters β€” flock outside, timeout inside: the lock is held for the whole bounded run and released when timeout exits.

Logging You Can Actually Search

By default, any output from a job is emailed to the crontab owner. On a server without a mail transfer agent that output is thrown away β€” Debian's cron logs (CRON) info (No MTA installed, discarding output) and moves on. Decide where output goes on purpose:

# Append both stdout and stderr to a file (order matters: > first, then 2>&1)

0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# Send output to syslog/journal with a searchable tag

0 2 * * * /opt/scripts/backup.sh 2>&1 | logger -t backup

# Or run the job attached directly to the journal

0 2 * * * systemd-cat -t backup /opt/scripts/backup.sh

$ journalctl -t backup --since today

Oct 02 02:00:01 web01 backup[48211]: dumping database app

Oct 02 02:03:47 web01 backup[48211]: uploaded app-2026-10-02.sql.gz (412M)

# Where cron itself logs that a job started

$ journalctl -u cron --since today # Debian, Ubuntu

$ journalctl -u crond --since today # RHEL, Fedora (also /var/log/cron)

2>&1 placement β€” cmd 2>&1 >> file points stderr at the old stdout β€” cron's mail β€” before stdout is redirected. Always write >> file 2>&1.

Pipes hide exit codes β€” in backup.sh | logger, the job's status is logger's, which is almost always 0. Use systemd-cat, or log from inside the script, when you need the real exit code.

Log from inside the script β€” in bash, exec > >(logger -t backup) 2>&1 at the top sends everything that follows to the journal while the script keeps its own exit status.

Quiet unless it fails β€” chronic from the moreutils package swallows output when the command succeeds and prints it all when it fails. Combined with MAILTO, you get mail only for failures.

Rotate file logs β€” a log file that is appended to on each run needs a logrotate entry. Because each run reopens the file, plain rotation works without copytruncate.

A Wrapper That Does All of It

Rather than repeating flock, timeout and logging on every line, put them in one wrapper and keep the crontab readable. Every run gets a lock per job name, a time limit, journal logging, a clear result line, and an optional heartbeat ping for a dead man's switch:

#!/bin/bash

# /usr/local/bin/cron-run β€” usage: cron-run NAME TIMEOUT COMMAND [ARGS...]

set -u

name=$1 limit=$2

shift 2

exec > >(logger -t "cron-run/$name") 2>&1

exec 9>"/run/lock/cron-run.$name.lock"

if ! flock -n 9; then

echo "skipped: previous run still holds the lock"

exit 0

fi

start=$(date +%s)

timeout -k 60s "$limit" "$@"

rc=$?

echo "finished rc=$rc duration=$(( $(date +%s) - start ))s"

if [ "$rc" -eq 0 ] && [ -n "${HEARTBEAT_URL:-}" ]; then

curl -fsS -m 10 --retry 3 -o /dev/null "$HEARTBEAT_URL"

fi

exit "$rc"

# /etc/cron.d/app β€” system crontab files need a user field

SHELL=/bin/bash

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

LANG=C.UTF-8

[email protected]

15 2 * * * root cron-run backup 2h /opt/scripts/backup.sh

*/5 * * * * deploy cron-run sync 4m /opt/scripts/sync.sh

/etc/cron.d naming β€” on Debian and Ubuntu, files in /etc/cron.d whose names contain a dot are ignored, so app works and app.cron silently does not. Files must be owned by root and not group- or world-writable.

Lock directory for non-root users β€” as written, the wrapper locks in /run/lock. On RHEL and Fedora that directory is root-only, so point it at a directory the deploy user owns.

Heartbeats catch silence β€” logs and mail tell you a job failed. Only a heartbeat that stops arriving tells you a job never ran at all β€” crond stopped, the crontab was overwritten, the host was rebuilt.

When to switch to systemd β€” systemd timers give you the lock (a service cannot start twice), journal logging, RandomizedDelaySec= and Persistent=true catch-up without a wrapper. If you are on systemd anyway, the systemd generator builds the unit pair, and our cron expressions guide helps translate the schedule.

Frequently Asked Questions

Why does my script work manually but not from cron?

Almost always the environment. Cron runs jobs with /bin/sh, a PATH of /usr/bin:/bin, the home directory as working directory, and no shell profile, ssh-agent or locale settings. Dump cron's environment with a temporary 'env > /tmp/cron-env.txt' entry, then reproduce it with env -i to see the failure yourself.

How do I prevent a cron job from running twice at the same time?

Wrap it in flock: 'flock -n /run/lock/job.lock /path/to/job.sh' skips the run if the previous one still holds the lock. The kernel releases the lock when the process exits, so there are no stale lock files to clean up after a crash.

Why does a % sign break my crontab entry?

Cron converts an unescaped % in the command field into a newline and sends everything after the first one to the command's standard input. Escape it as \% in the crontab, or move the command into a script, where percent signs need no escaping.

Where do cron job logs go?

Cron logs that a job started to syslog or the journal: journalctl -u cron on Debian and Ubuntu, journalctl -u crond or /var/log/cron on RHEL and Fedora. The job's own output is emailed to the owner, or discarded if no MTA is installed, unless you redirect it to a file, logger or systemd-cat.

Can I use variables like $HOME in crontab PATH lines?

No. Environment assignment lines in a crontab are not processed by a shell, so PATH=$PATH:/opt/bin sets PATH to that literal text. Write the full value out, or set variables inside the script the job runs.

sponsored

Related Tools

See also