<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tenium App</title>
    <description>The latest articles on DEV Community by Tenium App (@teniumapp).</description>
    <link>https://dev.to/teniumapp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4236932%2Feedbabc4-6506-4c37-99cc-c01b08338bc1.png</url>
      <title>DEV Community: Tenium App</title>
      <link>https://dev.to/teniumapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/teniumapp"/>
    <language>en</language>
    <item>
      <title>Monitor a cron job with one curl command</title>
      <dc:creator>Tenium App</dc:creator>
      <pubDate>Wed, 11 Nov 2026 16:20:20 +0000</pubDate>
      <link>https://dev.to/teniumapp/monitor-a-cron-job-with-one-curl-command-36p8</link>
      <guid>https://dev.to/teniumapp/monitor-a-cron-job-with-one-curl-command-36p8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://tenium.app/blog/cron-job-monitoring-with-curl" rel="noopener noreferrer"&gt;Tenium blog&lt;/a&gt;. I am the author of Tenium, an uptime monitor for agencies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The nightly backup has been failing for three weeks. Nobody knew, because a cron job that fails produces no error page, no red status and no customer complaint. It produces a restore attempt in month four that finds nothing.&lt;/p&gt;

&lt;p&gt;The fix is small enough to fit in one line of your crontab. This guide shows the pattern, the variations for the platforms you probably use, and the mistakes that make a heartbeat monitor lie.&lt;/p&gt;

&lt;h3&gt;
  
  
  The idea: let the job check in
&lt;/h3&gt;

&lt;p&gt;A normal uptime monitor asks your site “are you there?” A scheduled job has nothing to ask. The only signal is that it ran. So you reverse the direction: when the job finishes, it requests a URL. If that request does not arrive on time, the monitor alerts you.&lt;/p&gt;

&lt;p&gt;Silence is the failure signal. That is why it catches jobs that never start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: create the monitor
&lt;/h3&gt;

&lt;p&gt;Create a heartbeat monitor. You get a URL of the form &lt;code&gt;https://ping.tenium.app/h/&amp;lt;id&amp;gt;&lt;/code&gt;. The ID is the secret, so treat the URL like a password and keep it out of public repositories. Heartbeat monitors start on the Starter plan ($7 a month, 20 heartbeats); Pro includes 100 and Agency 500.&lt;/p&gt;

&lt;p&gt;Choose how the schedule is expressed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A period (every 15 minutes) for workers and queue consumers.&lt;/li&gt;
&lt;li&gt;A cron expression with a time zone (&lt;code&gt;0 3 * * *&lt;/code&gt;, Europe/Tallinn) for jobs that run at fixed times. If you are unsure about the expression, the cron expression builder reads it back in plain English and lists the next five runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then set the grace time: the longest normal run plus a margin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: add the line to your crontab
&lt;/h3&gt;

&lt;p&gt;The smallest useful version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 3 * * * /usr/local/bin/backup.sh &amp;amp;&amp;amp; curl -fsS -m 10 --retry 3 https://ping.tenium.app/h/abc123 &amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that line matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; means curl runs only if the script exited with success. With &lt;code&gt;;&lt;/code&gt; you would report success for a failed job.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-m 10 --retry 3&lt;/code&gt; stops a slow network from hanging the cron entry and retries a failed ping.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-fsS&lt;/code&gt; fails on HTTP errors, stays quiet on success and still prints errors, which cron mails to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To report failures right away instead of waiting for the grace window to run out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 3 * * * /usr/local/bin/backup.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl -fsS -m 10 https://ping.tenium.app/h/abc123 &lt;span class="o"&gt;||&lt;/span&gt; curl -fsS -m 10 https://ping.tenium.app/h/abc123/fail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 (better): wrap the job once
&lt;/h3&gt;

&lt;p&gt;When you have ten jobs, copy-pasting that line gets messy. A small wrapper keeps it in one place and sends the exit code and the end of the output, which then appear in the incident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# usage: ping-wrap ID command args...&lt;/span&gt;
&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$1"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift&lt;/span&gt;
&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://ping.tenium.app/h/$ID"&lt;/span&gt;
curl -fsS -m 10 &lt;span class="s2"&gt;"$BASE/start"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;"$@"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$CODE"&lt;/span&gt; -eq 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  curl -fsS -m 10 --retry 3 &lt;span class="s2"&gt;"$BASE"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"$OUT"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; tail -c 4000 &lt;span class="o"&gt;|&lt;/span&gt; curl -fsS -m 10 --data-binary @- &lt;span class="s2"&gt;"$BASE/fail?exit=$CODE"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"$CODE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it as &lt;code&gt;0 3 * * * /usr/local/bin/ping-wrap abc123 /usr/local/bin/backup.sh&lt;/code&gt;. The &lt;code&gt;/start&lt;/code&gt; call enables duration tracking: you see how long the job takes, and a job that starts but never finishes shows up as late.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing the period and the grace
&lt;/h3&gt;

