<?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: Schiff Heimlich</title>
    <description>The latest articles on DEV Community by Schiff Heimlich (@schiff_heimlich).</description>
    <link>https://dev.to/schiff_heimlich</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%2F3949704%2F89c08e96-274f-4f09-a299-8ebdabdc7096.jpg</url>
      <title>DEV Community: Schiff Heimlich</title>
      <link>https://dev.to/schiff_heimlich</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/schiff_heimlich"/>
    <language>en</language>
    <item>
      <title>Linux 7.2 cut pipe mutex contention — why your shell pipelines just got faster</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Wed, 01 Jul 2026 17:04:58 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/linux-72-cut-pipe-mutex-contention-why-your-shell-pipelines-just-got-faster-4hg6</link>
      <guid>https://dev.to/schiff_heimlich/linux-72-cut-pipe-mutex-contention-why-your-shell-pipelines-just-got-faster-4hg6</guid>
      <description>&lt;p&gt;A Meta engineer profiling caching code found something worth fixing in the kernel's pipe write path. The fix landed in Linux 7.2: &lt;code&gt;anon_pipe_write&lt;/code&gt; now pre-allocates up to 8 pages before grabbing the lock, cutting the critical section down significantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Pipes use a ring buffer backed by anonymous pages. On write, the kernel would allocate pages under the same mutex used for the actual data copy — meaning every write had to wait for allocation, and allocation held the lock. Under load this caused measurable mutex contention.&lt;/p&gt;

&lt;p&gt;The fix separates allocation from the critical section. If 8 pages are already pre-allocated and available, the write just copies data and updates the ring buffer pointer — no allocation, no lock held during the slow path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;p&gt;The gains depend on your workload. Meta's testing showed meaningful improvements under memory pressure — the kind of situation where page allocation itself becomes expensive. Under lighter load the difference is smaller, but still present since you're avoiding the allocation path entirely when the pre-allocated pages cover the write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for pipeline work
&lt;/h2&gt;

&lt;p&gt;If you run anything that pipes data through shell utilities — log processing, build artifact transforms, text munging, any &lt;code&gt;sort | uniq | awk&lt;/code&gt; chain — you benefit from this. Pipes are the fundamental I/O primitive underneath all of it. Reducing contention at this layer makes the whole chain a bit more predictable under concurrent load.&lt;/p&gt;

&lt;p&gt;You don't need to do anything. This lands in your kernel update. But it's worth knowing why those &lt;code&gt;cat bigfile | sort | head&lt;/code&gt; runs feel a touch snappier on a recent kernel — it's not just compiler optimizations, it's a genuine kernel path improvement.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Kernel 7.2 or later required. Check with &lt;code&gt;uname -r&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>linux</category>
      <category>news</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Microsoft UEFI CA from 2011 expired last week. Here's what to check.</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Tue, 30 Jun 2026 17:03:02 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/the-microsoft-uefi-ca-from-2011-expired-last-week-heres-what-to-check-124d</link>
      <guid>https://dev.to/schiff_heimlich/the-microsoft-uefi-ca-from-2011-expired-last-week-heres-what-to-check-124d</guid>
      <description>&lt;p&gt;The Microsoft UEFI CA 2011 quietly expired on June 27, 2026. If you're running anything with Secure Boot enabled, this is worth five minutes of your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually breaks
&lt;/h2&gt;

&lt;p&gt;Third-party binaries that were signed &lt;em&gt;only&lt;/em&gt; by that CA — things like option ROMs, older third-party bootloaders, or hardware firmware blobs — can fail Secure Boot validation. The machine may refuse to boot, or just silently skip what it can't verify.&lt;/p&gt;

&lt;p&gt;The good news: most major Linux distros pushed dual-signed shim binaries in time. Debian, Ubuntu, Fedora — they're all covered. If you've been keeping up with updates, you're probably fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who probably needs to act
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted bare metal&lt;/strong&gt; with custom partitions or hardware RAID controllers that carry their own Option ROMs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge devices&lt;/strong&gt; that haven't been touched in a while — routers, appliances, IoT gateways running older firmware&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VMs on older hypervisors&lt;/strong&gt; where the firmware blob hasn't been updated&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anything running Secure Boot on hardware that predates 2022&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to check
&lt;/h2&gt;

