<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Shell Scripting | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/shell-scripting/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/shell-scripting/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Mon, 14 Sep 2026 13:13:20 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Shell Scripting | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/shell-scripting/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Cron vs systemd Timers: The Failure Modes That Decide It</title>
		<link>https://john-nessime.com/blog/devops/cron-vs-systemd-timers/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[anacron]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Cron]]></category>
		<category><![CDATA[Crontab]]></category>
		<category><![CDATA[flock]]></category>
		<category><![CDATA[Job Scheduling]]></category>
		<category><![CDATA[journalctl]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Reliability Engineering]]></category>
		<category><![CDATA[Shell Scripting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[systemd Timers]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=254</guid>

					<description><![CDATA[<p>Both cron and systemd timers can run a command at 2 a.m. The difference is what happens the night it doesn't run, and how long it takes anyone to notice. A comparison by failure family, with a decision rule, the same job written both ways, and how to debug each one when it goes quiet.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/cron-vs-systemd-timers/">Cron vs systemd Timers: The Failure Modes That Decide It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket usually arrives from the wrong direction. Nobody reports &#8220;the scheduler is broken.&#8221; Somebody asks for a restore, and the newest dump in the backup directory is three weeks old.</p>



<p class="wp-block-paragraph">The crontab entry is still there. The script still works when you run it by hand. Nothing in the monitoring went red, because nothing was watching whether the job ran at all. It was watching whether the server was up.</p>



<p class="wp-block-paragraph">That is the real question behind cron vs systemd timers, and it has almost nothing to do with syntax. Both tools can run a command at 2 a.m. The difference is what happens on the night the command doesn&#8217;t run, and how long it takes anyone to find out.</p>



<p class="wp-block-paragraph">This post compares the two by failure family rather than by feature list: missed runs, overlapping runs, environment surprises, output that goes nowhere, and jobs that eat the machine. Then a decision procedure, the same job written both ways, and how to debug each one when it goes quiet.</p>



<h2 class="wp-block-heading">The failure that actually decides it</h2>



<p class="wp-block-paragraph">Scheduled jobs fail silently far more often than they fail loudly. A job that crashes with a stack trace gets fixed the same week. A job that stopped firing six weeks ago gets found during an incident.</p>



<p class="wp-block-paragraph">Cron&#8217;s default answer to &#8220;what happened?&#8221; is to mail the job&#8217;s output to the owning user. On a server with no local mail transfer agent configured, which is most servers built in the last decade, that output is generated and then discarded. The daemon logs that it <em>started</em> the command. It does not log what the command printed, and in most implementations it does not log the exit status either.</p>



<p class="wp-block-paragraph">So you get a log line saying cron ran your backup, every night, forever, including on the nights the backup wrote nothing. That line is the trap. It looks like evidence.</p>



<p class="wp-block-paragraph">systemd timers hand this to journald instead. Standard output and standard error from the triggered service land in the journal, tagged with the unit name, alongside the exit status and the runtime. That is the single biggest practical difference between the two, and it is the reason most of my new scheduled work goes into timers.</p>



<p class="wp-block-paragraph">Neither one alerts you. Both will happily not run for a month while you sleep well. Observability is not monitoring, and I&#8217;ll come back to that.</p>



<h2 class="wp-block-heading">Where cron still wins</h2>



<p class="wp-block-paragraph">Cron is one line in one file. There is no unit to reload, no second file to keep in sync, no install section to get wrong. <code>crontab -e</code>, five fields, a command, done. For a job that trims a log directory on a box you own, that economy is not nothing.</p>



<p class="wp-block-paragraph">It also runs where systemd does not. BSD hosts, Alpine images, containers, appliance firmware, embedded boxes, older enterprise systems still on a legacy init. If your configuration management has to cover a fleet that is not uniformly systemd, cron is the common denominator and it is not close.</p>



<p class="wp-block-paragraph">Per-user scheduling is genuinely simpler too. A user runs <code>crontab -e</code> and owns their own jobs, with no root involvement and no unit files in <code>/etc</code>. On shared hosting and on control panels like DirectAdmin or cPanel, this is the only model available, and the panel writes the crontab for you.</p>



<h3 class="wp-block-heading">Where cron loses</h3>



<ul class="wp-block-list">

<li>No dependency ordering. Cron cannot wait for the network to be up or the database to be ready. It fires at the wall clock time and hopes.</li>


<li>No overlap protection. If the 2 a.m. run is still going at 3 a.m., the 3 a.m. run starts anyway, on top of it.</li>


<li>No resource containment. A runaway job competes with production for CPU, memory and disk with nothing standing between them.</li>


<li>Minute resolution, and no catch-up if the machine was off.</li>


<li>A syntax with sharp edges that fail quietly, which is the next section.</li>

</ul>



<h2 class="wp-block-heading">Where systemd timers win</h2>



<p class="wp-block-paragraph">A timer separates <em>when</em> from <em>what</em>. The <code>.timer</code> unit holds the schedule. The <code>.service</code> unit holds the job. That split feels like bureaucracy until the first time you need to run the job right now, out of band, without touching the schedule. Then it is just <code>systemctl start myjob.service</code>, and the run is logged exactly like a scheduled one.</p>



<p class="wp-block-paragraph">Everything systemd knows how to do to a service, it will do to a scheduled job. Ordering after other units. Restart-on-failure. Runtime caps. Memory and CPU limits through cgroups. A dedicated user. A private <code>/tmp</code>. A failure handler unit that fires when the job exits non-zero.</p>



<p class="wp-block-paragraph">You also get a schedule you can interrogate before it bites you. <code>systemctl list-timers</code> prints every timer on the box with its next and last elapse. <code>systemd-analyze calendar</code> takes an expression and tells you exactly when it would fire, which is the closest thing either scheduler has to a unit test.</p>



<h3 class="wp-block-heading">Where systemd timers lose</h3>



<ul class="wp-block-list">

<li>Two files, a <code>daemon-reload</code> and an <code>enable</code> for every job. That is real friction when you have forty small jobs.</li>


<li>The calendar syntax is more expressive than cron&#8217;s and harder to eyeball. You will reach for <code>systemd-analyze calendar</code> more often than you expect.</li>


<li>Portability is gone. These units run on systemd hosts and nowhere else.</li>


<li>User timers need lingering enabled, or they stop when the user logs out and never start at boot. This surprises people once, memorably.</li>


<li>The journal is not a log file. If your log shipping only tails <code>/var/log</code>, timer output is invisible to it until you point the shipper at journald.</li>

</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Cron vs systemd timers, by failure family</h2>



<h3 class="wp-block-heading">The machine was off at 2 a.m.</h3>



<p class="wp-block-paragraph">Cron has no memory. If the box was down, rebooting, or suspended when the schedule came round, that run is gone. Nothing catches it up and nothing records that it was skipped.</p>



<p class="wp-block-paragraph">The traditional fix is anacron, which tracks the last successful run per job in a timestamp file and runs the job shortly after boot if the interval has lapsed. It is why the scripts in <code>/etc/cron.daily</code> still run on a laptop that is closed every night. The catch is that anacron works in whole days and only covers the <code>cron.daily</code>, <code>cron.weekly</code> and <code>cron.monthly</code> directories by default. It does not help your custom job at 02:00.</p>



<p class="wp-block-paragraph">systemd folds that behaviour into a single directive:</p>



<pre class="wp-block-code"><code>[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true</code></pre>



<p class="wp-block-paragraph">With <code>Persistent=true</code>, systemd writes a timestamp under <code>/var/lib/systemd/timers/</code> each time the timer fires. On boot it compares that stamp against the schedule, and if a run was missed it triggers immediately. This matters more than it sounds on any VPS you reboot for kernel updates, and a great deal on hosts at providers like Contabo or InterServer where you take maintenance windows on their timetable rather than yours.</p>



<p class="wp-block-paragraph">One caveat worth knowing: &#8220;immediately&#8221; means immediately. If you have twelve persistent timers and the box has been off for a week, all twelve fire within seconds of boot, on a machine that is still starting services. Pair <code>Persistent=true</code> with <code>RandomizedDelaySec=</code> so the catch-up is spread out.</p>



<h3 class="wp-block-heading">Two copies of the same job</h3>



<p class="wp-block-paragraph">This is the one that corrupts data. A sync job normally takes four minutes and runs every five. One night the remote end is slow, the run takes eleven minutes, and cron starts two more copies while the first is still writing.</p>



<p class="wp-block-paragraph">Cron will not stop this. You have to do it yourself, and the standard tool is <code>flock</code> from util-linux:</p>



<pre class="wp-block-code"><code>*/5 * * * * /usr/bin/flock -n /var/lock/sync.lock /usr/local/bin/sync.sh</code></pre>



<p class="wp-block-paragraph"><code>-n</code> means fail immediately rather than queue behind the running copy. Without it you build a backlog of processes all waiting for the same lock, which turns an overlap problem into a fork bomb with a slow fuse.</p>



<p class="wp-block-paragraph">systemd gets this for free from its own model. A unit is either active or it isn&#8217;t. If <code>sync.service</code> is still running when the timer elapses, there is no second copy to start, because starting an already-active unit is a no-op. You do not have to remember anything.</p>



<p class="wp-block-paragraph">One default is worth knowing. A calendar timer schedules its next elapse from the previous trigger time, so if a run overran, that next elapse is already in the past and the service fires again the moment it finishes. Newer systemd releases add a <code>DeferReactivation=</code> boolean that schedules from when the service went inactive instead, so an overrunning job waits for the next real slot. Check your distribution&#8217;s systemd version before relying on it.</p>



<h3 class="wp-block-heading">The environment your job actually gets</h3>



<p class="wp-block-paragraph">&#8220;It works when I run it manually&#8221; is the single most common scheduled-job bug, and both schedulers cause it, for the same reason: neither one gives your job an interactive login shell.</p>



<p class="wp-block-paragraph">Cron runs the command with a short, hardcoded environment. Your shell profile is not sourced. Your <code>PATH</code> is much shorter than the one you tested with, which is why a script that calls <code>docker</code>, <code>wp</code>, <code>aws</code> or a version-manager shim works from your terminal and fails from cron. The working directory is the user&#8217;s home, not wherever you happened to be standing.</p>



<p class="wp-block-paragraph">Cron adds one hazard that is entirely its own. Inside a crontab, the percent sign is a metacharacter: it becomes a newline, and everything after the first one is fed to the command as standard input. So this looks fine and is broken:</p>



<pre class="wp-block-code"><code># Truncated at the first %. The redirect never happens.
0 2 * * * /usr/bin/mysqldump app &gt; /backups/app-$(date +%F).sql

# Escaped. This is what you meant.
0 2 * * * /usr/bin/mysqldump app &gt; /backups/app-$(date +%F).sql</code></pre>



<p class="wp-block-paragraph">There is no warning. The line parses, cron runs the truncated fragment, and the log says the job started. Anything with a <code>date +</code> format string in a crontab deserves a second look.</p>



<p class="wp-block-paragraph">systemd services get a minimal environment too, so the class of bug is identical. What differs is that the fix is declarative and lives with the unit rather than being hidden inside a wrapper script:</p>



<pre class="wp-block-code"><code>[Service]
Type=oneshot
User=deploy
WorkingDirectory=/srv/app
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/myapp/backup.env
ExecStart=/usr/local/bin/backup.sh</code></pre>



<p class="wp-block-paragraph">The honest summary: absolute paths for every binary solve most of this on either scheduler. The rest is solved by never putting logic in the schedule. Put it in a script and schedule the script.</p>



<h3 class="wp-block-heading">Everything firing at once</h3>



<p class="wp-block-paragraph">Write <code>0 0 * * *</code> on fifty hosts and fifty hosts hit your backup target, your package mirror or your API at exactly midnight. The classic cron answer is to hash the hostname into a minute offset in configuration management, which works and which every team reinvents.</p>



<p class="wp-block-paragraph">systemd has two directives here and they pull in opposite directions, which is the part people get wrong:</p>



<ul class="wp-block-list">

<li><code>RandomizedDelaySec=</code> spreads timers <em>apart</em>. It adds a delay between zero and the value you set, so identical schedules on many hosts land at different moments.</li>


<li><code>AccuracySec=</code> pulls timers <em>together</em>. It lets systemd shift the firing time within a window to coalesce wakeups and save power. It defaults to one minute, which is why a timer set to 02:00:00 often fires a few seconds late.</li>

</ul>



<p class="wp-block-paragraph">If you want a timer to fire close to the stated second, lower <code>AccuracySec=</code>. If you want to stagger a fleet, raise <code>RandomizedDelaySec=</code>. Setting only <code>AccuracySec=</code> to a large value and expecting it to spread load is the mistake, because coalescing is the opposite of spreading.</p>



<h3 class="wp-block-heading">A job that eats the box</h3>



<p class="wp-block-paragraph">Cron has no answer here beyond <code>nice</code> and <code>ionice</code> in front of the command, and a script that hangs forever will hang forever. A systemd service is a cgroup, so the limits are declarative and enforced:</p>



<pre class="wp-block-code"><code>[Service]
Type=oneshot
MemoryMax=512M
CPUQuota=40%
IOWeight=20
TimeoutStartSec=30min
OnFailure=notify-failure@%n.service
ExecStart=/usr/local/bin/reindex.sh</code></pre>



<p class="wp-block-paragraph">The two directives that earn their keep on a shared box are <code>TimeoutStartSec=</code>, which kills a <code>oneshot</code> job that has hung, and <code>OnFailure=</code>, which starts another unit when this one exits non-zero. That second one is how you get a scheduled job to page you without wiring an alerting call into every script.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The same job written both ways</h2>



<p class="wp-block-paragraph">A nightly database dump that must not overlap, must not run before the database is up, and must catch up if the host was rebooted. In cron:</p>



<pre class="wp-block-code"><code>0 2 * * * /usr/bin/flock -n /var/lock/db-backup.lock /usr/local/bin/db-backup.sh &gt;&gt; /var/log/db-backup.log 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">That handles overlap and captures output. It does not handle &#8220;the database wasn&#8217;t ready&#8221; or &#8220;the host was down&#8221;, and the log file is now your problem to rotate.</p>



<p class="wp-block-paragraph">The systemd version is two files. First the service, which is the job:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly application database dump
After=network-online.target mariadb.service
Wants=network-online.target

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
TimeoutStartSec=45min</code></pre>



<p class="wp-block-paragraph">Then the timer, which is only the schedule:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/db-backup.timer
[Unit]
Description=Run the nightly database dump

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target</code></pre>



<p class="wp-block-paragraph">Because the two units share a base name, you do not need a <code>Unit=</code> line. systemd pairs <code>db-backup.timer</code> with <code>db-backup.service</code> by convention. Add <code>Unit=</code> only when the names differ.</p>



<p class="wp-block-paragraph">Then the part people skip, which is verifying it before walking away:</p>



<pre class="wp-block-code"><code># Check the units parse before you enable anything
systemd-analyze verify /etc/systemd/system/db-backup.*

# Confirm the calendar expression means what you think
systemd-analyze calendar --iterations=5 "*-*-* 02:00:00"

systemctl daemon-reload
systemctl enable --now db-backup.timer

# Next and last elapse for this timer
systemctl list-timers db-backup.timer

# Run it now, out of band, without touching the schedule
systemctl start db-backup.service
journalctl -u db-backup.service -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">Note that you enable the <em>timer</em>, not the service. Enabling the service instead would try to run the job at every boot, which is a memorable way to learn the difference.</p>



<h2 class="wp-block-heading">How I&#8217;d decide</h2>



<p class="wp-block-paragraph">Work down this list and stop at the first line that applies. It resolves most cases in under a minute.</p>



<ol class="wp-block-list">

<li>The host might not be systemd, now or later. Use cron. Portability beats every other consideration on this list.</li>


<li>The job is inside a container. Use neither. Use your orchestrator&#8217;s scheduler, or a timer on the host that invokes the container.</li>


<li>A missed run matters, or the job must not overlap, or it needs to wait for another service. Use a timer. Every one of those is a directive rather than a wrapper script.</li>


<li>The job is heavy: a dump, a reindex, an import, anything that can pin a core or fill memory. Use a timer, for the cgroup limits and the runtime cap.</li>


<li>You need to see output after the fact without building a logging path first. Use a timer. The journal is already there.</li>


<li>You need sub-minute scheduling. Use a timer with a monotonic directive. Cron&#8217;s floor is one minute.</li>


<li>Otherwise, cron is fine, and one line beats two files.</li>

</ol>



<p class="wp-block-paragraph">The migration question answers itself from that. Do not convert forty working crontab lines in a weekend. Write new scheduled work as timers, convert the jobs that have actually burned you, and leave the rest. Mixing both on one host is normal and supported. Just never schedule the same job in both places, which is easier to do than it sounds when a configuration management run and a manual edit disagree.</p>



<h2 class="wp-block-heading">Diagnosing a job that never fired</h2>



<h3 class="wp-block-heading">Cron</h3>



<p class="wp-block-paragraph">Establish what actually happened before you theorise. The daemon logs every job it starts, so the log tells you which of three situations you are in: cron never tried, cron tried and the command failed, or cron never saw the entry at all.</p>



<pre class="wp-block-code"><code># Is the daemon running at all?
systemctl status crond      # RHEL, AlmaLinux, Rocky
systemctl status cron       # Debian, Ubuntu

# What did it start, and when?
journalctl -u crond --since "24 hours ago"
grep CRON /var/log/syslog | tail -20     # Debian family
tail -50 /var/log/cron                   # RHEL family

# Whose crontab are you actually looking at?
crontab -l -u deploy
ls -l /etc/cron.d/</code></pre>



<p class="wp-block-paragraph">Then work through the usual suspects, roughly in order of how often they turn out to be the cause:</p>



<ul class="wp-block-list">

<li><strong>Wrong crontab.</strong> The job is in root&#8217;s crontab and you are reading the deploy user&#8217;s, or it&#8217;s in <code>/etc/cron.d</code> where there is an extra user field before the command.</li>


<li><strong>PATH, or an unescaped percent sign.</strong> Dump what the job really sees with a temporary entry like <code>* * * * * env &gt; /tmp/cron-env.txt 2&gt;&amp;1</code>, then compare it against your shell.</li>


<li><strong>Permissions.</strong> The script is not executable, or the crontab owner cannot read it.</li>


<li><strong>The schedule is not what you think.</strong> When both day-of-month and day-of-week are restricted, cron runs the job when <em>either</em> matches, not both. <code>0 0 15 * 1</code> fires on the 15th and on every Monday.</li>


<li><strong>The command captured no output</strong>, so a real error was mailed into the void. Append <code>&gt;&gt; /var/log/myjob.log 2&gt;&amp;1</code> and wait for the next run.</li>

</ul>



<h3 class="wp-block-heading">systemd</h3>



<pre class="wp-block-code"><code># Is the timer loaded and scheduled? --all shows inactive ones too
systemctl list-timers --all

# Timer state, last trigger, next elapse
systemctl status db-backup.timer

# What the job printed, and how it exited
journalctl -u db-backup.service --since "7 days ago"

# Prove the schedule independently of the unit
systemd-analyze calendar --iterations=3 "Mon..Fri *-*-* 06:30:00"</code></pre>



<ul class="wp-block-list">

<li><strong>The timer is not enabled.</strong> Creating the files does nothing. It needs <code>daemon-reload</code> and <code>enable --now</code>.</li>


<li><strong>You enabled the service instead of the timer.</strong> Check with <code>systemctl is-enabled</code> on both.</li>


<li><strong>A user timer with no lingering.</strong> Under <code>systemctl --user</code>, the manager stops when the session ends unless you run <code>loginctl enable-linger</code> for that user.</li>


<li><strong>The calendar expression is valid but wrong.</strong> Valid syntax that never matches will not error. <code>systemd-analyze calendar</code> is how you find out.</li>


<li><strong>The service failed before your script ran.</strong> A missing <code>User=</code>, an <code>ExecStart</code> path that does not exist, a <code>WorkingDirectory</code> that is not there. The journal names the reason.</li>

</ul>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<p class="wp-block-paragraph"><strong>&#8220;Cron is deprecated.&#8221;</strong> It isn&#8217;t. It is packaged and supported on every mainstream distribution, and control panels, hosting providers and countless application installers write crontab entries as a matter of course. Nobody is removing it.</p>



<p class="wp-block-paragraph"><strong>&#8220;Timers are too complicated for small jobs.&#8221;</strong> Two files is more typing, not more complexity. The complexity that matters is the wrapper script you write to get locking, logging and a runtime cap out of cron, and how well the next person understands it. Compare like for like.</p>



<p class="wp-block-paragraph"><strong>&#8220;The journal means I have logging sorted.&#8221;</strong> The journal is a ring buffer with a size cap. If nobody set <code>SystemMaxUse=</code> deliberately and nobody ships journal entries anywhere else, your evidence has a retention window you never chose. Check it before you rely on it.</p>



<p class="wp-block-paragraph"><strong>&#8220;Switching to timers will stop jobs failing silently.&#8221;</strong> This is the one I&#8217;d push back on hardest. Timers make failures <em>visible</em>. They do not make them <em>noticed</em>. Neither scheduler will ever tell you that a job did not run, because absence is not an event. That needs a dead man&#8217;s switch: the job pings a heartbeat endpoint on success, and something external alerts when the ping stops. Healthchecks.io, Cronitor and Better Stack all sell exactly this, and Healthchecks is open source if you would rather self-host it next to your own Grafana. If you already run Prometheus, writing a completion timestamp through the node exporter&#8217;s textfile collector and alerting on its age gets you the same property without another vendor.</p>



<p class="wp-block-paragraph"><strong>&#8220;Just move everything to timers.&#8221;</strong> A mass migration of working jobs is a lot of change for no new capability, and every converted job is a chance to fat-finger a path. Convert on cause, not on principle.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are systemd timers better than cron?</h3>



<p class="wp-block-paragraph">For production jobs on a systemd host, generally yes: logging, dependency ordering, overlap protection, catch-up and resource limits are all built in rather than bolted on. For a one-line job on a box you own, cron&#8217;s simplicity is a real advantage and the timer buys you nothing you will use.</p>



<h3 class="wp-block-heading">Is cron deprecated or being removed?</h3>



<p class="wp-block-paragraph">No. Cron implementations are still packaged and maintained across the major distributions. Some distributions do not install a cron daemon in minimal images by default, which is a packaging decision rather than a deprecation, so check with your package manager before assuming a fresh host has one.</p>



<h3 class="wp-block-heading">Can cron and systemd timers coexist on the same server?</h3>



<p class="wp-block-paragraph">Yes, and most real servers run both. The cron daemon itself is usually managed as a systemd service. The only rule is that a given job should be scheduled in exactly one place. Double-scheduling is what produces the &#8220;why did this run twice&#8221; ticket.</p>



<h3 class="wp-block-heading">How do I see when a systemd timer will next run?</h3>



<p class="wp-block-paragraph"><code>systemctl list-timers</code> shows next and last elapse for all active timers; add <code>--all</code> to include inactive ones. To test an expression without creating a unit, use <code>systemd-analyze calendar "Mon..Fri *-*-* 06:30:00"</code> with <code>--iterations=N</code> for more fire times.</p>



<h3 class="wp-block-heading">How do I stop a cron job from overlapping itself?</h3>



<p class="wp-block-paragraph">Wrap it in <code>flock -n</code> with a dedicated lock file. The <code>-n</code> flag makes the new run exit straight away instead of queuing. Do not use a PID file you wrote yourself; it will leak a stale lock the first time the job is killed, and then the job stops running forever.</p>



<h3 class="wp-block-heading">Do systemd timers work inside Docker containers?</h3>



<p class="wp-block-paragraph">Not usefully. A container normally runs a single process without systemd as PID 1, so there is no service manager to own a timer. Schedule from outside the container instead: a host timer that runs the container, or the scheduler your platform provides. Podman is the exception worth knowing, since it can generate systemd units for containers and the host&#8217;s systemd drives them.</p>



<h3 class="wp-block-heading">How do I convert a crontab line to a systemd timer?</h3>



<p class="wp-block-paragraph">Move the command into a <code>Type=oneshot</code> service with an absolute <code>ExecStart</code>, then translate the five cron fields into an <code>OnCalendar=</code> expression of the form <code>DayOfWeek Year-Month-Day Hour:Minute:Second</code>. Verify the expression with <code>systemd-analyze calendar</code>, enable the timer, then remove the crontab line only after you have seen a successful run in the journal.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Cron vs systemd timers is not a contest between an old tool and a new one. Cron is a scheduler. A systemd timer is a scheduler attached to a supervisor, and the supervisor is what you are actually buying: dependency ordering, overlap protection, catch-up, resource limits, and output that lands somewhere you can read it.</p>



<p class="wp-block-paragraph">Pick timers when a missed or duplicated run costs you something. Pick cron when it doesn&#8217;t. And whichever you pick, add a heartbeat, because the failure that hurts is not the job that crashes. It&#8217;s the job that stopped running while everything stayed green.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your scheduled jobs?</h2>



<p class="wp-block-paragraph">Scheduling is one of those areas where the problem is rarely the syntax. It&#8217;s the job nobody has checked since the person who wrote it left. This is the kind of work I take on:</p>



<ul class="wp-block-list">

<li>Auditing every crontab and timer on a host and telling you which jobs have quietly stopped running</li>


<li>Converting fragile cron entries to systemd timers with locking, runtime caps and failure handlers, without a big-bang migration</li>


<li>Fixing the classic &#8220;works by hand, fails on schedule&#8221; environment and PATH problems, including the percent-sign trap</li>


<li>Adding heartbeat monitoring and alerting so a missed run pages someone instead of sitting there</li>


<li>Backup and dump jobs that verify their own output rather than exiting zero on an empty file</li>


<li>Untangling duplicate schedules left behind by control panels, deploy scripts and configuration management</li>

</ul>



<p class="wp-block-paragraph">If something on your server isn&#8217;t running when it should, send me the crontab line or the unit file and the journal output around the time it should have fired. That&#8217;s usually enough to tell you what&#8217;s wrong before we talk about anything else.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/cron-vs-systemd-timers/">Cron vs systemd Timers: The Failure Modes That Decide It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</title>
		<link>https://john-nessime.com/blog/devops/fail-fast-pipeline/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 23:12:22 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Exit Codes]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[GitLab CI]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Pipefail]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Shell Scripting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=55</guid>

					<description><![CDATA[<p>A green pipeline that ships a broken build is worse than a red one. Here is how to order stages so failures surface in seconds, stop shell pipes from swallowing exit codes, and write failure output that tells the next engineer exactly what to fix.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/fail-fast-pipeline/">Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The deploy went out at the end of the day. Pipeline green, every stage a tick, nobody thought about it again. Twenty minutes later support asks why checkout is returning 502.</p>



<p class="wp-block-paragraph">You go back through the logs and there it is, four hundred lines up, in a step that reported success: a download that returned an HTML error page, got piped into <code>tar</code>, and produced an archive containing nothing useful. The shell reported the exit status of <code>tar</code>, not of <code>curl</code>. The build carried on. The pipeline was never lying on purpose. It just never asked the right question.</p>



<p class="wp-block-paragraph">Two things go wrong with pipelines, and only one of them gets talked about. The famous one is slow feedback: thirty-four minutes of build and integration tests before a linter tells you a file is missing a trailing newline. The expensive one is a pipeline that passes when it should have stopped. A <strong>fail fast pipeline</strong> fixes the first problem. Making it explain itself fixes the second, and that is the half most teams skip.</p>



<p class="wp-block-paragraph">This post covers both: how to order stages so real failures surface in seconds, how to stop the shell from hiding non-zero exits, how to design failure output that hands the next engineer the fix, and the troubleshooting steps for when a pipeline stubbornly refuses to fail at the right moment.</p>



<h2 class="wp-block-heading">The failure that actually bites: a pipeline that lies</h2>



<p class="wp-block-paragraph">Start here, because everything else is optimisation on top. A slow pipeline wastes your afternoon. A silently passing pipeline ships a broken artifact and burns a day of debugging in production, usually while somebody is on a call with a customer.</p>



<p class="wp-block-paragraph">The shell is where most of this happens. Bash gives you three defaults that are wrong for CI, and one line fixes all three.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

# Without pipefail this line exits 0 even when curl fails,
# because the shell reports the status of the LAST command
# in the pipe, which is tar.
curl -sSL https://example.com/artifact.tar.gz | tar -xz</code></pre>



<h3 class="wp-block-heading">What each flag actually protects</h3>



<ul class="wp-block-list">
<li><strong><code>-e</code></strong> stops the script when a command exits non-zero. Without it, a failed database migration is followed by a successful <code>echo</code>, and the script exits 0 because the last command succeeded.</li>
<li><strong><code>-u</code></strong> turns an unset variable into an error. This is the one that saves you from <code>rm -rf "$PREFIX/build"</code> expanding to <code>rm -rf "/build"</code> when someone renames an environment variable in the pipeline settings and forgets the script.</li>
<li><strong><code>-o pipefail</code></strong> makes a pipeline return the first non-zero status instead of the last one. Every <code>curl | tar</code>, <code>cat | jq</code>, and <code>docker logs | grep</code> in your scripts is currently reporting only the exit code of the thing on the right.</li>
</ul>



<p class="wp-block-paragraph">The exit status is only half the story though. Plenty of tools return 0 while doing something useless, and <code>curl</code> is the classic offender.</p>



<pre class="wp-block-code"><code># curl exits 0 here even when the server returns 500,
# and you end up untarring an HTML error page.
curl -sSL "$ARTIFACT_URL" | tar -xz

# --fail          makes curl exit non-zero on 4xx and 5xx
# --show-error    keeps the reason visible even with --silent
# --location      follows redirects instead of saving a 302 body
curl --fail --silent --show-error --location "$ARTIFACT_URL" | tar -xz</code></pre>



<p class="wp-block-paragraph">Now the download failing stops the build, and <code>pipefail</code> makes sure that failure propagates past the pipe. The first fixes the check, the second makes sure the check is heard.</p>



<h3 class="wp-block-heading">Where <code>set -e</code> quietly gives up</h3>



<p class="wp-block-paragraph">This trips people up constantly. <code>set -e</code> is suspended whenever a command&#8217;s exit status is being consumed by something else. It is not a safety net, it is a default.</p>



<pre class="wp-block-code"><code>set -e

# -e does NOT stop the script here.
# The if statement consumes the exit status.
if grep -q "TODO" src/main.go; then
  echo "found"
fi

# -e does NOT stop the script here either.
# The left side of || is explicitly allowed to fail.
run_migrations || echo "migrations failed, continuing anyway"

# -e DOES stop the script here.
run_migrations</code></pre>



<p class="wp-block-paragraph">The same trap applies inside containers. On Debian-based images <code>/bin/sh</code> is <code>dash</code>, which has no <code>pipefail</code> option at all, so putting <code>set -o pipefail</code> inside a <code>RUN</code> line fails outright. Set the shell at the top of the Dockerfile instead. <a href="https://github.com/hadolint/hadolint" target="_blank" rel="noreferrer noopener">Hadolint</a> flags this as DL4006 and it is worth wiring into your static stage.</p>



<pre class="wp-block-code"><code># Set this before any RUN instruction that contains a pipe.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN curl --fail --silent --show-error --location 
      https://example.com/tool.tar.gz | tar -xz -C /usr/local/bin</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Order stages by cost, not by category</h2>



<p class="wp-block-paragraph">Most pipelines are laid out the way the team thinks about work: tests, then build, then security, then deploy. That grouping is comfortable and it is the wrong axis. Order by two things instead, cost and failure probability.</p>



<p class="wp-block-paragraph">The rule I use: <strong>every stage should be cheaper than the one after it and more likely to fail than the one after it.</strong> If a check catches ten percent of your broken commits and takes fifteen seconds, it goes in front of the check that catches two percent and takes nine minutes. That is the whole design principle behind a fail fast pipeline.</p>



<ol class="wp-block-list">
<li><strong>Syntax and static analysis.</strong> <code>shellcheck</code>, <code>hadolint</code>, <code>yamllint</code>, <code>terraform validate</code>, <code>tflint</code>, formatter checks, type checks. Seconds, no dependencies, no network. This stage catches an embarrassing share of broken pushes.</li>
<li><strong>Unit tests.</strong> No database, no containers, no network. If they need any of those, they are integration tests and belong further down.</li>
<li><strong>Secrets and dependency scanning.</strong> Fast, and a leaked credential is worth stopping before you build an image around it.</li>
<li><strong>Build.</strong> This is where you start spending real minutes and cache space.</li>
<li><strong>Integration and end-to-end.</strong> Slow, flaky, expensive to run. Everything above should have already caught the boring failures.</li>
<li><strong>Deploy and post-deploy verification.</strong></li>
</ol>



<p class="wp-block-paragraph">A note on the trade-off, because this ordering costs something. Splitting the fast checks into their own stage adds a checkout and a container pull before your unit tests even start. On a busy shared runner that overhead is real. If your static stage takes twelve seconds of work and forty seconds of setup, merge it into the test job and run it as the first step instead. The principle is failure order, not job count.</p>



<p class="wp-block-paragraph">The other thing that keeps the fast lane fast is caching, and it is the piece people get wrong in the opposite direction: they cache so aggressively that a stale dependency tree hides a genuine break. Cache the package manager&#8217;s download directory, not the resolved dependency tree, and key the cache on the lockfile hash.</p>



<h2 class="wp-block-heading">Let the platform kill work you already know is doomed</h2>



<p class="wp-block-paragraph">Ordering stages is only useful if the platform actually stops when something breaks. Three settings do most of the work, and they exist under different names on every runner.</p>



<h3 class="wp-block-heading">GitHub Actions</h3>



<pre class="wp-block-code"><code>concurrency:
  # A new push to the same branch cancels the run that is
  # already in flight. Nobody needs results for a commit
  # that has been superseded.
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  static:
    runs-on: ubuntu-latest
    timeout-minutes: 5          # a hung job is worse than a failed one
    steps:
      - uses: actions/checkout@v4   # pin to a full commit SHA in production
      - run: shellcheck scripts/*.sh
      - run: hadolint Dockerfile

  test:
    needs: static               # will not start until static passes
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false          # see note below
      matrix:
        suite: [unit, contract]
    steps:
      - uses: actions/checkout@v4
      - run: make test-${{ matrix.suite }}</code></pre>



<p class="wp-block-paragraph">That <code>fail-fast: false</code> looks like it contradicts the whole post, so it is worth explaining. Matrix fail-fast defaults to true: one leg fails, the rest get cancelled. That is right when the legs are redundant and you only need to know that something broke. It is wrong when the legs tell you different things. If your matrix is three runtime versions and only one fails, cancelling the others hides whether this is a version-specific problem or a general one, and you will burn a rerun finding out. Fail fast on the pipeline, not on the diagnosis.</p>



<p class="wp-block-paragraph"><code>timeout-minutes</code> is the setting people skip and regret. A job waiting on an interactive prompt, a dead package mirror, or a container that never becomes healthy will sit there consuming a runner until someone notices. Set a timeout on every job, generous enough that a slow day does not trip it and tight enough that a hang gets caught inside one coffee break.</p>



<h3 class="wp-block-heading">GitLab CI</h3>



<pre class="wp-block-code"><code>stages: [static, test, build, deploy]

default:
  interruptible: true     # allows auto-cancel when a newer pipeline starts
  retry:
    max: 2                # capped at 2 by GitLab, so 3 attempts total
    when:
      # Retry only on infrastructure problems.
      # Never retry script_failure: that is your code failing,
      # and retrying it just hides a flaky test.
      - runner_system_failure
      - stuck_or_timeout_failure
      - api_failure

lint:
  stage: static
  timeout: 5m
  script:
    - shellcheck scripts/*.sh

integration:
  stage: test
  needs: [lint]           # DAG: starts as soon as lint passes
  script:
    - make integration</code></pre>



<p class="wp-block-paragraph">The <code>retry: when</code> list is the important bit and it is the one people get lazy about. A bare <code>retry: 2</code> retries on everything including a genuine test failure, which converts an intermittent bug into an invisible one. Scope retries to the failure classes that are genuinely the infrastructure&#8217;s fault. Jenkins has <code>failFast true</code> for parallel stages, Buildkite and CircleCI have their own equivalents, and the reasoning transfers cleanly.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A failure should hand you the answer, not the search</h2>



<p class="wp-block-paragraph">Here is the test I apply to a pipeline failure. Can someone who did not write the change, at an inconvenient hour, fix it from the failure output alone without opening the source? If not, the failure message is incomplete.</p>



<p class="wp-block-paragraph">Four things need to be in there:</p>



<ul class="wp-block-list">
<li><strong>What failed</strong>, named precisely. Not &#8220;build failed&#8221; but the command and its exit code.</li>
<li><strong>Why</strong>, in terms of the input rather than the internals. &#8220;lockfile is out of date with package.json&#8221;, not &#8220;npm ci exited 1&#8221;.</li>
<li><strong>Where</strong>, meaning the file, the line, or the config key that caused it.</li>
<li><strong>What to do next.</strong> One concrete action, even if it is just a runbook link.</li>
</ul>



<p class="wp-block-paragraph">In shell, a small helper plus an <code>ERR</code> trap gets you most of the way there.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

fail() {
  echo "ERROR: $1" &gt;&amp;2
  echo "  fix: $2" &gt;&amp;2
  exit 1
}

# Catches anything you did not write an explicit check for,
# and tells you the line and the command that blew up.
trap 'echo "FAILED at line $LINENO: $BASH_COMMAND" &gt;&amp;2' ERR

[[ -f config/app.yaml ]] || fail 
  "config/app.yaml is missing" 
  "copy config/app.yaml.example and set DATABASE_URL"

[[ -n "${IMAGE_TAG:-}" ]] || fail 
  "IMAGE_TAG is empty" 
  "check the tag job output; it sets IMAGE_TAG from the git SHA"</code></pre>



<p class="wp-block-paragraph">Note <code>${IMAGE_TAG:-}</code> rather than <code>$IMAGE_TAG</code>. Under <code>set -u</code> the bare form aborts with a shell error before your nice message ever prints, which is technically correct and practically useless.</p>



<p class="wp-block-paragraph">On GitHub Actions you can go one step further and attach the error to the file and line, so it shows up as an annotation on the pull request instead of something to be dug out of a log.</p>



<pre class="wp-block-code"><code># Renders as a red annotation on that exact line in the diff view.
echo "::error file=deploy/values.yaml,line=42::image tag is empty"

# Visible, but does not fail the job.
echo "::warning file=Dockerfile,line=7::apt cache not cleaned"</code></pre>



<p class="wp-block-paragraph">Two more habits worth building. Upload logs, test reports, and screenshots as artifacts on failure, conditionally, so the evidence survives the job that produced it. And emit test results as JUnit XML so the platform renders a list of failed test names instead of asking people to scroll. Both are cheap and both cut the time between &#8220;it broke&#8221; and &#8220;I know why&#8221; more than any clever retry logic will.</p>



<h2 class="wp-block-heading">Exit codes are a contract, so use more than one</h2>



<p class="wp-block-paragraph">Most scripts have two states: 0 and &#8220;something went wrong&#8221;. That collapses distinctions the pipeline needs. Terraform gets this right and it is a good model to copy.</p>



<pre class="wp-block-code"><code># -detailed-exitcode changes the meaning of the exit code:
#   0 = succeeded, no changes
#   1 = error
#   2 = succeeded, changes present
set +e
terraform plan -detailed-exitcode -out=tfplan
code=$?
set -e

case "$code" in
  0) echo "No drift. Skipping apply." ;;
  2) echo "Changes detected. Holding for approval." ;;
  *) echo "Plan failed. See log above." &gt;&amp;2; exit "$code" ;;
esac</code></pre>



<p class="wp-block-paragraph">The <code>set +e</code> around it is deliberate: with <code>-e</code> active, exit code 2 would kill the script before you got to interpret it. This is one of the few places where turning the flag off is the correct move, and it is worth a comment in the file explaining why so nobody &#8220;fixes&#8221; it later.</p>



<p class="wp-block-paragraph">Do the same in your own scripts. Reserve a code for &#8220;the thing you asked for is not possible&#8221; versus &#8220;I could not reach the API&#8221; versus &#8220;your input is wrong&#8221;. The pipeline can then retry one and refuse to retry the others, which is the difference between a retry that absorbs a runner hiccup and a retry that hides a race condition for six months.</p>



<h2 class="wp-block-heading">Gate on what you care about, not what is easy to check</h2>



<p class="wp-block-paragraph">This is the deploy-stage version of the same lie. Plenty of commands confirm that a request was accepted, not that the thing you wanted happened.</p>



<pre class="wp-block-code"><code># Returns 0 the moment the API server accepts the manifest,
# even if every pod goes into CrashLoopBackOff a second later.
kubectl apply -f k8s/

# This is the step that actually waits for the rollout,
# and fails the job if it does not converge.
kubectl rollout status deployment/api --timeout=180s

# And this proves the application answers a request,
# not just that a pod reports Ready.
curl --fail --silent --show-error https://api.example.com/healthz</code></pre>



<p class="wp-block-paragraph">Run that last check against the public hostname, through your CDN, rather than against the pod directly. If you front the app with Cloudflare or similar, a smoke test that skips the edge will happily pass while real users hit a cached error page or a misrouted origin. Same reasoning applies to the origin host itself: if the app runs on a VPS from a provider like InterServer behind a reverse proxy, verify through the proxy, because that is the path traffic takes.</p>



<p class="wp-block-paragraph">Then track how long each stage takes over time. Pipeline duration and failure rate per stage are perfectly ordinary metrics to ship into Prometheus and graph in Grafana, or into whatever platform you already use for application monitoring. When the fast lane creeps from forty seconds to four minutes, that is a slow regression nobody will notice from a single run.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">The job passed but the artifact is wrong</h3>



<p class="wp-block-paragraph">Look for pipes first. Grep the scripts for <code>|</code> and check whether <code>pipefail</code> is set in that shell. Then check for tools that return 0 on failure: <code>curl</code> without <code>--fail</code>, <code>docker run</code> where the entrypoint swallows errors, anything wrapped in <code>|| true</code> that somebody added during an incident and never removed.</p>



<h3 class="wp-block-heading"><code>set -e</code> is set but the script keeps going</h3>



<p class="wp-block-paragraph">The failing command is almost certainly inside a condition, on the left of <code>||</code> or <code>&amp;&amp;</code>, or in a function that is itself being called from an <code>if</code>. Add <code>set -x</code> temporarily and read the trace to see exactly where control flow continued. Then use an explicit check with your <code>fail</code> helper rather than relying on <code>-e</code> at that point.</p>



<h3 class="wp-block-heading">Works locally, fails in CI (or the reverse)</h3>



<p class="wp-block-paragraph">Usually a shell difference. Your terminal runs bash or zsh, the runner may execute the script with <code>sh</code>. Add an explicit shebang, and on GitHub Actions set the shell on the step or at the job level rather than assuming. Inside containers, remember <code>dash</code> has no <code>pipefail</code> and no <code>[[ ]]</code>.</p>



<h3 class="wp-block-heading">The job hangs until the platform kills it</h3>



<p class="wp-block-paragraph">Something is waiting on stdin. Package managers prompting for confirmation, <code>ssh</code> asking about a host key, a database client waiting for a password. Add the non-interactive flags, set <code>CI=true</code> where tools respect it, and put a timeout on the job so the next occurrence fails in ten minutes instead of sixty.</p>



<h3 class="wp-block-heading">Fails on the first run, passes on rerun</h3>



<p class="wp-block-paragraph">Resist the retry. Log the failure with enough context to identify it later, quarantine the test into a non-blocking job if it is genuinely blocking the team, and give it an owner and a date. A blanket retry turns a flaky test into a permanent tax on everyone&#8217;s confidence in the pipeline.</p>



<h3 class="wp-block-heading">The failure output is a wall of noise</h3>



<p class="wp-block-paragraph">Suppress progress bars and spinners in CI, they render as thousands of unreadable lines. Then print a short summary block at the very end of the job, after the noise, listing what failed and what to do. People read the bottom of a log first.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Writing <code>set -e</code> without <code>-o pipefail</code> and assuming the script is covered.</li>
<li>Using <code>|| true</code> to silence a step that fails intermittently, instead of finding out why.</li>
<li>Running the slowest, flakiest checks before the ones that take fifteen seconds.</li>
<li>Retrying on every failure class rather than only infrastructure faults.</li>
<li>No job timeouts, so a hang consumes a runner until someone notices.</li>
<li>Treating <code>kubectl apply</code> or a deploy API call as proof the deploy worked.</li>
<li>Failure messages that name the internal function that threw, and nothing the reader can act on.</li>
<li>Caching so aggressively that a stale dependency tree hides a real break.</li>
<li>Discarding logs and test reports when the job fails, which is exactly when you need them.</li>
<li>Turning off matrix fail-fast everywhere by reflex, so every broken commit costs the full matrix.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Put <code>set -euo pipefail</code> at the top of every shell script the pipeline runs, and set the Dockerfile <code>SHELL</code> before any piped <code>RUN</code>.</li>
<li>Order stages so each one is cheaper and more likely to fail than the next.</li>
<li>Give every job a timeout, and cancel superseded runs on the same branch.</li>
<li>Use explicit checks with actionable messages instead of relying on <code>-e</code> to catch everything.</li>
<li>Distinguish exit codes so the pipeline can react differently to different failures.</li>
<li>Verify deploys with a rollout status check plus a real HTTP request through the public path.</li>
<li>Upload artifacts and structured test reports on failure.</li>
<li>Pin action and image references to immutable digests so a green pipeline stays reproducible.</li>
<li>Track stage duration and failure rate over time, the same way you track application metrics.</li>
<li>Fix the failure message the first time somebody has to ask what it means.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What does &#8220;fail fast&#8221; actually mean in a CI/CD pipeline?</h3>



<p class="wp-block-paragraph">Two things. First, the pipeline stops as soon as it knows the result will be a failure, rather than finishing work whose outcome no longer matters. Second, the cheapest checks that are most likely to catch a problem run first, so the average failure surfaces in seconds. The second half is a design choice about ordering, not a platform setting.</p>



<h3 class="wp-block-heading">Is <code>set -e</code> enough on its own?</h3>



<p class="wp-block-paragraph">No. It is suspended inside conditions, on the left side of <code>&amp;&amp;</code> and <code>||</code>, and after <code>!</code>. It also does nothing about pipes, which is why <code>-o pipefail</code> matters, or about commands that return 0 while failing, which is why explicit checks matter. Use it as a baseline, not as your only line of defence.</p>



<h3 class="wp-block-heading">Should I disable matrix fail-fast?</h3>



<p class="wp-block-paragraph">Only when the legs give you different diagnostic information. If the matrix is runtime versions or operating systems and you need to know whether the break is universal or specific, let all legs run. If the legs are interchangeable shards of the same test suite, leave fail-fast on and save the compute.</p>



<h3 class="wp-block-heading">How do I stop a pipeline from passing when a step really failed?</h3>



<p class="wp-block-paragraph">Work through three things in order. Set <code>pipefail</code> so pipes propagate failure. Add the flags that make tools return non-zero on failure, such as <code>curl --fail</code>. Then replace implicit trust with explicit assertions: after a deploy, check that the rollout converged and that the service answers a request. Most silent passes come from one of those three gaps.</p>



<h3 class="wp-block-heading">Are automatic retries a good idea?</h3>



<p class="wp-block-paragraph">Only for failures that are the infrastructure&#8217;s fault: runner crashes, image pull errors, API timeouts from the CI platform itself. Never retry a script failure by default. And make retries visible, because a job that quietly succeeds on the third attempt is telling you something important that nobody sees.</p>



<h3 class="wp-block-heading">How long should a fast feedback stage take?</h3>



<p class="wp-block-paragraph">Short enough that an engineer waits for it rather than switching context. Somewhere under a couple of minutes for the combined lint, format, and unit stage is a reasonable target for most codebases. Past that, people push and walk away, and you lose the benefit of fast feedback even though the pipeline is technically quick.</p>



<h3 class="wp-block-heading">Does this apply to self-hosted runners too?</h3>



<p class="wp-block-paragraph">All of it, and timeouts matter more. On hosted runners a stuck job eventually hits the platform&#8217;s own ceiling. On your own machine it holds a slot until you intervene, so a hung job blocks the queue for everyone. Set explicit timeouts and make sure the runner cleans up its workspace between jobs.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">Speed is the part everyone optimises and honesty is the part that actually costs money. A fail fast pipeline that finishes in ninety seconds is worth very little if one of those seconds contains a swallowed exit code. Fix the lying first: <code>pipefail</code>, tools that return non-zero when they fail, and assertions that check the outcome rather than the request. Then reorder for speed.</p>



<p class="wp-block-paragraph">And judge every failure by one question. Could someone else fix this from the output alone, without reading the source? If the answer is no, the pipeline has not finished its job, no matter how quickly it went red.</p>



<h2 class="wp-block-heading">Need help fixing a pipeline that lies to you?</h2>



<p class="wp-block-paragraph">If any of this sounds like your CI, this is the kind of work I take on. Typical engagements look like:</p>



<ul class="wp-block-list">
<li>Auditing an existing GitHub Actions, GitLab CI, or Jenkins pipeline for silently swallowed failures and false-green stages.</li>
<li>Restructuring stage order and caching so the common failure surfaces in seconds instead of after the build.</li>
<li>Hardening deploy verification: rollout gates, smoke tests through the real public path, and automatic rollback on a failed health check.</li>
<li>Rewriting pipeline shell scripts with proper error handling, exit code contracts, and failure messages an on-call engineer can act on.</li>
<li>Wiring pipeline duration and failure metrics into Prometheus and Grafana so regressions get caught early.</li>
</ul>



<p class="wp-block-paragraph">Send me the workflow file and a link to a failing run, and I will tell you what I would change first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/fail-fast-pipeline/">Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
