<?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: Mito</title>
    <description>The latest articles on DEV Community by Mito (@_a2a5aeb0db084c76b8ea).</description>
    <link>https://dev.to/_a2a5aeb0db084c76b8ea</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%2F4083338%2Fcd4dae83-adc7-4b01-a553-ba40b4b5e209.jpeg</url>
      <title>DEV Community: Mito</title>
      <link>https://dev.to/_a2a5aeb0db084c76b8ea</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_a2a5aeb0db084c76b8ea"/>
    <language>en</language>
    <item>
      <title>Raspberry Pi OS Trixie throws away your logs on reboot, and /var/log/journal exists anyway to reassure you it doesn’t</title>
      <dc:creator>Mito</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/_a2a5aeb0db084c76b8ea/raspberry-pi-os-trixie-throws-away-your-logs-on-reboot-and-varlogjournal-exists-anyway-to-5ag0</link>
      <guid>https://dev.to/_a2a5aeb0db084c76b8ea/raspberry-pi-os-trixie-throws-away-your-logs-on-reboot-and-varlogjournal-exists-anyway-to-5ag0</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; : Raspberry Pi OS Trixie ships &lt;code&gt;/usr/lib/systemd/journald.conf.d/40-rpi-volatile-storage.conf&lt;/code&gt; with &lt;code&gt;Storage=volatile&lt;/code&gt;. Your journal lives in RAM and is gone after a reboot — exactly when you need it. &lt;code&gt;/var/log/journal/&lt;/code&gt; exists but stays empty, which makes it look like persistence is already working. Fixing it takes a drop-in &lt;strong&gt;and&lt;/strong&gt; a flush; the drop-in alone silently does nothing until the next boot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Your Pi does something bad — crash-loops, drops off the network, wedges. You reboot it to get back in, then go looking for what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;journalctl -b -1


Specifying boot ID or boot offset has no effect, no persistent journal was found.

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logs from the boot you actually care about do not exist. They never made it to disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it’s easy to convince yourself this is already fine
&lt;/h2&gt;

&lt;p&gt;Two things conspire here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the directory exists.&lt;/strong&gt; &lt;code&gt;/var/log/journal/&lt;/code&gt; is present on a stock Trixie install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /var/log/journal/
&lt;span class="go"&gt;total 8
drwxr-sr-x+ 2 root systemd-journal 4096 Jun 18 09:19 .
drwxr-xr-x 6 root root 4096 Aug 16 18:31 ..

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For years, the canonical way to enable persistent journald has been “create &lt;code&gt;/var/log/journal&lt;/code&gt; and it starts persisting”. The directory’s presence is normally &lt;em&gt;the&lt;/em&gt; signal that persistence is on. Here it’s present and empty, and the emptiness reads as “nothing has been logged yet” rather than “nothing will ever be written here”.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the default config file agrees with you.&lt;/strong&gt; &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt; — the file you’d naturally open to check — says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;#Storage=auto
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commented out, default &lt;code&gt;auto&lt;/code&gt;. And &lt;code&gt;auto&lt;/code&gt; means “persist if &lt;code&gt;/var/log/journal&lt;/code&gt; exists”, which it does. So the config file you inspected and the directory you found both say persistence should be working.&lt;/p&gt;

&lt;p&gt;Neither of them is where the decision is being made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the decision actually is
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /usr/lib/systemd/journald.conf.d/
&lt;span class="go"&gt;40-rpi-volatile-storage.conf
syslog.conf

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /usr/lib/systemd/journald.conf.d/40-rpi-volatile-storage.conf
&lt;span class="go"&gt;[Journal]
Storage=volatile

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A vendor drop-in, shipped by the distro, overriding the default you read in the main config file. Drop-ins in &lt;code&gt;/usr/lib/systemd/journald.conf.d/&lt;/code&gt; take precedence over &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt;, so &lt;code&gt;volatile&lt;/code&gt; wins and the journal is written to &lt;code&gt;/run/log/journal/&lt;/code&gt; — a tmpfs — instead.&lt;/p&gt;

&lt;p&gt;The reasoning behind the default is defensible: it protects SD cards from log write wear, which is a real failure mode on Pis. The problem isn’t the choice, it’s that the choice is invisible from every place you’d normally look.&lt;/p&gt;

&lt;p&gt;Don’t guess at precedence. Ask systemd what it actually resolved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;systemd-analyze cat-config systemd/journald.conf

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints every file in load order with its contents, so you can see exactly which line won.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone posts, and why it isn’t enough
&lt;/h2&gt;