&lt;p&gt;On a Linux system with Secure Boot enabled:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;`bash&lt;/p&gt;

&lt;h1&gt;
  
  
  Check what UEFI vars are loaded
&lt;/h1&gt;

&lt;p&gt;cat /sys/firmware/efi/efivars/SecureBoot*&lt;/p&gt;

&lt;h1&gt;
  
  
  See what keys are enrolled
&lt;/h1&gt;

&lt;p&gt;ls /sys/firmware/efi/efivars/ | grep -i db&lt;br&gt;
`&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you're seeing boot failures that coincide with June 27 or later, that's your culprit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update firmware&lt;/strong&gt; on affected hardware — most vendors have pushed updated Option ROMs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update your bootloader shim&lt;/strong&gt; if you're on an older distro that missed the window — grab a recent signed shim from your distro's repos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the shim-review repo&lt;/strong&gt; if you're a hardware vendor or maintain a custom bootloader: the new dual-signed shims are all documented there&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shim-review team processed 21 reviews in a few weeks to handle this. Most distros got it done. The outliers are the devices nobody has touched in three years.&lt;/p&gt;

&lt;p&gt;Worth a quick audit if you manage anything outside the normal update cycle.&lt;/p&gt;

</description>
      <category>sysadmin</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your CI ran code it should not have, and you cannot prove it did not</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Tue, 30 Jun 2026 04:55:09 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/your-ci-ran-code-it-should-not-have-and-you-cannot-prove-it-did-not-4ap4</link>
      <guid>https://dev.to/schiff_heimlich/your-ci-ran-code-it-should-not-have-and-you-cannot-prove-it-did-not-4ap4</guid>
      <description>&lt;p&gt;A few weeks ago someone force-pushed 75 of 76 version tags in aquasecurity/trivy-action. Pipelines that had pinned to a tag — the thing we all tell people to do — pulled credential-stealing code on their next run. It read /proc//environ and sent secrets to a typosquat domain.&lt;/p&gt;

&lt;p&gt;A few days later, two litellm releases on PyPI shipped a stealer in a .pth file. Python runs .pth files on startup. You did not have to import the package. If it touched the machine, the code already ran.&lt;/p&gt;

&lt;p&gt;Both attacks had the same shape: CI ran code it had no reason to trust, with credentials it had no reason to hold, and afterward nobody could prove what actually executed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "read the workflow file"
&lt;/h2&gt;

&lt;p&gt;You can audit your CI workflow YAML. You can review the action source before you pin a version. But when the tag gets retagged after you pin, or when a dependency runs arbitrary code at import time, your workflow file does not show you what actually executed. There is no signed record.&lt;/p&gt;

&lt;p&gt;This is the gap &lt;strong&gt;CI/Lock&lt;/strong&gt; (cilock) tries to close. It wraps a CI command, traces what it actually does using ptrace (or eBPF if you prefer), and signs the evidence as an in-toto/DSSE attestation. The output is a signed document that says: here is what ran, here is what it read, here is what it produced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cilock run &lt;span class="nt"&gt;--&lt;/span&gt; go build &lt;span class="nt"&gt;-o&lt;/span&gt; app ./...
cilock verify ./app &lt;span class="nt"&gt;-p&lt;/span&gt; release.policy.signed &lt;span class="nt"&gt;-k&lt;/span&gt; policy.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy is signed by a human. The build agent is separate. This is the part that matters: an AI coding agent can run the build, gather the evidence, and draft a release — but it cannot sign the policy, so "the agent did it" is not provenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works in practice
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;You install cilock in your CI environment&lt;/li&gt;
&lt;li&gt;You define a policy: what is allowed to ship, what is allowed to read&lt;/li&gt;
&lt;li&gt;You sign that policy with your key (or use GitHub Actions OIDC for keyless signing)&lt;/li&gt;
&lt;li&gt;You run your build through &lt;code&gt;cilock run -- &amp;lt;command&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On release, anyone can verify: &lt;code&gt;cilock verify &amp;lt;artifact&amp;gt; -p &amp;lt;policy&amp;gt; -k &amp;lt;pubkey&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The attestation records every file the process opens, the environment at build time, and the artifacts produced. If something later turns out to be malicious, you can compare what you think ran against what actually ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not just for big orgs
&lt;/h2&gt;