&lt;p&gt;The two numbers decide how fast you hear about a problem and how often you are woken by nothing. Start from how the job really behaves, not from how it is meant to behave.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Schedule&lt;/th&gt;
&lt;th&gt;Grace&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nightly database backup&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 3 * * *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;45 minutes&lt;/td&gt;
&lt;td&gt;Runs 10 to 25 minutes; month-end is slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue worker heartbeat&lt;/td&gt;
&lt;td&gt;every 5 minutes&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;One missed ping is a stuck worker, so be strict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hourly import from a supplier&lt;/td&gt;
&lt;td&gt;&lt;code&gt;15 * * * *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20 minutes&lt;/td&gt;
&lt;td&gt;The supplier’s API is sometimes slow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly report e-mail&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 7 * * 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 hours&lt;/td&gt;
&lt;td&gt;Waits for other jobs to finish first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the last ten runs before you pick a grace: take the slowest, add half of it, and round up. Review it when the job gets slower.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test it before you trust it
&lt;/h3&gt;

&lt;p&gt;A monitor you have never seen fail is a guess. Test all three outcomes once, on a quiet afternoon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the curl line by hand and check the monitor turns up with a fresh ping time.&lt;/li&gt;
&lt;li&gt;Request the &lt;code&gt;/fail&lt;/code&gt; URL by hand and confirm that the incident opens and the alert reaches the contact you actually read.&lt;/li&gt;
&lt;li&gt;Comment the job out of the crontab, wait for the period plus the grace to pass, and confirm the “no ping” alert arrives. Then put the job back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write the monitor’s name and the job’s owner next to the crontab entry. In a year, nobody will remember which of twenty heartbeats belongs to which script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Variations by platform
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;systemd timer.&lt;/strong&gt; Add a line to the service unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;oneshot&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="n"&gt;usr&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;backup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sh&lt;/span&gt;
&lt;span class="na"&gt;ExecStartPost&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="n"&gt;usr&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bin&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;curl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;fsS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//ping.tenium.app/h/abc123&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;Type=oneshot&lt;/code&gt;, &lt;code&gt;ExecStartPost&lt;/code&gt; runs only after &lt;code&gt;ExecStart&lt;/code&gt; finished successfully, so a failed job sends no ping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker.&lt;/strong&gt; Ping from the same shell command as the job, because a container that exits has no “after”:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run --rm myimage sh -c &lt;span class="s1"&gt;'run-job &amp;amp;&amp;amp; wget -q -O- https://ping.tenium.app/h/abc123'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Kubernetes CronJob.&lt;/strong&gt; Add the ping as the last step of the container command, or run a second container in the pod that waits for the first. Keep the ID in a Secret, not in the manifest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows Task Scheduler.&lt;/strong&gt; As a PowerShell action: &lt;code&gt;.\backup.ps1; if ($?) { Invoke-RestMethod https://ping.tenium.app/h/abc123 | Out-Null }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress.&lt;/strong&gt; &lt;code&gt;wp-cron&lt;/code&gt; runs only when somebody visits. The &lt;a href="https://tenium.app/blog/five-wordpress-checks-every-agency-should-run" rel="noopener noreferrer"&gt;five WordPress checks&lt;/a&gt; post has a ready-made must-use plugin that pings on each scheduled run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistakes that make a heartbeat lie
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Pinging at the start only. You learn that the job began, not that it finished. Ping at the end, use &lt;code&gt;/start&lt;/code&gt; as an extra.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;;&lt;/code&gt; instead of &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;. The ping goes out even though the script failed.&lt;/li&gt;
&lt;li&gt;Pinging from a different place than the job runs. If the job is on server A and the cron ping is on server B, the monitor says nothing about A.&lt;/li&gt;
&lt;li&gt;A grace period shorter than the worst run. Month-end runs are the slowest. Size the grace for them.&lt;/li&gt;
&lt;li&gt;Time zone drift. A cron expression in the monitor must use the same zone as the server, including daylight saving changes.&lt;/li&gt;
&lt;li&gt;Overlapping runs. A job that takes longer than its interval starts twice. Use &lt;code&gt;flock -n /tmp/backup.lock&lt;/code&gt; in front of the script, and let the monitor tell you when runs are skipped.&lt;/li&gt;
&lt;li&gt;Success that means nothing. A backup script that exits 0 after writing an empty file is a green light on a broken backup. Make the script check the output size, or run a restore test and ping only if it passes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What to put in front of it
&lt;/h3&gt;

&lt;p&gt;For every job that matters, the checklist is short: a heartbeat with a sensible grace, an alert contact you read (email on every plan, Telegram on paid plans), and a note of who owns the job. See &lt;a href="https://tenium.app/monitoring/cron" rel="noopener noreferrer"&gt;cron job monitoring&lt;/a&gt; for the options, and the &lt;a href="https://tenium.app/compare/cronitor" rel="noopener noreferrer"&gt;Tenium vs Cronitor comparison&lt;/a&gt; if cron and queue monitoring is your main requirement: Cronitor is built around exactly that, and the page says where it is stronger.&lt;/p&gt;

&lt;p&gt;If you look after many client sites, Tenium has a free plan (20 monitors; heartbeats start on Starter): &lt;a href="https://tenium.app" rel="noopener noreferrer"&gt;https://tenium.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>bash</category>
      <category>monitoring</category>
      <category>cron</category>
    </item>
  </channel>
</rss>