&lt;p&gt;The advice you’ll find in forum threads is: add a higher-priority drop-in under &lt;code&gt;/etc&lt;/code&gt;. That part is right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;sudo mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nStorage=persistent\n' | sudo tee /etc/systemd/journald.conf.d/99-persistent-storage.conf
sudo systemctl restart systemd-journald

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;journalctl &lt;span class="nt"&gt;--header&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'File path'&lt;/span&gt;
&lt;span class="gp"&gt;File path: /run/log/journal/&amp;lt;machine-id&amp;gt;&lt;/span&gt;/system.journal
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still &lt;code&gt;/run&lt;/code&gt;. The setting is correct — &lt;code&gt;systemd-analyze cat-config&lt;/code&gt; confirms &lt;code&gt;Storage=persistent&lt;/code&gt; is winning — and the journal is &lt;em&gt;still in RAM&lt;/em&gt;. Restarting the service was not enough.&lt;/p&gt;

&lt;p&gt;What’s missing is the flush. Moving the journal from the runtime location to the persistent one is a distinct operation, normally performed at boot by &lt;code&gt;systemd-journal-flush.service&lt;/code&gt;. Change the setting mid-session and nothing triggers it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;sudo journalctl --flush


&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;journalctl &lt;span class="nt"&gt;--header&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'File path'&lt;/span&gt;
&lt;span class="gp"&gt;File path: /var/log/journal/&amp;lt;machine-id&amp;gt;&lt;/span&gt;/system.journal
&lt;span class="gp"&gt;File path: /var/log/journal/&amp;lt;machine-id&amp;gt;&lt;/span&gt;/user-1000.journal
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it’s real.&lt;/p&gt;

&lt;p&gt;This is the part that makes the bug expensive. Without the flush, the drop-in &lt;em&gt;does&lt;/em&gt; take effect — at the next reboot. So if you apply the incomplete fix, verify it by checking &lt;code&gt;journalctl --header&lt;/code&gt;, and see &lt;code&gt;/run&lt;/code&gt;, you’ll reasonably conclude your drop-in didn’t work and start debugging precedence rules that are already correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the filename everyone tells you to use
&lt;/h2&gt;

&lt;p&gt;Forum threads specify the override must be named &lt;code&gt;99-something.conf&lt;/code&gt;, on the grounds that it has to sort after the vendor file to win.&lt;/p&gt;

&lt;p&gt;The sorting rule is real. The number isn’t. On the image checked here (2026-06-18 Trixie arm64 Lite) the vendor file is &lt;strong&gt;&lt;code&gt;40-&lt;/code&gt;&lt;/strong&gt; , not the &lt;code&gt;70-&lt;/code&gt; those threads describe. Anything above &lt;code&gt;40-&lt;/code&gt; wins, so &lt;code&gt;50-&lt;/code&gt; would do.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;99-&lt;/code&gt; is still the right choice — not because &lt;code&gt;40-&lt;/code&gt; demands it, but because it survives the vendor renaming the file, which apparently already happened once. Just don’t take the specific number in a forum post as fact about your system. Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;ls /usr/lib/systemd/journald.conf.d/

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The trade-off you’re accepting
&lt;/h2&gt;

&lt;p&gt;Turning this on means writing logs to your boot media continuously. The vendor default exists for a reason: on a Pi running from an SD card, that’s real wear on a device that’s already the most common hardware failure point.&lt;/p&gt;

&lt;p&gt;Worth turning on if you boot from an SSD or NVMe, or if you’re debugging something that survives reboots. Worth thinking twice about on a plain SD card — and if you do enable it there, cap the size so it can’t grow without bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;[Journal]&lt;/span&gt;
&lt;span class="py"&gt;Storage&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;persistent&lt;/span&gt;
&lt;span class="py"&gt;SystemMaxUse&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;200M&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;Three separate things pointed at “persistence is on”: the directory existed, the main config said &lt;code&gt;auto&lt;/code&gt;, and after the first fix attempt the config resolution confirmed &lt;code&gt;persistent&lt;/code&gt;. All three were true. None of them described where log bytes were actually being written.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;journalctl --header&lt;/code&gt; was the only thing that reported the live state — the actual open file — and it’s the check worth building the habit around. Config tells you intent. Headers tell you reality. When they disagree, something in between hasn’t run yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.\n\n*&lt;em&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit \xe2\x86\x92&lt;/a&gt;&lt;/em&gt;*\n&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>cloudflare</category>
      <category>stripe</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Stripe retries a failed webhook for three days, and I found out by getting the same email three times</title>
      <dc:creator>Mito</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/_a2a5aeb0db084c76b8ea/stripe-retries-a-failed-webhook-for-three-days-and-i-found-out-by-getting-the-same-email-three-g1n</link>
      <guid>https://dev.to/_a2a5aeb0db084c76b8ea/stripe-retries-a-failed-webhook-for-three-days-and-i-found-out-by-getting-the-same-email-three-g1n</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; : Stripe webhook delivery is at-least-once, not exactly-once. If a webhook fails for any reason and you fix the cause later, every queued retry succeeds afterward — and if your handler isn’t idempotent, that means re-running your whole side effect (sending an email, granting access, provisioning something) once per retry. Keep a marker per event/session and check it before acting, not just on send.&lt;/p&gt;

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