&lt;p&gt;If you ship software — open source or internal — you are a potential supply chain target. The tooling here is not exotic. The in-toto framework has been around in the CNCF for years. CI/Lock is the practical layer that makes it usable in a normal CI pipeline without standing up a full PKI.&lt;/p&gt;

&lt;p&gt;You do not need to trust that your CI environment is clean. You need a signed record of what happened, so you can verify rather than trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://news.ycombinator.com/item?id=48728596" rel="noopener noreferrer"&gt;https://news.ycombinator.com/item?id=48728596&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>The jq newline that silently corrupted my URLs</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Thu, 25 Jun 2026 17:04:34 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/the-jq-newline-that-silently-corrupted-my-urls-16n3</link>
      <guid>https://dev.to/schiff_heimlich/the-jq-newline-that-silently-corrupted-my-urls-16n3</guid>
      <description>&lt;p&gt;Ran into a fun one last week. Was debugging why some shortened URLs I was sharing kept returning 404 — turned out the links all ended with a stray "j" character.&lt;/p&gt;

&lt;p&gt;Here's what happened. I have a small shell script that shortens URLs via an API and copies the result to my clipboard. Simplified version:&lt;/p&gt;

&lt;h1&gt;
  
  
  !/bin/sh
&lt;/h1&gt;

&lt;p&gt;shirt=$(curl -s -X POST "$API" ...)&lt;br&gt;
echo "$shirt" | jq -r '.short_url' | wl-copy&lt;/p&gt;

&lt;p&gt;This worked fine for months. Then one evening people started telling me the links were broken. After staring at the messages long enough, I noticed every URL ended with "j".&lt;/p&gt;

&lt;p&gt;Root cause: jq adds a newline after each output by default. When that gets pasted into an ANSI terminal, the newline character gets interpreted as "j". In my terminal it displayed fine — the clipboard had the actual link — but in Signal messages, the paste behavior was different and the "j" got embedded in the URL.&lt;/p&gt;

&lt;p&gt;The fix is one flag:&lt;/p&gt;

&lt;p&gt;echo "$shirt" | jq -j -r '.short_url' | wl-copy&lt;/p&gt;

&lt;p&gt;jq -j (or --join-output) suppresses the trailing newline. That's it.&lt;/p&gt;

&lt;p&gt;Two things worth remembering here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;jq outputs a newline by default&lt;/strong&gt; — this is almost always what you want for human-readable output. When you're piping to another tool or storing the result programmatically, you usually want -j.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clipboard tools and terminals have different paste semantics&lt;/strong&gt; — what looks like a clean URL in your clipboard might carry extra characters depending on how it's pasted into different contexts.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This one's been in jq forever and I've hit it before. Still surprises me every few years.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>devjournal</category>
      <category>linux</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Making HTTP requests from a container with no curl</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Wed, 24 Jun 2026 17:02:41 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/making-http-requests-from-a-container-with-no-curl-2geb</link>
      <guid>https://dev.to/schiff_heimlich/making-http-requests-from-a-container-with-no-curl-2geb</guid>
      <description>&lt;p&gt;You SSH into a minimal container image to debug something, and &lt;code&gt;curl&lt;/code&gt; isn’t there. Neither is &lt;code&gt;wget&lt;/code&gt;. You need to check if an endpoint is reachable or poke at an API, and you are stuck.&lt;/p&gt;

&lt;p&gt;Bash can actually do TCP directly. It’s been able to since Bash 2.0-something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;exec &lt;/span&gt;3&amp;lt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/tcp/example.com/80
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"GET / HTTP/1.0&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;Host: example.com&lt;/span&gt;&lt;span class="se"&gt;\r\n\r\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;3
&lt;span class="nb"&gt;cat&lt;/span&gt; &amp;lt;&amp;amp;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/dev/tcp/&lt;/code&gt; is a bash-specific pseudo-device. Opening it sets up a TCP connection to the host and port you specify. The first line opens file descriptor 3 for read-write on that socket. You write your HTTP request to it, then read the response back.&lt;/p&gt;

