skip to content

Running Scripts from cron and systemd

A script that works in your terminal often fails under cron because none of your interactive environment is there — no PATH from .bashrc, no login shell, and stray output turns into mail. Under systemd the contract differs again: exit codes matter and anything you print lands in the journal.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

One nightly cron job stopped working weeks ago and nobody noticed; another crontab line was "fixed" with `> /dev/null 2>&1` after it flooded an operator's mailbox. What does cron do with whatever a scheduled command prints, and how should a scheduled script's output be handled instead?

level: middleimportance: must knowfreq 65%

answer

  1. output is the alert, not the exit code
  2. nothing printed means nothing mailed
  3. MAILTO and the missing mail transport
  4. 2>&1 to /dev/null hides errors too
  5. quiet on success, log to a file

basics

~20 s

cron mails any output a job produces to the crontab owner, or to MAILTO if set, and mails nothing when the job prints nothing. Appending > /dev/null 2>&1 discards errors too, which is why failures go unnoticed.

solid answer

~50 s

cron captures a job's stdout and stderr; if there is any output it mails it to the crontab owner, or to the address in `MAILTO`, and `MAILTO=""` disables mail for the entries that follow. That mechanism is fragile: it needs a working local mail transport, and on a minimal server or container there usually is none, so cron just logs that it discarded the output. Because a chatty job then mails on every successful run, people silence it with `> /dev/null 2>&1` — which throws away the error text as well, so the job fails silently forever. The better shape is a script that is quiet on success and prints only real problems, with output appended to a log file, and failure reported to whatever actually pages you rather than to a mail spool. Note that cron mails output, not exit status, so a job that fails silently with a non-zero code produces no mail at all.

code

bash · 8 lines
bash
#!/usr/bin/env bash
# Quiet on success, loud on failure, with a durable log.
log=/var/log/backup.log

if ! /opt/app/do-backup --quiet >>"$log" 2>&1; then
  printf 'backup failed on %s, see %s\n' "$(hostname)" "$log" >&2
  exit 1
fi

go deeper

for a junior

Remember that cron mails whatever the job prints to the crontab owner, and that redirecting both streams to /dev/null makes errors disappear along with the noise.

for a middle

Explain the MAILTO variable and its empty-string form, and be able to say why the output-is-the-alert design breaks on a host with no local mail transport.

for a senior

Show the operational fix: scripts quiet on success, output appended to a real log, and failures routed to actual alerting rather than a local mail spool.

for a principal

Own the distinction between a job that failed and a job that never ran, and argue for heartbeat or dead-man's-switch monitoring across the fleet, since no output-based mechanism can detect absence.

