<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[HelloCron Engineering]]></title><description><![CDATA[Engineering notes from HelloCron: cron jobs, scheduled tasks, queue workers and the monitoring that tells you when a job never ran, not just when it failed.]]></description><link>https://hellocron.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad63f8a0d986cb6e9539224/049aaaaa-455d-4fa6-a459-2fb12e0795ce.png</url><title>HelloCron Engineering</title><link>https://hellocron.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 09:44:55 GMT</lastBuildDate><atom:link href="https://hellocron.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your cron job did not fail. It never ran.]]></title><description><![CDATA[Exit codes tell you how a job ended. They tell you nothing about a job that never started, which is the failure mode that actually costs you data.]]></description><link>https://hellocron.hashnode.dev/your-cron-job-did-not-fail-it-never-ran</link><guid isPermaLink="true">https://hellocron.hashnode.dev/your-cron-job-did-not-fail-it-never-ran</guid><category><![CDATA[cron]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Bash]]></category><category><![CDATA[monitoring]]></category><dc:creator><![CDATA[HelloCron]]></dc:creator><pubDate>Mon, 19 Oct 2026 16:41:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad63f8a0d986cb6e9539224/ea707534-0dc5-499d-a7f1-9649e1801b77.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There is a specific kind of outage that does not show up in any log you are reading.</p>
<p>A backup job runs nightly at 3am. One night the machine reboots for a kernel patch and comes back with <code>crond</code> masked. No error is raised, because nothing ran. No alert fires, because nothing failed. The job's log file keeps its last entry from the night before, and that entry says <code>SUCCESS</code>.</p>
<p>Eleven days later somebody needs the backup.</p>
<p>This is not a story about backups. It is about the shape of the failure: <strong>the absence of an event is not an event</strong>, and almost every monitoring setup only watches for events.</p>
<h2>Why exit codes are not enough</h2>
<p>The standard advice is to check exit codes and alert on non-zero. That is correct and insufficient. An exit code is produced by a process that started. Here is an incomplete list of ways a scheduled job produces no exit code at all:</p>
<ul>
<li><p><code>crond</code> is not running, or was never enabled after a reboot</p>
</li>
<li><p>the crontab line has an unescaped <code>%</code>, so cron silently truncates the command</p>
</li>
<li><p>the script is on a filesystem that failed to mount</p>
</li>
<li><p>the user's shell was changed to <code>/sbin/nologin</code></p>
</li>
<li><p>the disk is full, so the redirect target cannot be opened and the shell bails</p>
</li>
<li><p><code>PATH</code> under cron is not your login <code>PATH</code>, so <code>python</code> is not found</p>
</li>
<li><p>the container the job lived in was rescheduled and the sidecar never came back</p>
</li>
<li><p>DST shifted the wall clock and the 2:30am job was skipped for the year</p>
</li>
</ul>
<p>And the classic: cron mails output to the local user, <code>MAILTO</code> is unset, nobody has read <code>/var/mail/root</code> since 2019.</p>
<p>Every one of those is silent. A monitoring rule of the form "alert when the job reports a failure" cannot catch any of them, because the job never reported anything.</p>
<h2>Invert the check</h2>
<p>The fix is old and has a grim name: a <strong>dead man's switch</strong>. Instead of waiting for bad news, you require good news on a schedule. The job tells you it ran. If the report does not arrive inside the window you expect, the absence itself is the alert.</p>
<p>Concretely, that means every scheduled job gets three lines around it:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

PING="https://example-monitor.invalid/ping/db-backup"

curl -fsS "$PING?status=run" &gt;/dev/null || true
./do-the-backup.sh
curl -fsS "$PING?status=complete" &gt;/dev/null || true
</code></pre>
<p>Two details in there matter more than they look.</p>
<p><strong>The</strong> <code>|| true</code> <strong>is deliberate.</strong> Your monitoring must never be able to fail your job. If the monitoring endpoint is down, the backup still has to run. Any wrapper that can turn a monitoring outage into a production outage is worse than no wrapper.</p>
<p>Note that <code>-S</code> makes curl print connection errors to stderr, and under cron stderr becomes mail. If a monitoring outage would fill your inbox, add <code>2&gt;/dev/null</code> to the ping, not to the job.</p>
<p><code>status=run</code> <strong>is not optional.</strong> Reporting only completion tells you whether jobs finish. Reporting the start as well tells you how long they take, and it lets you catch the run that started at 3am and is still going at 7am. A job that hangs forever never reports failure either.</p>
<h2>Report the failure too, and mean it</h2>
<p><code>set -e</code> will exit before your failure ping if you are not careful. Use a trap:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