&lt;p&gt;For HTTPS you will need &lt;code&gt;openssl s_client&lt;/code&gt; or similar, but for internal HTTP endpoints this gets you surprisingly far.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reusable function
&lt;/h2&gt;

&lt;p&gt;If you do this often enough, wrap it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;http_get&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exec &lt;/span&gt;3&amp;lt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/tcp/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/80
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"GET %s HTTP/1.0&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;nHost: %s&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;3
  &lt;span class="nb"&gt;cat&lt;/span&gt; &amp;lt;&amp;amp;3
  &lt;span class="nb"&gt;exec &lt;/span&gt;3&amp;lt;&amp;amp;-
&lt;span class="o"&gt;}&lt;/span&gt;

http_get example.com /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When this matters
&lt;/h2&gt;

&lt;p&gt;Minimal images for security scanning, single-purpose sidecars, or base images you are building from scratch — these often strip out anything not explicitly needed. If you are debugging a network issue inside one of those containers, you do not have to install curl just to test connectivity. Bash is usually there.&lt;/p&gt;

&lt;p&gt;This is not a replacement for proper HTTP clients. But for the moment you need to check if something is listening, it saves a round-trip to install a tool.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>sysadmin</category>
      <category>containers</category>
    </item>
    <item>
      <title>You might not need Redis: a DB table and your filesystem do more than you think</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Tue, 23 Jun 2026 17:04:24 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/you-might-not-need-redis-a-db-table-and-your-filesystem-do-more-than-you-think-2k4g</link>
      <guid>https://dev.to/schiff_heimlich/you-might-not-need-redis-a-db-table-and-your-filesystem-do-more-than-you-think-2k4g</guid>
      <description>&lt;p&gt;Had yet another conversation yesterday with a team burning themselves on a Redis cache misconfiguration. Hot keys, thundering herd, memory policy oversights — the usual scars. One of the replies on the HN thread mentioned something that keeps showing up in these discussions: they just use a database table + filesystem, no Redis, no Memcached.&lt;/p&gt;

&lt;p&gt;Sounded like a lazy joke. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;You already have a database. You already have a filesystem. You can build a perfectly functional caching layer with both.&lt;/p&gt;

&lt;p&gt;The database table looks something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;cache_entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;cache_key&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;computing&lt;/span&gt;   &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filesystem holds the actual cached values — call them &lt;code&gt;/var/cache/myapp/&amp;lt;key&amp;gt;&lt;/code&gt;. Reads check the filesystem first; if the file is stale or missing, the DB is consulted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Thundering herd protection.&lt;/strong&gt; This is the part teams reach for Redis for, and it's the easiest win. Use a database row lock — &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; — so that when a cache miss hits, only one process computes the result while others wait. Postgres handles this fine. The filesystem doesn't need to know about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordinated expiration.&lt;/strong&gt; The DB holds the TTL. Workers can query &lt;code&gt;expires_at&lt;/code&gt; before even touching the filesystem, so you don't serve stale content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No extra daemon.&lt;/strong&gt; No Redis process to babysit, no persistence configuration to get wrong, no memory limit to tune.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence you actually control.&lt;/strong&gt; The filesystem is on disk. If you want to survive restarts, you already have that. You don't have to configure &lt;code&gt;appendfsync always&lt;/code&gt; and pray.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that bites
&lt;/h2&gt;

&lt;p&gt;This is not Redis. If your cache layer needs to be shared across many application servers, you're back to a central store — the DB becomes that bottleneck and you haven't actually solved anything. This pattern works best on a single server or when the DB is already the shared coordinator anyway.&lt;/p&gt;

&lt;p&gt;For high-TPS workloads (thousands of cache reads per second from many nodes) you'll still want something designed for the job. But most internal tooling, sidekiq-style job caching, and slow-query caching isn't that workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's worth considering
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You're already running a database&lt;/li&gt;
&lt;li&gt;Your cache is per-server or your DB is already shared&lt;/li&gt;
&lt;li&gt;You want to reduce operational dependencies&lt;/li&gt;
&lt;li&gt;You're building something that doesn't need Redis' data structures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comment that stuck with me from the thread: "Using a db table as a k/v store + the FS can do so much before even considering paying the price of setting up a dedicated caching store."&lt;/p&gt;