## What cron actually does When cron runs an entry it attaches the command's standard output and standard error to a pipe rather than to a terminal, since there is no terminal. If anything at all comes out of that pipe, cron composes a mail message containing the output and sends it to the crontab's owner. If nothing comes out, no mail is sent. That is the whole notification design: **output is the alert**. Two variables in the crontab steer it. `MAILTO=address` sends the mail somewhere else, and it applies to every entry below it in the file, so you can set different destinations for different blocks. `MAILTO=""` disables mail entirely for the entries that follow. Crucially, cron mails **output**, not **exit status**. A job that exits 1 while printing nothing generates no mail. Most cron implementations do log the run to syslog or the journal, and some record the exit status there, but that is a log line nobody reads, not a notification. ## Why the mail so often never arrives Mail delivery is not cron's job — it hands the message to a local mail transport agent. Desktop and traditional server installs have one; minimal cloud images, containers and hardened hosts frequently do not. In that case the output is simply dropped, and the cron daemon typically logs a note that no mail transport is installed and the output was discarded. Teams then get the worst outcome available: a mechanism they believe is alerting them, that has never delivered anything. Even where mail works, it goes to a local spool file on that host, not to a rotation, a chat channel or an on-call system. It is a 1980s design that survives because it is the default. ## The `> /dev/null 2>&1` reflex A job that prints a progress line on every successful run mails the owner every night. The usual response is to append `> /dev/null 2>&1` to the crontab line. That redirects standard output to the bit bucket and then points standard error at the same place, so **both** streams are discarded. The mailbox goes quiet, and so does the failure that arrives three weeks later. This is the single most common way a scheduled job becomes invisible. ## The shape that works **Make the script quiet on success.** The convention that makes cron's design usable is silence when there is nothing wrong. A script that prints nothing on a clean run needs no redirection at all, and any output it produces is by definition worth reading. **Keep a durable log anyway.** Diagnosis needs history, and mail is not history: ```bash 0 3 * * * /opt/app/backup.sh >> /var/log/backup.log 2>&1 ``` This keeps everything, forfeits mail, and needs its own rotation — an acceptable trade when the script reports failures through a channel that actually reaches a human. **Report failures deliberately.** Do not rely on the mail path being intact. Have the script call your alerting on a failing exit: ```bash if ! /opt/app/backup.sh >>"$log" 2>&1; then /usr/local/bin/notify-oncall "backup failed, see $log on $(hostname)" fi ``` **Or keep the output but only on failure.** The `chronic` wrapper from moreutils runs a command, swallows its output when it succeeds, and prints everything it captured when it fails — which turns a chatty command into a cron entry that mails only when something is wrong, without editing the command itself. ## Answering "did it even run?" When a job appears not to have run, distinguish three states: cron never started it, it started and failed, or it ran fine and did nothing visible. The cron daemon's own log records the first, an exit status or your log file records the second. A run marker the script itself writes — a touched file or a heartbeat ping — is what lets you assert the third, and a monitoring check on the freshness of that marker is far more reliable than waiting for a message that stopped arriving.

  • Does cron mail anything when a job exits non-zero but prints nothing?
    No. Mail is triggered by output on stdout or stderr, not by exit status. A silent failing job produces no mail at all. Most cron daemons log the run, and some record the status there, but if you want a non-zero exit to notify anyone the script has to say something or call your alerting itself.
  • If you must keep both a log file and cron's mail behaviour, how do you get them?
    Send the routine output to the log and leave stderr alone: `>>/var/log/job.log` with no `2>&1`. Then successful chatter is filed away and anything on stderr still triggers mail. Alternatively wrap the command in moreutils' `chronic`, which buffers all output and prints it only if the command fails.
  • Why is a heartbeat file or a monitoring ping better than relying on cron mail?
    Mail only tells you about runs that happened and produced output; it cannot tell you that a job never started because cron was down, the crontab was wiped, or the host was off. A freshness check on a marker the job writes detects absence, which is the failure mode mail is structurally blind to.

saying these in an interview costs you the question

  • Believes cron mails a notification whenever a job exits non-zero
  • Adds > /dev/null 2>&1 to silence a job and calls it fixed
  • Assumes cron mail is delivered on a host with no mail transport
  • Thinks MAILTO="" disables the job rather than its mail
  • Treats a chatty successful run as normal instead of noise

context

open as a page

A container's entrypoint is a bash script whose last line is `myserver --config /etc/app.yml`. Stopping the container always takes the full grace period before the process dies, and the server never runs its shutdown routine. What is wrong with that last line, and what is the fix?

level: seniorimportance: must knowfreq 55%

basics

~20 s

The shell stays as process 1 and the server runs as its child, so the runtime's SIGTERM goes to the shell and never reaches the server. Ending the line with exec replaces the shell, making the server process 1.

open as a page

A backup script that works when you run it from its own directory fails from a crontab entry with "config/settings.conf: No such file or directory". Which working directory does cron give a scheduled command, which one does systemd give a system service, and how should the script locate its own files?

level: juniorimportance: should knowfreq 50%

basics

~20 s

cron starts the command in the user's home directory and systemd starts a system service in the root directory, so relative paths inside the script resolve somewhere unexpected. Use absolute paths, or derive the script's own directory before opening anything.

open as a page

A maintenance script that will run as a systemd service currently opens /var/log/maintenance.log itself, writes its own timestamps and truncates the file when it grows too large. What should a script run under systemd do with its output instead, and how does it mark one line as an error?

level: seniorimportance: should knowfreq 38%

basics

~20 s

A script run under systemd should just print to stdout and stderr; systemd captures both into the journal and adds the timestamp, unit and PID. Both streams get the same default priority, so mark errors with a <3> prefix on the line.

open as a page

This line works when pasted into an interactive shell but not from a crontab: `0 3 * * * /opt/bin/dump.sh --stamp $(date +%Y%m%d)`. What does cron do with the `%` character in a crontab command, and how do you write the entry correctly?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

In a crontab, an unescaped percent sign ends the command and becomes a newline: everything after the first one is fed to the command as standard input. Escape each one as %, or move the logic into the script.

open as a page