PING="https://example-monitor.invalid/ping/db-backup"
SERIES="$(date +%s)-$$"        # correlates this run's start and end

# curl -G puts every --data on the query string, and --data-urlencode escapes
# free text for us. No jq, no manual %20 arithmetic.
ping() { curl -fsS -G "$PING" --data "series=$SERIES" "$@" &gt;/dev/null || true; }

fail() {
  local code=$?
  ping --data "status=fail" --data "exit_code=$code" \
       --data-urlencode "msg=failed at line ${BASH_LINENO[0]}"
  exit "$code"
}
trap fail ERR

ping --data "status=run"
START=$(date +%s)

./do-the-backup.sh

ping --data "status=complete" --data "duration=$(( $(date +%s) - START ))" \
     --data "exit_code=0"
</code></pre>
<p>The <code>series</code> value is what turns two independent pings into one run. Without it, a job that runs every five minutes and takes six minutes gives you interleaved starts and ends that no amount of timestamp guessing will pair up correctly.</p>
<p><code>${BASH_LINENO[0]}</code> is the line in the caller, not the line inside <code>fail</code>, which is the one you actually want in the alert. Getting this backwards produces a message that always points at the same line of your error handler.</p>
<h2>The part everyone gets wrong: the grace period</h2>
<p>Once absence is an alert, you have to define absence, and this is where these setups earn their reputation for crying wolf.</p>
<p>A job scheduled every five minutes does not run exactly every five minutes. It runs when the scheduler gets to it, after the previous run of something else, on a machine whose clock drifts. If your rule is "alert if no report for five minutes and one second", you will be paged at 3am by jitter, you will mute the alert, and three weeks later the real outage will arrive muted.</p>
<p>Two rules that have held up for me:</p>
<ol>
<li><p><strong>Grace period covers the worst normal run, not the average one.</strong> Look at your actual durations for a month. If a nightly job usually takes 4 minutes but takes 40 on the first of the month when the table is bigger, your window is built from the 40.</p>
</li>
<li><p><strong>Alert on the second miss for anything that runs more than once an hour.</strong> A single missed five-minute run is noise. Two in a row is a signal. For daily jobs, invert this: one miss is already a full day of nothing, so alert immediately.</p>
</li>
</ol>
<p>The number you are choosing is not a technical parameter. It is the answer to "how long am I willing to not know", and that answer is different for a backup than for a cache warmer.</p>
<h2>Three things worth watching beyond "did it run"</h2>
<p>Once the pings are in place, they carry more than presence:</p>
<p><strong>Duration drift.</strong> A backup that grew from 4 to 26 minutes over six months has not failed yet, and it is going to. Compare each run against a rolling median of the last 30. This is the cheapest early warning you will get anywhere in your infrastructure.</p>
<p><strong>Overlap.</strong> If the next run starts while the previous one is still going, you have either a slow job or a data race, and both are worth knowing about. <code>flock</code> prevents it:</p>
<pre><code class="language-bash">* * * * * /usr/bin/flock -n /tmp/job.lock /opt/scripts/job.sh
</code></pre>
<p>Note that <code>flock -n</code> makes the second run exit immediately, which your monitoring will see as a missed run. That is correct behaviour and you want the alert, not silence.</p>
<p><strong>Partial success.</strong> A job that processes 900 of 1000 rows and exits 0 is a lie your exit code is telling you. If a job can partially succeed, ping the count, not just the status, and alert on the count.</p>
<h2>Where the state field trips people up</h2>
<p>One last practical note, because it costs me time in every integration. Whatever service you use, be exact about which field carries the state. Most APIs of this kind take a <code>status</code> or <code>state</code> query parameter with a small set of values, typically along the lines of <code>run</code>, <code>complete</code>, <code>fail</code>, <code>skip</code>. Sending a state the service does not recognise is usually a 400 you will not notice, because you wrote <code>|| true</code> earlier and quite rightly do not want a broken ping to break the job. Test the failure path once, on purpose, before you trust it:</p>
<pre><code class="language-bash"># does a real fail ping actually produce an alert? find out on a Tuesday afternoon,
# not during an incident
curl -fsS "$PING?status=fail&amp;msg=deliberate+test"
</code></pre>
<p>An untested alert path is an assumption, and this whole article is about not assuming that silence means everything is fine.</p>
<hr />
<p><strong>Disclosure:</strong> I build <a href="https://hellocron.com">HelloCron</a>, a monitoring service for exactly this: cron jobs, scheduled tasks, and HTTP/SSL endpoints, with the dead man's switch as the default rather than an add-on. The free plan covers 15 monitors without a card, which is more than enough to put the pattern above on every job you actually care about. The examples in this post are deliberately vendor-neutral, because the pattern is the useful part and it works against anything that accepts an HTTP request.</p>
<p>If you just want to check whether a cron expression means what you think it means, there is a <a href="https://hellocron.com/tools/cron-parser/">cron expression parser</a> that shows the next five runs in your own timezone. It runs entirely in your browser, so nothing you paste goes anywhere, and it needs no account.</p>
]]></content:encoded></item><item><title><![CDATA[Where are cron logs? Finding cron output on Ubuntu, Debian, RHEL, Alpine and in containers]]></title><description><![CDATA[Cron logs live somewhere different on every distro, and your job's output is in none of them. Where to look for both, system by system.]]></description><link>https://hellocron.hashnode.dev/where-are-cron-logs-finding-cron-output-on-ubuntu-debian-rhel-alpine-and-in-containers</link><guid isPermaLink="true">https://hellocron.hashnode.dev/where-are-cron-logs-finding-cron-output-on-ubuntu-debian-rhel-alpine-and-in-containers</guid><category><![CDATA[cron]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Docker]]></category><dc:creator><![CDATA[HelloCron]]></dc:creator><pubDate>Mon, 19 Oct 2026 16:29:52 GMT</pubDate><content:encoded><![CDATA[<p>"Where are cron logs?" is really two questions, and most of the confusion around cron comes from mixing them up. The first is: did cron start my job at all? That is answered by the <strong>cron daemon's log</strong>, and where it lives depends on your distribution. The second is: what did my job print, and why did it fail? That is the <strong>job's output</strong>, and cron does not write it to any log file. It mails it, and on most servers that mail goes nowhere.</p>
<p>This is the second post in the <strong>Cron in production</strong> series on the HelloCron blog. The <a href="https://hellocron.com/blog/cron-job-not-running/">first one</a> covered the reasons a job does not run. This one is about finding the evidence.</p>
<h2>Part 1: the cron daemon log, by system</h2>
<p>Cron itself logs through syslog. Whether that ends up in a file, in the systemd journal, or both, depends on whether a syslog daemon is installed. The fastest way to find out is to check for the file first and fall back to the journal.</p>
<h3>Ubuntu and Debian</h3>
<p>With <code>rsyslog</code> installed, cron lines go to <code>/var/log/syslog</code>:</p>
<pre><code class="language-bash">grep CRON /var/log/syslog
</code></pre>
<p>If there is no <code>/var/log/syslog</code>, the machine has no syslog daemon (common on recent Debian and on minimal Ubuntu images) and everything is in the journal:</p>
<pre><code class="language-bash">journalctl -u cron --since "1 hour ago"
</code></pre>
<p>A dedicated <code>/var/log/cron.log</code> exists only if someone enabled it. The line is in <code>/etc/rsyslog.d/50-default.conf</code>, commented out by default:</p>
<pre><code class="language-plaintext">#cron.*                          /var/log/cron.log
</code></pre>
<p>Uncomment it and restart rsyslog, and cron gets its own file. The entries still appear in <code>/var/log/syslog</code> too.</p>
<p>Debian's cron can log more than the start of each job. The <code>-L</code> option in <code>/etc/default/cron</code> is a bitmask: 1 logs job start, 2 logs job end, 4 logs failed jobs with their exit status, 8 adds the process id. The default is 1, which is why you normally never see a failure in the cron log. This makes failures visible:</p>
<pre><code class="language-bash"># /etc/default/cron
EXTRA_OPTS="-L 15"
</code></pre>
<h3>RHEL, Rocky, AlmaLinux, CentOS and Fedora</h3>
<p>These ship <code>cronie</code>, and <code>rsyslog</code> routes its messages to a dedicated file:</p>
<pre><code class="language-bash">grep CROND /var/log/cron
journalctl -u crond --since "1 hour ago"
</code></pre>
<p>On minimal Fedora or cloud images without rsyslog, only the second command works. Note the daemon is <code>crond</code>, not <code>cron</code>, so <code>journalctl -u cron</code> returns nothing and looks like "no logs" when it is really "wrong unit name".</p>
<h3>Alpine</h3>
<p>Alpine uses the BusyBox <code>crond</code>. It logs through syslog, and with the BusyBox <code>syslogd</code> that means <code>/var/log/messages</code>:</p>
<pre><code class="language-bash">grep crond /var/log/messages
</code></pre>
<p>BusyBox crond takes a log level with <code>-l</code> (lower is more verbose) and can write straight to a file with <code>-L</code>. The options live in <code>/etc/conf.d/crond</code>. If the default level is too quiet to show job starts, lower it there and restart the service with <code>rc-service crond restart</code>.</p>
<h3>Inside Docker and other containers</h3>
<p>In a container there is usually no syslog and no journal, so cron's log goes nowhere unless you run the daemon in the foreground and point its logging at the terminal. Cronie:</p>
<pre><code class="language-bash">crond -n
</code></pre>
<p>BusyBox (Alpine images), with log output on stderr:</p>
<pre><code class="language-bash">crond -f -d 8
</code></pre>
<p>Either way the lines end up in <code>docker logs &lt;container&gt;</code>. Your jobs' own output does not: it still goes to mail, which does not exist in the container. Redirect it to the container's stdout explicitly:</p>
<pre><code class="language-plaintext">*/5 * * * * /app/sync.sh &gt; /proc/1/fd/1 2&gt;&amp;1
</code></pre>
<p><code>/proc/1/fd/1</code> is the stdout of process 1, which is the container's main process. That is the only thing <code>docker logs</code> reads.</p>
<h2>What the daemon log actually tells you</h2>
<p>A healthy start looks like this on Debian or Ubuntu:</p>
<pre><code class="language-plaintext">Oct 13 02:00:01 web1 CRON[18233]: (root) CMD (/opt/scripts/backup.sh)
</code></pre>
<p>And like this with cronie:</p>
<pre><code class="language-plaintext">Oct 13 02:00:01 web1 CROND[18233]: (root) CMD (/opt/scripts/backup.sh)
</code></pre>
<p>That line means cron forked a process for the job. It does not mean the job succeeded, and by default nothing is logged when it fails. If the <code>CMD</code> line is missing at the time you expected, cron never started the job, and the causes are in the <a href="https://hellocron.com/blog/cron-job-not-running/">previous post</a>. Check that the schedule means what you think with the <a href="https://hellocron.com/tools/cron-parser/">cron expression parser</a> first; it shows the next five runs in your own time zone.</p>
<p>Two other lines are worth recognising:</p>
<pre><code class="language-plaintext">CRON[18233]: (CRON) info (No MTA installed, discarding output)
</code></pre>
<p>The job printed something, cron tried to mail it, there is no mail system, so the output was thrown away. You just lost the error message you are looking for.</p>
<pre><code class="language-plaintext">CRON[18201]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)
</code></pre>
<p>This is just cron opening a session before the job. It is noise, but a lot of noise: on a busy crontab it drowns the <code>CMD</code> lines, which is why the <code>grep</code> examples above filter on <code>CMD</code> or on the daemon name.</p>
<h2>Part 2: where your job's output went</h2>
<p>Everything a job writes to stdout and stderr is collected by cron and sent as an email to the user whose crontab it is, or to the address in <code>MAILTO</code>. If a local mail system is installed, that mail is in the user's mailbox:</p>
<pre><code class="language-bash">mail                # or: mailx
less /var/mail/root
</code></pre>
<p>If no mail system is installed, the output is discarded, and the daemon log says so (see above). This is the default state of most servers built in the last ten years, which is why "cron has no logs" is such a common belief: the logs existed for a fraction of a second and were deleted.</p>
<p>You have three ways to keep the output. Pick one per job.</p>
<p><strong>Redirect to a file.</strong> The simplest and the most common:</p>
<pre><code class="language-plaintext">0 2 * * * /opt/scripts/backup.sh &gt;&gt; /var/log/backup.log 2&gt;&amp;1
</code></pre>
<p>The <code>2&gt;&amp;1</code> matters. Without it, stderr still goes to mail and the file only holds the uninteresting half. Add the file to <code>logrotate</code>, because a job that logs every five minutes will eventually fill the disk. Lines have no timestamps unless the script adds them; <code>ts</code> from <code>moreutils</code> does that cheaply: <code>backup.sh 2&gt;&amp;1 | ts '%F %T' &gt;&gt; /var/log/backup.log</code>.</p>
<p><strong>Send it to syslog or the journal.</strong> Then the job's output sits next to the daemon's log, with timestamps, rotation and remote shipping already handled:</p>
<pre><code class="language-plaintext">0 2 * * * /opt/scripts/backup.sh 2&gt;&amp;1 | logger -t backup
</code></pre>
<p>Read it with <code>journalctl -t backup</code> or <code>grep backup /var/log/syslog</code>. One trap: the exit status cron sees is now <code>logger</code>'s, which is always 0. If you also alert on exit codes, wrap the job in <code>bash -o pipefail -c '...'</code> or put the pipe inside the script.</p>
<p><strong>Keep the mail, but make it reach someone.</strong> Install a minimal relay such as <code>msmtp</code> or a local <code>postfix</code> configured to forward to your mail provider, and set <code>MAILTO</code> at the top of the crontab. This is the original design and it works, but you then get an email for every run that prints anything, and people learn to filter them within a week.</p>
<h2>A quick diagnosis table</h2>
<table>
<thead>
<tr>
<th>What you see</th>
<th>What it means</th>
<th>Where to look next</th>
</tr>
</thead>
<tbody><tr>
<td>No <code>CMD</code> line at the expected time</td>
<td>Cron did not start the job</td>
<td><a href="https://hellocron.com/blog/cron-job-not-running/">12 causes a cron job does not run</a></td>
</tr>
<tr>
<td><code>CMD</code> line, then <code>No MTA installed, discarding output</code></td>
<td>Job ran and printed something, output lost</td>
<td>Redirect output, rerun, read it</td>
</tr>
<tr>
<td><code>CMD</code> line, job output looks fine, nothing happened</td>
<td>Job ran in a different environment than your shell</td>
<td><code>PATH</code>, variables, working directory (next post but one)</td>
</tr>
<tr>
<td><code>CMD</code> line, then <code>failed with exit status N</code> (Debian with <code>-L 4</code>)</td>
<td>Job ran and failed</td>
<td>Its own log or redirected output</td>
</tr>
<tr>
<td>Output file exists but stops at some date</td>
<td>Job stopped running or the file was rotated without <code>copytruncate</code></td>
<td>Daemon log for that date, logrotate config</td>
</tr>
</tbody></table>
<h2>Part 3: knowing next time, without reading logs</h2>
<p>Everything above is forensics. It tells you what happened after someone noticed that the report did not arrive. The question "did last night's backup run?" should not require a login and a <code>grep</code>.</p>
<p>The usual fix is a heartbeat: the job tells a monitor when it starts and when it ends, and the monitor alerts when a start is late or an end is missing. With HelloCron that is two <code>curl</code> calls around the existing command, or one wrapper that also captures the exit code and the last lines of error output, so the "where did the output go" problem from Part 2 disappears as well:</p>
<pre><code class="language-plaintext">0 2 * * * /usr/local/bin/hellocron run nightly-backup /opt/scripts/backup.sh
</code></pre>
<p>When the job fails, the alert contains the exit code and the error output. When it never starts, you get an alert anyway, which no log file can give you, because a job that did not run writes nothing. <a href="https://hellocron.com/blog/monitor-cron-jobs-in-60-seconds/">Monitor a cron job in 60 seconds</a> walks through the setup, and the <a href="https://docs.hellocron.com/guides/shell-script/">shell script guide</a> covers the wrapper. The free plan includes 15 monitors, which covers most servers.</p>
<h2>Quick checklist</h2>
<ol>
<li><p>Daemon log: <code>/var/log/syslog</code> or <code>journalctl -u cron</code> (Debian, Ubuntu), <code>/var/log/cron</code> or <code>journalctl -u crond</code> (RHEL family), <code>/var/log/messages</code> (Alpine), <code>docker logs</code> with crond in the foreground (containers).</p>
</li>
<li><p>Look for the <code>CMD</code> line at the expected time. No line means cron never started the job.</p>
</li>
<li><p>Job output goes to mail, not to a log. <code>No MTA installed, discarding output</code> means it is gone.</p>
</li>
<li><p>Redirect with <code>&gt;&gt; file 2&gt;&amp;1</code>, pipe to <code>logger -t name</code>, or install a mail relay. One of the three, for every job.</p>
</li>
<li><p>On Debian, <code>EXTRA_OPTS="-L 15"</code> makes failures show up in the cron log.</p>
</li>
<li><p>For the question "did it run last night", use a heartbeat monitor instead of a log.</p>
</li>
</ol>
<p>Next in the series: <strong>crontab syntax explained</strong>, every field and special character, with the examples that trip people up.</p>
]]></content:encoded></item></channel></rss>