&lt;p&gt;He's right. The boring choice is boring for a reason.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>IPv6 zone identifiers do not belong in URLs</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Mon, 22 Jun 2026 17:02:58 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/ipv6-zone-identifiers-do-not-belong-in-urls-1231</link>
      <guid>https://dev.to/schiff_heimlich/ipv6-zone-identifiers-do-not-belong-in-urls-1231</guid>
      <description>&lt;p&gt;Every now and then you run into something that breaks in a way that is hard to google because the error messages never mention the actual cause. IPv6 zone identifiers are one of those things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a zone identifier looks like
&lt;/h2&gt;

&lt;p&gt;IPv6 link-local addresses are scoped to an interface. The same address can exist on multiple interfaces on the same machine, so you need to say which interface you mean. That is what the zone identifier does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fe80::1%eth0
fe80::1%wlan0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;%eth0&lt;/code&gt; part is the zone identifier. It is a local concept — it never leaves the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with URLs
&lt;/h2&gt;

&lt;p&gt;RFC 3986 is explicit: the zone identifier is not part of a URL. When you write a URL, you stop at the address. Browsers know this and strip the zone identifier before doing anything with the URL.&lt;/p&gt;

&lt;p&gt;But parsers, proxies, load balancers, and DNS tools do not always do the same thing.&lt;/p&gt;

&lt;p&gt;Some libraries will decode &lt;code&gt;%eth0&lt;/code&gt; as part of the URL path and happily try to resolve a hostname that looks like &lt;code&gt;fe80::1eth0&lt;/code&gt;. Others will pass it through unchanged and cause downstream failures in systems that do not expect it. A load balancer parsing an &lt;code&gt;X-Forwarded-For&lt;/code&gt; header or a URL from a log file will occasionally hit something that looks like a zone identifier and behave unexpectedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this shows up in practice
&lt;/h2&gt;

&lt;p&gt;If you are working with IPv6 link-local addresses — for example, accessing a service on a local machine via &lt;code&gt;[fe80::1%eth0]:8080&lt;/code&gt; — the browser handles it fine. But if you are writing a tool that processes URLs, or if you are debugging something that touches raw URL strings, zone identifiers are one of those edge cases that can silently produce wrong results.&lt;/p&gt;

&lt;p&gt;The practical takeaway is simple: when you are writing URL parsing or HTTP request handling code, strip or normalize zone identifiers before processing. And if you ever see a lookup failure that does not make sense, checking for an unexpected &lt;code&gt;%&lt;/code&gt; in what should be a clean address is worth 30 seconds of your time.&lt;/p&gt;

&lt;p&gt;It is a niche problem, but it is the kind of niche that surfaces at 2am when you are trying to figure out why a health check is failing.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>infrastructure</category>
      <category>networking</category>
      <category>webdev</category>
    </item>
    <item>
      <title>IPv6 zone identifiers in URLs are a quiet parser gotcha</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Sun, 21 Jun 2026 17:03:51 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/ipv6-zone-identifiers-in-urls-are-a-quiet-parser-gotcha-5b2m</link>
      <guid>https://dev.to/schiff_heimlich/ipv6-zone-identifiers-in-urls-are-a-quiet-parser-gotcha-5b2m</guid>
      <description>&lt;p&gt;You're debugging a URL parsing issue in your load balancer logs. Someone pasted a link like &lt;code&gt;http://[fe80::1%eth0]:8080/&lt;/code&gt; and your proxy choked on it.&lt;/p&gt;

&lt;p&gt;That's the zone identifier — the &lt;code&gt;%eth0&lt;/code&gt; part. IPv6 link-local addresses need it to specify which interface to use. The problem is, zone identifiers don't actually belong in URLs, and different tools handle them very differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What zone identifiers are for
&lt;/h2&gt;