&lt;p&gt;A small toolkit sold as a one-time purchase: buy via a Stripe Payment Link, a webhook fires on &lt;code&gt;checkout.session.completed&lt;/code&gt;, a Cloudflare Worker verifies the signature and emails a download link. Straightforward, and it had already been tested end-to-end with a real charge before going live.&lt;/p&gt;

&lt;p&gt;Shortly after that real charge, three separate copies of the exact same download email arrived, roughly thirty minutes apart.&lt;/p&gt;

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

&lt;p&gt;Nothing was resending on my end. Nothing in the Worker was looping. The explanation was upstream: the very first delivery attempt for that charge had failed — a webhook secret had been misconfigured minutes earlier — so Stripe’s signature check on my side rejected it with a 400.&lt;/p&gt;

&lt;p&gt;Stripe’s webhook delivery isn’t “try once and give up.” A non-2xx response is treated as a delivery failure, and Stripe retries on a backoff schedule for up to three days. The first few retries land roughly every few minutes to half an hour depending on how long the endpoint’s been failing.&lt;/p&gt;

&lt;p&gt;The secret got fixed a few minutes after the first failure. From Stripe’s side, nothing about that mattered — it just kept retrying the same event on its normal schedule. Once the secret was correct, the &lt;em&gt;next&lt;/em&gt; retry succeeded. So did the one after that. Each one was a completely valid, correctly signed, legitimate-looking webhook for a real completed checkout — because it was. Nothing distinguished retry three from delivery one except that my endpoint had already acted on delivery one’s twin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss until it costs you
&lt;/h2&gt;

&lt;p&gt;The natural mental model when building a webhook handler is “an event happens once, so my handler runs once.” That model is wrong in a way that doesn’t show up in testing, because a clean test run typically &lt;em&gt;doesn’t&lt;/em&gt; have a failed first attempt — you write the handler, it works, you ship it. The retry behavior only becomes visible the first time something fails at exactly the wrong moment: mid-deploy, during a secret rotation, during any transient error on either side.&lt;/p&gt;