&lt;p&gt;Link-local IPv6 addresses like &lt;code&gt;fe80::1&lt;/code&gt; are only usable on a single network segment. When you have multiple interfaces, the OS needs to know which one to send the packet out of. So you append &lt;code&gt;%eth0&lt;/code&gt; (or &lt;code&gt;%1&lt;/code&gt;, &lt;code&gt;%en0&lt;/code&gt;, whatever the interface name is) to disambiguate.&lt;/p&gt;

&lt;p&gt;In code, this looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;

&lt;span class="c1"&gt;# This is valid - bind to a specific interface
&lt;/span&gt;&lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_INET6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fe80::1%eth0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why URLs are different
&lt;/h2&gt;

&lt;p&gt;RFC 3986 is explicit: the zone identifier is not part of the URL syntax. It's a parsing artifact that only makes sense in address literals used by the local system.&lt;/p&gt;

&lt;p&gt;Browsers know this. Chrome and Firefox strip zone identifiers before sending the URL to a server. But if you're writing a proxy, a URL parser, a DNS tool, or anything that processes URLs programmatically, you might be getting raw user input with zone identifiers still attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;URL parsers&lt;/strong&gt;: stdlib &lt;code&gt;urllib.parse&lt;/code&gt; in Python will happily parse &lt;code&gt;[fe80::1%eth0]&lt;/code&gt; as the host, but whether it keeps or strips the zone depends on the library and version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancers&lt;/strong&gt;: HAProxy, nginx, Envoy all handle this differently in their URL parsing layers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS tools&lt;/strong&gt;: Some &lt;code&gt;dig&lt;/code&gt; or &lt;code&gt;nslookup&lt;/code&gt; wrappers that accept URLs will fail or misinterpret the zone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log parsers&lt;/strong&gt;: If you're ingesting access logs and someone hotlinks a zone-scoped IPv6 address, your parser might silently drop the zone or fail to match the address&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalize on input&lt;/strong&gt;: Strip zone identifiers from IPv6 addresses in URLs before processing, unless your code is actually doing local interface binding&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be consistent&lt;/strong&gt;: If you're storing or comparing addresses, normalize them the same way every time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your tools&lt;/strong&gt;: Review how your proxy, load balancer, or any URL-processing code handles link-local IPv6 addresses&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Zone identifiers are for local interface routing, not for URLs. Browsers strip them. Your parsers might not. If you're building anything that touches IPv6 URLs, this is one of those details that will bite you once before you remember to handle it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Not groundbreaking, but if you've ever spent an hour wondering why a link-local address won't bind or why a URL won't parse, this is probably why.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>networking</category>
      <category>programming</category>
      <category>python</category>
    </item>
    <item>
      <title>The queue probability drops faster than you think when you add servers</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Sat, 20 Jun 2026 17:03:41 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/the-queue-probability-drops-faster-than-you-think-when-you-add-servers-gml</link>
      <guid>https://dev.to/schiff_heimlich/the-queue-probability-drops-faster-than-you-think-when-you-add-servers-gml</guid>
      <description>&lt;p&gt;A load-balanced system with more servers gives you better latency not just because you have more capacity, but because the queuing math has a shape that surprises most people.&lt;/p&gt;

&lt;p&gt;I ran into this recently when someone asked me to walk through an M/M/c queue model. The setup: c servers, each processing one request at a time, with a load balancer holding an infinite queue. If you're offering c * 0.8 requests per second (80% utilization per server), what happens to mean request latency as you increase c?&lt;/p&gt;

&lt;p&gt;Most people expect latency to improve slowly, or maybe linearly. The actual result is that latency drops faster than your intuition suggests, especially in the tail.&lt;/p&gt;

&lt;p&gt;Here's the specific finding that stuck with me. At 5 servers and half-saturation (offering load equal to half what the servers can handle), about 13% of requests end up in queue. Double the servers to 10, keep the per-server load constant at 80%, and that queuing probability drops to 3.6%. Double again to 20 servers, and you're effectively at zero queuing at half load.&lt;/p&gt;

&lt;p&gt;That's not a linear relationship. The queuing probability is a function of the Erlang C formula, and its shape is convex in a way that favors scale.&lt;/p&gt;

&lt;p&gt;The practical takeaway isn't "add more servers." It's that load balancer latency is often not the right thing to optimize when you have headroom. If your services are sitting at 60-70% utilization, adding capacity will reduce queuing more than you'd predict from simple capacity math.&lt;/p&gt;

&lt;p&gt;The flip side: if you're already running lean (85%+ utilization), adding a couple servers won't move the needle much. You're in the steep part of the curve where queuing probability climbs quickly.&lt;/p&gt;

&lt;p&gt;This is a reminder that queue theory has real teeth in distributed systems design. The counterintuitive behavior is worth knowing when you're sizing clusters or debugging latency spikes that seem out of proportion with load.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source: Marcus Brooks (brooker.co.za) on M/M/c queuing systems and Erlang's C formula.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>computerscience</category>
      <category>distributedsystems</category>
      <category>performance</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Homebrew 6.0 sandbox: what the systemd confinement actually does</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Fri, 19 Jun 2026 17:04:28 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/homebrew-60-sandbox-what-the-systemd-confinement-actually-does-2b0h</link>
      <guid>https://dev.to/schiff_heimlich/homebrew-60-sandbox-what-the-systemd-confinement-actually-does-2b0h</guid>
      <description>&lt;p&gt;Homebrew 6.0 shipped a Linux sandbox. Here's what that actually means in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The sandbox isn't containers. It's &lt;code&gt;systemd&lt;/code&gt; sleep confinement applied per-formula at install/run time. When a formula runs, systemd places it in a cgroup slice with restricted access to filesystem paths, syscall capabilities, and device nodes. If the formula tries to write somewhere it shouldn't, the kernel enforces it at the cgroup level — not at the container boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for dev environments
&lt;/h2&gt;

&lt;p&gt;On a dev workstation or shared Linux build box, Homebrew installs run under the same user context as everything else. A buggy or malicious formula can overwrite your dotfiles, read SSH keys if the agent is running, or trash /usr/local if permissions allow. The sandbox doesn't eliminate that risk, but it limits the blast radius.&lt;/p&gt;

&lt;p&gt;Specifically, the confinement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Denies access to paths outside the Homebrew prefix unless explicitly allowlisted&lt;/li&gt;
&lt;li&gt;Drops capabilities like CAP_SYS_ADMIN that aren't needed for most formula builds&lt;/li&gt;
&lt;li&gt;Restricts device access so the formula can't probe /dev&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What doesn't change
&lt;/h2&gt;

&lt;p&gt;This isn't containerisation. There's no namespace isolation, no separate mount table, no seccomp filter applied by default. The sandbox constrains what the process can do through cgroup v2 and capability bounding, but a determined formula running as your user can still cause plenty of damage inside those limits.&lt;/p&gt;

&lt;p&gt;Also worth noting: this only applies on Linux. macOS Homebrew still relies on SIP and the normal Unix permission model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking if a formula is sandboxed
&lt;/h2&gt;

&lt;p&gt;You can inspect the cgroup a formula is running in after install:&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;# Find the PID of a running formula&lt;/span&gt;
pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &amp;lt;formula-name&amp;gt;

&lt;span class="c"&gt;# Check its cgroup slice&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&amp;lt;PID&amp;gt;/cgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Homebrew's sandbox is active, the process will be under a homebrew.sandbox slice rather than the default user slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you need to opt out
&lt;/h2&gt;

&lt;p&gt;Some formulae legitimately need wider access — building kernel modules, probing hardware, etc. You can bypass the sandbox per-install:&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="nv"&gt;HOMEBREW_NO_SANDBOX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 brew &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;formula&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just be deliberate about when you do this. The sandbox exists precisely because a formula you didn't write is executing arbitrary code on your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;6.0 moves Homebrew's threat model in the right direction for shared Linux environments. It's not a substitute for isolation tools when you need hard boundaries, but it's a sensible default that raises the bar without the operational overhead of containers. If you're managing multi-user Linux build machines, test it against your common formulae before rolling it out broadly — some build systems make assumptions about what they can access that conflict with the new restrictions.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>sysadmin</category>
      <category>linux</category>
    </item>
    <item>
      <title>Batch-converting documents to markdown with Microsoft's markitdown</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Thu, 18 Jun 2026 17:01:26 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/batch-converting-documents-to-markdown-with-microsofts-markitdown-kbi</link>
      <guid>https://dev.to/schiff_heimlich/batch-converting-documents-to-markdown-with-microsofts-markitdown-kbi</guid>
      <description>&lt;p&gt;Here's a quick tool that landed in my queue recently: &lt;strong&gt;microsoft/markitdown&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's a Python CLI that converts PDFs, Word docs, PowerPoint, and Excel files to Markdown. Not groundbreaking, but if you've ever had to process a folder of legacy documentation for a static site, you know the value of not doing it manually.&lt;/p&gt;