&lt;p&gt;And the failure mode isn’t a crash or an error someone will report. It’s a customer quietly getting the same email two or three times, mildly annoyed, possibly assuming something is wrong with the business rather than realizing the delivery layer is precisely doing what it’s specified to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Stripe’s Checkout Session ID is a stable identifier for the same real-world event across every retry. That’s the natural idempotency key: before acting, check whether that session has already been handled; if it has, acknowledge and stop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`delivered/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TOOLKIT_BUCKET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Session &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; already delivered; skipping duplicate send.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok (already delivered)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;downloadUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createDownloadUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DOWNLOAD_SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendDownloadEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;downloadUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Written only after a confirmed send, so a failed attempt stays retryable.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TOOLKIT_BUCKET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deliveredAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 500 so Stripe retries — a transient send failure shouldn't lose a sale.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delivery failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters as much as the check. The marker is written &lt;em&gt;after&lt;/em&gt; the email send succeeds, not before it and not unconditionally. Writing it earlier would mean a transient failure in the email provider permanently marks the session as handled, and the customer never gets their download at all — trading a duplicate-email bug for a much worse silent-failure bug. The retry mechanism that caused this problem is also the thing you want protecting you against provider outages; the fix has to keep both properties.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check it without waiting for another real charge
&lt;/h2&gt;

&lt;p&gt;You don’t need to make another purchase to verify this. Forge a correctly signed request against your own secret and send it directly:&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;SESSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cs_idempotency_test_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ...compute t=&amp;lt;timestamp&amp;gt;,v1=&amp;lt;hmac-sha256 of "&amp;lt;timestamp&amp;gt;.&amp;lt;body&amp;gt;" with the webhook secret&amp;gt;...&lt;/span&gt;

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"stripe-signature: &lt;/span&gt;&lt;span class="nv"&gt;$SIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BODY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="c"&gt;# expect: ok&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"stripe-signature: &lt;/span&gt;&lt;span class="nv"&gt;$SIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BODY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="c"&gt;# expect: ok (already delivered)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"stripe-signature: &lt;/span&gt;&lt;span class="nv"&gt;$SIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BODY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="c"&gt;# expect: ok (already delivered)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same technique as forging a signature to test rejection — construct the real thing yourself instead of guessing at what production will do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;“At-least-once” is the standard delivery guarantee for webhooks, queues, and most event systems, precisely because “exactly-once” is expensive or impossible to guarantee end-to-end. Any time you’re consuming events from a system that documents at-least-once delivery, the assumption to design against isn’t “this fires once” — it’s “this will eventually fire more than once, probably at the worst possible time, and my handler needs to be safe either way.”&lt;/p&gt;

&lt;p&gt;The cost of skipping that isn’t a crash you’ll notice in a log. It’s a side effect running twice, silently, on exactly the request path most likely to be running for a real customer at the exact moment you were still shaking out the rest of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.\n\n*&lt;em&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit \xe2\x86\x92&lt;/a&gt;&lt;/em&gt;*\n&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>cloudflare</category>
      <category>stripe</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Your Discord bot is ignoring you, and the status output is lying about why</title>
      <dc:creator>Mito</dc:creator>
      <pubDate>Mon, 17 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/_a2a5aeb0db084c76b8ea/your-discord-bot-is-ignoring-you-and-the-status-output-is-lying-about-why-41a4</link>
      <guid>https://dev.to/_a2a5aeb0db084c76b8ea/your-discord-bot-is-ignoring-you-and-the-status-output-is-lying-about-why-41a4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; : If a self-hosted bot connects successfully, can post to a channel, but never responds when you &lt;code&gt;@mention&lt;/code&gt; it, check whether your framework has the bot’s own &lt;strong&gt;application ID&lt;/strong&gt; configured. Without it, the bot receives your message, fails to recognise its own mention, and drops it. Any “message content intent” warning you see at the same time is probably a red herring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The bot was up. &lt;code&gt;channels status --probe&lt;/code&gt; said &lt;code&gt;connected&lt;/code&gt;, &lt;code&gt;works&lt;/code&gt;. Sending &lt;em&gt;from&lt;/em&gt; the bot into a Discord channel worked on the first try — the test message landed instantly.&lt;/p&gt;

&lt;p&gt;Then I mentioned the bot in that same channel and got nothing. No error, no reply, no visible reaction. Just silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The misleading clue
&lt;/h2&gt;

&lt;p&gt;The status line included this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;intents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;content=limited&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks like an answer. “Content is limited” reads exactly like the well-known Discord gotcha where a bot can’t read message text because &lt;strong&gt;Message Content Intent&lt;/strong&gt; isn’t enabled in the Discord Developer Portal. That intent genuinely is required for bots to see the text of ordinary guild messages, it genuinely is off by default, and forgetting it genuinely is one of the most common Discord bot mistakes.&lt;/p&gt;

&lt;p&gt;So I chased it. Enabled the intent in the portal — it was already on. Restarted the gateway to force a fresh connection — still &lt;code&gt;content=limited&lt;/code&gt;. Went looking for a config key to force it on, found documentation describing &lt;code&gt;intents.messageContent&lt;/code&gt;, tried to set it, and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Config validation failed: channels.discord.intents:
must not have additional properties: "messageContent"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error is the moment the investigation should have turned, and it’s worth dwelling on. The documented key didn’t exist in the schema. Two readings were available: either the docs were for a different version, or I was in the wrong place entirely. I checked the actual schema:&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="n"&gt;openclaw&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;python3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
import json,sys
d=json.load(sys.stdin)
print(json.dumps(d[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;channels&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;discord&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;intents&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;], indent=2))
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole set of supported keys came back as &lt;code&gt;presence&lt;/code&gt;, &lt;code&gt;guildMembers&lt;/code&gt;, &lt;code&gt;voiceStates&lt;/code&gt;. There was &lt;strong&gt;no message-content key at all&lt;/strong&gt; — not disabled, not misspelled, not present. Which meant &lt;code&gt;content=limited&lt;/code&gt; could not be something I was failing to configure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the logs actually said
&lt;/h2&gt;

&lt;p&gt;Reading the raw channel log instead of the status summary took about ten seconds and settled it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;info&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"discord-auto-reply"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"channelId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"no-mention"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;discord:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;skipping&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;guild&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;message&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are true in that one line, and they point in opposite directions from where I’d been looking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The bot received the message.&lt;/strong&gt; It could not log a decision about a message it never got. Message content was never the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It decided the message contained no mention&lt;/strong&gt; — and dropped it on that basis.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And critically, the log line was &lt;em&gt;identical&lt;/em&gt; for a plain message and for a message where I had explicitly &lt;code&gt;@mentioned&lt;/code&gt; the bot. Same &lt;code&gt;reason: "no-mention"&lt;/code&gt;, both times. The bot wasn’t failing to read my message. It was failing to recognise itself in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;A Discord mention arrives in the message payload as &lt;code&gt;&amp;lt;@BOT_USER_ID&amp;gt;&lt;/code&gt;. To decide “was I mentioned?”, the receiving code has to compare that ID against its own. If the framework doesn’t know its own application/client ID, that comparison can’t succeed, and every mention looks like a non-mention.&lt;/p&gt;

&lt;p&gt;The config had no &lt;code&gt;applicationId&lt;/code&gt; set:&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;$ &lt;/span&gt;openclaw config get channels.discord.applicationId
Config path not found: channels.discord.applicationId

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bot’s own ID had been sitting in the startup log the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;discord client initialized as 1538471699438370957; awaiting gateway readiness

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting it fixed the bot immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw config &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; channels.discord.applicationId &lt;span class="s1"&gt;'"1538471699438370957"'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;--json&lt;/code&gt; flag and the nested quotes. Without them the value is parsed as a number and rejected — Discord snowflake IDs are numeric strings, and they must stay strings.&lt;/p&gt;

&lt;p&gt;Restart the gateway, mention the bot, get a reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth writing down
&lt;/h2&gt;

&lt;p&gt;Not because the fix is hard — it’s one config key. It’s worth writing down because of how the failure presents itself.&lt;/p&gt;

&lt;p&gt;Everything visible pointed at the intent. The status output named it. The symptom (bot can’t act on my messages) matches the intent problem exactly. The intent problem is far more commonly discussed online, so every search reinforces it. And the fix for the intent problem — toggling a switch in the Developer Portal — is easy enough that you’ll happily do it, see nothing change, and conclude you need to dig &lt;em&gt;deeper into intents&lt;/em&gt; rather than sideways into something else.&lt;/p&gt;

&lt;p&gt;Meanwhile the actual cause is invisible from the status output. A missing &lt;code&gt;applicationId&lt;/code&gt; doesn’t warn you. It doesn’t degrade a status line. It just quietly makes one boolean always false.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;When a status summary and a raw log disagree about what’s happening, &lt;strong&gt;the log is describing events and the summary is describing an interpretation&lt;/strong&gt;. The summary is a convenience layer written by someone who guessed which distinctions would matter to you; it flattens away detail by design. The log records what the code actually decided, and it tells you &lt;em&gt;why&lt;/em&gt; in the code’s own terms.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reason: "no-mention"&lt;/code&gt; was a precise, correct, complete description of the failure. It was available before I made my first change. I reached for the status line because it was already on screen, and spent the next several steps debugging an interpretation instead of an event.&lt;/p&gt;

&lt;p&gt;So: when a component “connects fine but doesn’t react”, check the receive path’s decision log before you touch a single config value. And when a documented config key doesn’t exist in the running schema, treat that as evidence you’re in the wrong subsystem — not as an invitation to find a different way to set it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.\n\n*&lt;em&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit \xe2\x86\x92&lt;/a&gt;&lt;/em&gt;*\n&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>cloudflare</category>
      <category>stripe</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Undervoltage doesn’t look like a power problem. It looks like bad Wi-Fi.</title>
      <dc:creator>Mito</dc:creator>
      <pubDate>Mon, 17 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/_a2a5aeb0db084c76b8ea/undervoltage-doesnt-look-like-a-power-problem-it-looks-like-bad-wi-fi-32b7</link>
      <guid>https://dev.to/_a2a5aeb0db084c76b8ea/undervoltage-doesnt-look-like-a-power-problem-it-looks-like-bad-wi-fi-32b7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; : On a Raspberry Pi, an inadequate power supply rarely announces itself as “the power is bad”. It shows up as flaky Wi-Fi, hung logins, and mysterious slowness. Check &lt;code&gt;vcgencmd get_throttled&lt;/code&gt; (or &lt;code&gt;/sys/class/hwmon/hwmon*/in0_lcrit_alarm&lt;/code&gt;) &lt;em&gt;before&lt;/em&gt; you debug the symptom you can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;A freshly imaged Pi 4B, running headless-ish off a monitor and keyboard for setup. Two things were wrong, and neither of them sounded electrical:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wi-Fi wouldn’t come up.&lt;/strong&gt; &lt;code&gt;hostname -I&lt;/code&gt; returned &lt;code&gt;127.0.1.1&lt;/code&gt; and nothing else — no DHCP lease, no address, on a network every other device was using without complaint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The console was unusable.&lt;/strong&gt; Typing a username at the login prompt appeared to do nothing; after a delay the prompt would simply return. It read like a broken install or a corrupt filesystem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The natural reading of those two symptoms together is “bad image” or “bad SD card”. That is where I would have gone next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that changed the diagnosis
&lt;/h2&gt;

&lt;p&gt;Buried in the console spam was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[899.552015] hwmon hwmon2: Undervoltage detected!

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, printed once, ~15 minutes into uptime, surrounded by unrelated boot chatter. Easy to scroll past — and in fact it had scrolled past several times before it got read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What undervoltage actually does
&lt;/h2&gt;

&lt;p&gt;The Pi’s firmware monitors the 5V rail. When it drops below roughly 4.63V, it flags undervoltage and starts protecting itself: the CPU is throttled, clocks are capped. That much is documented and expected.&lt;/p&gt;

&lt;p&gt;What’s less obvious — and what makes this so hard to diagnose from symptoms — is the &lt;strong&gt;second-order&lt;/strong&gt; effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The wireless chip is voltage-sensitive.&lt;/strong&gt; A rail that sags under load will produce exactly what I was seeing: a radio that scans but won’t associate, or associates and drops. It presents as a networking fault, not a power fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything gets slower, unevenly.&lt;/strong&gt; A throttled CPU plus a stressed rail makes interactive work feel like the machine is hanging rather than running slowly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It’s the single most common cause of SD card corruption on Pis.&lt;/strong&gt; Losing the rail mid-write is how filesystems get damaged. So the “bad SD card” hypothesis isn’t wrong, exactly — it’s downstream. Undervoltage &lt;em&gt;creates&lt;/em&gt; bad SD cards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is why this matters more than a performance footnote. If you chase the symptom and reflash the card, you will produce a machine that works briefly and then breaks again, and you will blame the card a second time.&lt;/p&gt;

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

&lt;p&gt;Don’t infer it from behaviour. Ask the firmware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vcgencmd get_throttled

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;throttled=0x0&lt;/code&gt; means clean. Anything else is a bitfield:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bit&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0 (&lt;code&gt;0x1&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Under-voltage &lt;strong&gt;right now&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 (&lt;code&gt;0x2&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;ARM frequency &lt;strong&gt;currently&lt;/strong&gt; capped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 (&lt;code&gt;0x4&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Currently&lt;/strong&gt; throttled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 (&lt;code&gt;0x10000&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Under-voltage &lt;strong&gt;has occurred&lt;/strong&gt; since boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17 (&lt;code&gt;0x20000&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Frequency capping &lt;strong&gt;has occurred&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 (&lt;code&gt;0x40000&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Throttling &lt;strong&gt;has occurred&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The split matters. Low bits mean it’s happening as you look. High bits mean it happened earlier and has since recovered — which is exactly the fingerprint of an intermittent supply, and exactly what you’d otherwise dismiss as “it’s fine now, must have been a fluke”.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;vcgencmd&lt;/code&gt; isn’t installed (it isn’t always, on minimal Lite images), the kernel exposes the same alarm through hwmon and needs no packages:&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="nb"&gt;cat&lt;/span&gt; /sys/class/hwmon/hwmon&lt;span class="k"&gt;*&lt;/span&gt;/in0_lcrit_alarm

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0&lt;/code&gt; is healthy, &lt;code&gt;1&lt;/code&gt; means undervoltage is currently flagged.&lt;/p&gt;

&lt;p&gt;Note that the “has occurred” bits only clear on reboot. So the correct test loop after changing anything is: reboot, use the machine, &lt;em&gt;then&lt;/em&gt; re-check — not check, change, check again on the same boot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix
&lt;/h2&gt;

&lt;p&gt;A Pi 4B wants &lt;strong&gt;5.1V at 3A&lt;/strong&gt;. The usual culprits, in the order they actually bite:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The cable, not the adapter.&lt;/strong&gt; This is the one people skip. A thin or long USB-C cable drops meaningful voltage under load even when the supply is genuinely rated 3A. Short and thick beats nominally-correct-at-the-plug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone chargers that don’t negotiate.&lt;/strong&gt; A charger can be rated well above 15W and still hand a Pi 5V at low current, because the Pi doesn’t perform the USB-PD negotiation the charger is waiting for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whatever else is drawing from the Pi’s rails.&lt;/strong&gt; USB peripherals, HATs, SPI displays and GPIO-powered fans all come out of the same budget. A setup that’s stable bare can go unstable the moment you add a display.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Swapping to a supply that could actually hold the rail cleared both symptoms at once — Wi-Fi associated immediately, and the console stopped stalling. &lt;code&gt;throttled=0x0&lt;/code&gt; from then on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable habit
&lt;/h2&gt;

&lt;p&gt;The reason this costs people hours is that &lt;strong&gt;the failure surfaces far from its cause&lt;/strong&gt;. Power is the substrate everything else runs on, so when it’s marginal, the visible breakage appears in whichever subsystem is least tolerant — usually the radio. You end up debugging NetworkManager while the actual fault is a cable.&lt;/p&gt;

&lt;p&gt;So it’s worth making the power check reflexive rather than diagnostic: on any Pi that’s misbehaving in more than one way at once, run &lt;code&gt;get_throttled&lt;/code&gt; first. It takes two seconds, and it either eliminates an entire category of cause or hands you the answer.&lt;/p&gt;

&lt;p&gt;Two unrelated subsystems failing simultaneously is rarely two bugs. It’s usually one thing underneath both of them.&lt;/p&gt;

&lt;p&gt;A ready-to-run version of this check — which works with or without &lt;code&gt;vcgencmd&lt;/code&gt;, decodes the bitfield into plain English, and tells you which of the three causes above to look at — is in the &lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;toolkit&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.\n\n*&lt;em&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit \xe2\x86\x92&lt;/a&gt;&lt;/em&gt;*\n&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>cloudflare</category>
      <category>stripe</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>The rpi-clone PARTUUID trap: why your Pi 4B silently refuses to boot from a cloned SSD</title>
      <dc:creator>Mito</dc:creator>
      <pubDate>Sun, 16 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/_a2a5aeb0db084c76b8ea/the-rpi-clone-partuuid-trap-why-your-pi-4b-silently-refuses-to-boot-from-a-cloned-ssd-1bke</link>
      <guid>https://dev.to/_a2a5aeb0db084c76b8ea/the-rpi-clone-partuuid-trap-why-your-pi-4b-silently-refuses-to-boot-from-a-cloned-ssd-1bke</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; : &lt;code&gt;rpi-clone&lt;/code&gt; copies your SD card to a USB SSD beautifully, but it does &lt;em&gt;not&lt;/em&gt; update the &lt;code&gt;root=PARTUUID=...&lt;/code&gt; reference in the destination’s &lt;code&gt;cmdline.txt&lt;/code&gt;. Flip the boot order to USB before catching this, and your Pi either boots the wrong disk or fails to boot at all. Five-minute fix once you know to look for it.&lt;/p&gt;

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

&lt;p&gt;Moving a Raspberry Pi’s root filesystem off the SD card and onto a USB-attached SSD is one of the highest-value changes you can make to a Pi that’s meant to run unattended for months: SD cards wear out under sustained write load, SSDs don’t (practically speaking), and boot/IO latency drops noticeably as a bonus.&lt;/p&gt;

&lt;p&gt;The standard playbook is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update the bootloader EEPROM (&lt;code&gt;sudo rpi-eeprom-update -a&lt;/code&gt;), reboot.&lt;/li&gt;
&lt;li&gt;Clone the running SD card to the SSD with &lt;a href="https://github.com/billw2/rpi-clone" rel="noopener noreferrer"&gt;&lt;code&gt;rpi-clone&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Flip &lt;code&gt;raspi-config&lt;/code&gt; → &lt;em&gt;Advanced Options&lt;/em&gt; → &lt;em&gt;Boot Order&lt;/em&gt; to prefer USB.&lt;/li&gt;
&lt;li&gt;Reboot and confirm &lt;code&gt;findmnt /&lt;/code&gt; shows the SSD.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1, 3, and 4 are exactly as advertised. Step 2 has a landmine in it.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;rpi-clone&lt;/code&gt; does a genuinely good job: it partitions the destination to match the source, &lt;code&gt;rsync&lt;/code&gt;s the filesystem across, and rewrites the destination’s &lt;code&gt;/etc/fstab&lt;/code&gt; with the &lt;em&gt;destination’s own&lt;/em&gt; &lt;code&gt;PARTUUID&lt;/code&gt;s. If you check &lt;code&gt;fstab&lt;/code&gt; after cloning, it’s correct.&lt;/p&gt;

&lt;p&gt;What it does &lt;strong&gt;not&lt;/strong&gt; do is touch &lt;code&gt;/boot/firmware/cmdline.txt&lt;/code&gt; on the destination. That file still contains the &lt;strong&gt;source disk’s&lt;/strong&gt; &lt;code&gt;root=PARTUUID=...&lt;/code&gt;, copied verbatim.&lt;/p&gt;

&lt;p&gt;This is easy to miss because nothing in the clone process complains. The clone finishes, reports success, and looks done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it bites you specifically at the worst moment
&lt;/h2&gt;

&lt;p&gt;You won’t notice during the clone. You won’t notice right after, either — you’re still running from the SD card at that point, so everything looks fine. The mismatch only matters the moment the firmware actually tries to boot the kernel &lt;em&gt;from the SSD&lt;/em&gt;, using the SSD’s own &lt;code&gt;cmdline.txt&lt;/code&gt; — which is exactly what happens right after you flip the boot order and reboot.&lt;/p&gt;

&lt;p&gt;At that point the kernel is told “find your root filesystem at PARTUUID &lt;code&gt;10052bbd-02&lt;/code&gt;” (the SD card’s identifier), while the disk it’s actually booting from carries an entirely different, freshly-generated PARTUUID (say, &lt;code&gt;b11c96d4-02&lt;/code&gt;). Depending on whether the SD card is still physically present, you get either a boot that silently falls back to the SD card’s root filesystem (confusing — your changes “don’t stick”), or a kernel panic waiting for a root device that isn’t there (worse).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to catch it before rebooting
&lt;/h2&gt;

&lt;p&gt;Right after the clone finishes — &lt;strong&gt;before&lt;/strong&gt; you touch the boot order — compare the source and destination PARTUUIDs directly:&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;# What the SD card's root partition is actually called&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;blkid /dev/mmcblk0p2

&lt;span class="c"&gt;# What the SSD's root partition is actually called&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;blkid /dev/sda2

&lt;span class="c"&gt;# What the SSD's own boot config *thinks* the root partition is called&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/sda1 /mnt
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'root=PARTUUID=[a-f0-9-]*'&lt;/span&gt; /mnt/cmdline.txt

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the third value matches the first (the SD card’s) instead of the second (the SSD’s own), you have the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&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;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/root=PARTUUID=&amp;lt;OLD_PARTUUID&amp;gt;/root=PARTUUID=&amp;lt;NEW_PARTUUID&amp;gt;/"&lt;/span&gt; /mnt/cmdline.txt

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then do one more sweep to make sure nothing else on the destination still references the stale identifier — it’s a one-liner and costs nothing:&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="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;OLD_PARTUUID&amp;gt;"&lt;/span&gt; /mnt 2&amp;gt;/dev/null

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns nothing, unmount and reboot into the boot-order change with confidence.&lt;/p&gt;

&lt;p&gt;A ready-to-run version of this check-and-fix — with a dry-run mode and an automatic backup of &lt;code&gt;cmdline.txt&lt;/code&gt; before it touches anything — is in the &lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;toolkit&lt;/a&gt; that comes with this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;Any tool that clones a running system to a new disk has to solve the “how does the new disk know it’s the new disk” problem somewhere. Some tools solve it by rewriting every reference at clone time; &lt;code&gt;rpi-clone&lt;/code&gt; solves &lt;em&gt;most&lt;/em&gt; of it that way (fstab) but leaves one file (cmdline.txt) untouched. That’s not a bug exactly — it’s a scope boundary the tool doesn’t advertise loudly. The general habit worth keeping: after any disk clone, before changing what your firmware boots from, grep the new disk for the old disk’s identifiers. If anything comes back, you’ve found your landmine before it found you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.\n\n*&lt;em&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit \xe2\x86\x92&lt;/a&gt;&lt;/em&gt;*\n&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>cloudflare</category>
      <category>stripe</category>
      <category>webhooks</category>
    </item>
  </channel>
</rss>