&lt;p&gt;Two things I found useful:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch conversion with piping&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;markitdown &lt;span class="nt"&gt;--input&lt;/span&gt; document.docx &lt;span class="nt"&gt;--output&lt;/span&gt; converted/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can point it at a directory and it processes everything in one shot. Combine with standard Unix tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find ./legacy-docs &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.docx'&lt;/span&gt; | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'markitdown --input {} --output ./md/'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;stdout output for scripting&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;markitdown document.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dumps the markdown to stdout, which makes it easy to pipe into other text processing or redirect to specific filenames based on the input.&lt;/p&gt;

&lt;p&gt;It's on PyPI (&lt;code&gt;pip install markitdown&lt;/code&gt;), so it'll drop into a CI pipeline without much friction. If you've got a documentation migration on your plate and you're tired of manual conversions, it's worth a look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/microsoft/markitdown" rel="noopener noreferrer"&gt;https://github.com/microsoft/markitdown&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>microsoft</category>
      <category>python</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Systemd timer units: two things cron still cant do</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Wed, 17 Jun 2026 17:02:53 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/systemd-timer-units-two-things-cron-still-cant-do-1ip3</link>
      <guid>https://dev.to/schiff_heimlich/systemd-timer-units-two-things-cron-still-cant-do-1ip3</guid>
      <description>&lt;p&gt;Every time I see a cron tab I wonder why nobody reached for systemd timers. Cron works fine until it doesnt, and by then youre already in a hole.&lt;/p&gt;

&lt;p&gt;Here are the two things that always bite us.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your cron PATH is a coin flip
&lt;/h2&gt;

&lt;p&gt;Cron runs everything with a stripped down environment. &lt;code&gt;PATH&lt;/code&gt; is usually &lt;code&gt;/usr/bin:/bin&lt;/code&gt;. So when you write a cron job that calls &lt;code&gt;vault&lt;/code&gt; or &lt;code&gt;python3&lt;/code&gt; or anything not in that short list, it silently fails or runs the wrong binary.&lt;/p&gt;

&lt;p&gt;Systemd services inherit the full environment from the service manager. If it works in your shell it works in your timer.&lt;/p&gt;

&lt;p&gt;With cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; * * * &lt;span class="n"&gt;backup&lt;/span&gt;.&lt;span class="n"&gt;sh&lt;/span&gt;  &lt;span class="c"&gt;# fails because backup.sh calls vault not /usr/bin/vault
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a systemd timer, your PATH is what you expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cron has no concept of a run completing
&lt;/h2&gt;

&lt;p&gt;You set a schedule. Cron fires the job. If the job is already running, cron fires another one anyway. You end up with five backup scripts running simultaneously because the previous one was slow.&lt;/p&gt;

&lt;p&gt;Systemd timers have &lt;code&gt;AccuracySec=&lt;/code&gt; and you can set &lt;code&gt;Unit=backup.service&lt;/code&gt; with &lt;code&gt;RefuseManualStop=no&lt;/code&gt; and the service itself just handles one execution at a time. Or you use &lt;code&gt;Persistent=true&lt;/code&gt; to catch up on missed runs after a reboot.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal working example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/nightly-backup.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2026-01-01 02:00:00&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/nightly-backup.service
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/backup.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable with &lt;code&gt;systemctl enable --now nightly-backup.timer&lt;/code&gt;. Check next run with &lt;code&gt;systemctl list-timers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Logs go straight to journald. No more hunting for cron output in mail.&lt;/p&gt;

&lt;p&gt;Cron is fine for simple stuff. But when your scheduled job touches production systems, the systemd approach gives you control that cron simply cant match.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
