<?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: The Homelab Postmortem</title>
    <description>The latest articles on DEV Community by The Homelab Postmortem (@homelabpm).</description>
    <link>https://dev.to/homelabpm</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: The Homelab Postmortem</title>
      <link>https://dev.to/homelabpm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/homelabpm"/>
    <language>en</language>
    <item>
      <title>llama-cli prints the error and exits 0. The same binary exits 1 when the model is missing.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:28:19 +0000</pubDate>
      <link>https://dev.to/homelabpm/llama-cli-prints-the-error-and-exits-0-the-same-binary-exits-1-when-the-model-is-missing-48cn</link>
      <guid>https://dev.to/homelabpm/llama-cli-prints-the-error-and-exits-0-the-same-binary-exits-1-when-the-model-is-missing-48cn</guid>
      <description>&lt;p&gt;Point &lt;code&gt;llama-cli&lt;/code&gt; at a file that isn't there:&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;llama-cli &lt;span class="nt"&gt;-m&lt;/span&gt; gemma-3-1b-it-Q4_K_M.gguf &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--audio&lt;/span&gt; /tmp/does-not-exist.wav &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1 &lt;span class="nt"&gt;--no-warmup&lt;/span&gt; &lt;span class="nt"&gt;--simple-io&lt;/span&gt; &lt;span class="nt"&gt;--single-turn&lt;/span&gt;
...
Error: file does not exist or cannot be opened: &lt;span class="s1"&gt;'/tmp/does-not-exist.wav'&lt;/span&gt;
...
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It printed the error and returned success.&lt;/strong&gt; Both of those are true at the&lt;br&gt;
same time, and only one of them is visible to the script that called it.&lt;/p&gt;
&lt;h2&gt;
  
  
  It is not that the exit status is meaningless here
&lt;/h2&gt;

&lt;p&gt;That was my first assumption and it is wrong, which matters, because "this tool&lt;br&gt;
doesn't do exit codes properly" is a much less useful thing to know than what is&lt;br&gt;
actually happening. Three controls, same binary, same machine:&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;# Missing media file&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;llama-cli &lt;span class="nt"&gt;-m&lt;/span&gt; model.gguf &lt;span class="nt"&gt;--audio&lt;/span&gt; /tmp/does-not-exist.wav ... &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
Error: file does not exist or cannot be opened: &lt;span class="s1"&gt;'/tmp/does-not-exist.wav'&lt;/span&gt;
0

&lt;span class="c"&gt;# Missing image, to check this isn't audio-specific&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;llama-cli &lt;span class="nt"&gt;-m&lt;/span&gt; model.gguf &lt;span class="nt"&gt;--image&lt;/span&gt; /tmp/nope.png ... &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
Error: file does not exist or cannot be opened: &lt;span class="s1"&gt;'/tmp/nope.png'&lt;/span&gt;
0

&lt;span class="c"&gt;# Same missing file, through llama-mtmd-cli instead&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;llama-mtmd-cli &lt;span class="nt"&gt;-m&lt;/span&gt; model.gguf &lt;span class="nt"&gt;--audio&lt;/span&gt; /tmp/does-not-exist.wav ... &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1

&lt;span class="c"&gt;# Missing *model*, through llama-cli&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;llama-cli &lt;span class="nt"&gt;-m&lt;/span&gt; /tmp/nope.gguf ... &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
gguf_init_from_file: failed to open GGUF file &lt;span class="s1"&gt;'/tmp/nope.gguf'&lt;/span&gt;
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The same binary returns 1 for a missing model and 0 for a missing media&lt;br&gt;
file.&lt;/strong&gt; So the machinery to fail is present and works; one specific path does&lt;br&gt;
not use it. &lt;code&gt;llama-mtmd-cli&lt;/code&gt; returning 1 on the identical input rules out the&lt;br&gt;
other easy explanation — this is not a limitation of what the media loader can&lt;br&gt;
express upward.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the code actually does
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;tools/cli/cli-context.cpp&lt;/code&gt; on master. The staging function reports failure&lt;br&gt;
honestly and says nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;cli_context&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;stage_media_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ifstream&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ios&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;binary&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;file&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="nb"&gt;false&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;Its caller — the loop that handles media given on the command line — prints the&lt;br&gt;
message I saw, and then leaves the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;stage_media_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;media_type_from_ext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;show_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string_format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"file does not exist or cannot be opened: '%s'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_str&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;ui&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;show_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string_format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Loaded media from '%s'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_str&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;break&lt;/code&gt; ends the media loop and nothing else.&lt;/strong&gt; The next statement continues&lt;br&gt;
into ordinary prompt handling, which is why the model still answered my prompt&lt;br&gt;
in the run above — it answered it without the audio I asked for, having told me&lt;br&gt;
so in a line that changed nothing.&lt;/p&gt;

&lt;p&gt;And the function containing all this has exactly one exit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;cli_context&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the only return in the function&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;There is no path through &lt;code&gt;run()&lt;/code&gt; that returns non-zero.&lt;/strong&gt; Not "there is one&lt;br&gt;
and this case misses it" — there isn't one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the upstream report and this disagree
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ggml-org/llama.cpp/issues/28095" rel="noopener noreferrer"&gt;The issue&lt;/a&gt; — open,&lt;br&gt;
&lt;code&gt;bug-unconfirmed&lt;/code&gt;, no comments, no fix — attributes this to &lt;code&gt;run()&lt;/code&gt; discarding&lt;br&gt;
the return value of &lt;code&gt;generate_completion()&lt;/code&gt;. That is a real defect: the function&lt;br&gt;
is declared &lt;code&gt;bool&lt;/code&gt;, and its caller does not look at the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But it is not what fires here.&lt;/strong&gt; A missing file on the command line is handled&lt;br&gt;
before generation starts, breaks out of the media loop, and never reaches that&lt;br&gt;
call. Generation then runs normally and succeeds.&lt;/p&gt;

&lt;p&gt;So there are &lt;strong&gt;two independent routes to the same symptom&lt;/strong&gt; — a staging failure&lt;br&gt;
that breaks without propagating, and a generation failure whose return value is&lt;br&gt;
dropped — and they converge on a &lt;code&gt;run()&lt;/code&gt; that cannot report failure either way.&lt;br&gt;
Fixing only the one named in the report would leave the case in the report's own&lt;br&gt;
reproduction command still returning 0.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I verified and what I didn't
&lt;/h2&gt;

&lt;p&gt;Reproduced on &lt;strong&gt;Linux x86_64&lt;/strong&gt;, build &lt;code&gt;b10703&lt;/code&gt;, in a throwaway container, with a&lt;br&gt;
&lt;strong&gt;text-only&lt;/strong&gt; model — no multimodal model is needed, because the failure happens&lt;br&gt;
while staging the file, before anything looks at what it contains. The upstream&lt;br&gt;
report is from &lt;strong&gt;Darwin arm64&lt;/strong&gt;, build &lt;code&gt;10706&lt;/code&gt;. Different OS, different&lt;br&gt;
architecture, different build, same behaviour.&lt;/p&gt;

&lt;p&gt;I did &lt;strong&gt;not&lt;/strong&gt; verify the generation-failure route by triggering it; I read it in&lt;br&gt;
the source. I did not run the reporter's zero-byte-PNG cases. And I have not&lt;br&gt;
checked whether any of this differs on macOS beyond taking the report at its&lt;br&gt;
word.&lt;/p&gt;
&lt;h2&gt;
  
  
  If you call this from a script
&lt;/h2&gt;

&lt;p&gt;Until it is fixed, &lt;code&gt;llama-cli&lt;/code&gt;'s exit status does not tell you whether your media&lt;br&gt;
was loaded. Check the thing you actually care about instead:&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;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;llama-cli &lt;span class="nt"&gt;-m&lt;/span&gt; model.gguf &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$img&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ... 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'does not exist or cannot be opened'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"media failed to load"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grepping output is a bad interface and I would rather not recommend it. It is&lt;br&gt;
the only signal that is currently correct.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;llama-mtmd-cli&lt;/code&gt; returns 1 on the same input, so if your use is multimodal&lt;br&gt;
anyway, it is the better call today — not because it is a workaround, but&lt;br&gt;
because it is the tool that reports what happened.&lt;/p&gt;

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

&lt;p&gt;The error text and the exit status came from two different places in the&lt;br&gt;
program, and nothing keeps them consistent. &lt;code&gt;ui::show_error&lt;/code&gt; is a print. &lt;code&gt;return&lt;br&gt;
0&lt;/code&gt; is a claim. A tool can do both in the same run, and every layer above it —&lt;br&gt;
your shell, your &lt;code&gt;set -e&lt;/code&gt;, your CI step, your retry loop — is reading only the&lt;br&gt;
second one.&lt;/p&gt;

&lt;p&gt;So the habit is narrow: &lt;strong&gt;when you automate a tool, verify that its failure&lt;br&gt;
actually reaches its exit status, using a case you know is broken.&lt;/strong&gt; Point it at&lt;br&gt;
a file that does not exist and read &lt;code&gt;$?&lt;/code&gt;. It takes one command, and it is the&lt;br&gt;
only way to find out whether the thing your automation depends on is load-&lt;br&gt;
bearing or decorative.&lt;/p&gt;

&lt;p&gt;The wider version is about which artefact you trust. A message on the terminal&lt;br&gt;
is written for a human who is watching. An exit status is the only thing a&lt;br&gt;
program downstream can see. When those two disagree, the human sees a failure&lt;br&gt;
and the machine sees success — and the pipeline keeps running on the machine's&lt;br&gt;
version.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>linux</category>
      <category>devops</category>
      <category>bash</category>
    </item>
    <item>
      <title>cloud-init calls your user-data valid. It will never read the ssh_import_id in it.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 29 Aug 2026 04:21:25 +0000</pubDate>
      <link>https://dev.to/homelabpm/cloud-init-calls-your-user-data-valid-it-will-never-read-the-sshimportid-in-it-flf</link>
      <guid>https://dev.to/homelabpm/cloud-init-calls-your-user-data-valid-it-will-never-read-the-sshimportid-in-it-flf</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On Raspberry Pi OS with cloud-init, a top-level &lt;code&gt;ssh_import_id:&lt;/code&gt; in your &lt;code&gt;user-data&lt;/code&gt; is only ever read for a user marked &lt;code&gt;default&lt;/code&gt;. Raspberry Pi's own documented example creates a named user, which is not default, so the key is silently skipped — logged at &lt;code&gt;debug&lt;/code&gt;, never at warning. &lt;code&gt;cloud-init schema&lt;/code&gt; validates the file as correct, the &lt;code&gt;ssh_authorized_keys&lt;/code&gt; you put next to it works fine, and the imported keys never arrive. On a headless box the first symptom is that you cannot log in with the key you expected.&lt;/p&gt;

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

&lt;p&gt;Raspberry Pi Imager 2.0 generates cloud-init configuration by default, and &lt;a href="https://www.raspberrypi.com/news/cloud-init-on-raspberry-pi-os/" rel="noopener noreferrer"&gt;Raspberry Pi's own announcement&lt;/a&gt; shows this shape for creating your user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pi&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users,adm,dialout,audio,netdev,video,plugdev,cdrom,games,input,gpio,spi,i2c,render,sudo&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;lock_passwd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;plain_text_passwd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysecretpassword123&lt;/span&gt;
    &lt;span class="na"&gt;ssh_authorized_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh-ed25519 mykeystuff&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That page does not mention &lt;code&gt;ssh_import_id&lt;/code&gt;, so if you want your keys pulled from GitHub rather than pasted in, you go to cloud-init's documentation and add it — at the top level, which is where the schema defines it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pi&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users,adm,sudo&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;ssh_authorized_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterialHere&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;
&lt;span class="na"&gt;ssh_import_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gh:mitoyosh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it before you flash, because that is the responsible thing to do:&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;cloud-init schema &lt;span class="nt"&gt;--config-file&lt;/span&gt; user-data
Valid schema user-data
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boot the Pi. The static key in &lt;code&gt;ssh_authorized_keys&lt;/code&gt; is in &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; exactly as promised. The keys from &lt;code&gt;gh:mitoyosh&lt;/code&gt; are not, and never will be. Nothing raises an error to change that: the skip is a &lt;code&gt;LOG.debug&lt;/code&gt; and a &lt;code&gt;continue&lt;/code&gt;, with no exception and nothing recorded as a failure, so there is no reason for the run's status to be anything but &lt;code&gt;done&lt;/code&gt; — which is what &lt;code&gt;cloud-init status&lt;/code&gt; reports on this machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to misdiagnose
&lt;/h2&gt;

&lt;p&gt;Three separate things tell you the configuration is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema says so.&lt;/strong&gt; This is not a typo being tolerated — the shipped schema explicitly defines &lt;code&gt;ssh_import_id&lt;/code&gt; as a top-level key:&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;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json;d=json.load(open('/usr/lib/python3/dist-packages/cloudinit/config/schemas/schema-cloud-config-v1.json'));print(json.dumps(d['&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;defs']['cc_ssh_import_id']))"&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;: &lt;span class="s2"&gt;"object"&lt;/span&gt;, &lt;span class="s2"&gt;"properties"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"ssh_import_id"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;: &lt;span class="s2"&gt;"array"&lt;/span&gt;, &lt;span class="s2"&gt;"items"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;: &lt;span class="s2"&gt;"string"&lt;/span&gt;, &lt;span class="s2"&gt;"description"&lt;/span&gt;: &lt;span class="s2"&gt;"The SSH public key to import."&lt;/span&gt;&lt;span class="o"&gt;}}}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;cloud-init schema&lt;/code&gt; is correct to pass it. The key is valid cloud-config. It is simply not read on this path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other key in the same block works.&lt;/strong&gt; &lt;code&gt;ssh_authorized_keys&lt;/code&gt; is nested under the user, and nested keys are read. So you get a machine where one of the two SSH directives you wrote took effect. That is much more confusing than neither working, because it rules out the whole category of "my user-data wasn't applied."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing is logged above debug level.&lt;/strong&gt; The module runs — it is not skipped — and then declines to do anything, quietly:&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_cfg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;import_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;import_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_cfg_option_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssh_import_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;else&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="n"&gt;import_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssh_import_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;LOG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User %s is not configured for ssh_import_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LOG.debug&lt;/code&gt;, not &lt;code&gt;LOG.warning&lt;/code&gt;. It does not appear in the default log level, and &lt;code&gt;cloud-init status&lt;/code&gt; has no opinion about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;The top-level &lt;code&gt;ssh_import_id&lt;/code&gt; is read inside exactly one branch: &lt;code&gt;if user_cfg["default"]&lt;/code&gt;. Every other user falls to the &lt;code&gt;else&lt;/code&gt;, which looks &lt;strong&gt;only&lt;/strong&gt; at a key nested under that user.&lt;/p&gt;

&lt;p&gt;A user created through the &lt;code&gt;users:&lt;/code&gt; list is not the default user. Reproducing that on the machine, with the shipped cloud-init and the config shape above:&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;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import cloudinit; print(cloudinit.version.version_string())"&lt;/span&gt;
25.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config placement   user   default   branch taken          import_ids
top-level          pi     False     else (nested only)    None
nested under user  pi     False     else (nested only)    ['gh:mitoyosh']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same user, same &lt;code&gt;default=False&lt;/code&gt;, same cloud-init. The only difference is where the key sits. In the top-level case the module reaches &lt;code&gt;LOG.debug(...)&lt;/code&gt; and &lt;code&gt;continue&lt;/code&gt;s, and there is no other user for the top-level value to apply to.&lt;/p&gt;

&lt;p&gt;Note the version: Raspberry Pi OS ships &lt;strong&gt;25.2&lt;/strong&gt; here, from their own build (&lt;code&gt;25.2-1~bpo13+1+rpt20&lt;/code&gt;), not the &lt;code&gt;25.1.4&lt;/code&gt; that Debian Trixie carries. Check the box rather than the distro's package page — the behaviour above is what is actually installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is known upstream, and the fix in flight is for a different case
&lt;/h3&gt;

&lt;p&gt;Two trackers are open and neither is fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/raspberrypi/trixie-feedback/issues/98" rel="noopener noreferrer"&gt;&lt;code&gt;raspberrypi/trixie-feedback#98&lt;/code&gt;&lt;/a&gt; — opened 2026-08-13, no comments.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/canonical/cloud-init/issues/4306" rel="noopener noreferrer"&gt;&lt;code&gt;canonical/cloud-init#4306&lt;/code&gt;&lt;/a&gt; — open, labelled &lt;code&gt;bug&lt;/code&gt;, eight comments, reported by a cloud-init contributor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is an open PR, &lt;a href="https://github.com/canonical/cloud-init/pull/7050" rel="noopener noreferrer"&gt;#7050&lt;/a&gt;, and it is easy to assume it covers this. It does not. Its own commit message says what it changes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Merge both sources for the default user. A top-level &lt;code&gt;ssh_import_id&lt;/code&gt; keeps working on its own, and &lt;strong&gt;non-default users are unchanged&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That fixes a nested &lt;code&gt;ssh_import_id&lt;/code&gt; being dropped &lt;em&gt;for the default user&lt;/em&gt;. The case above — a top-level key with a non-default user — is explicitly left as it is. So merging that PR will not fix a Raspberry Pi Imager setup.&lt;/p&gt;

&lt;p&gt;There is also &lt;a href="https://forums.raspberrypi.com/viewtopic.php?p=2385229" rel="noopener noreferrer"&gt;a Raspberry Pi forum thread&lt;/a&gt; asking exactly this question. It has no replies.&lt;/p&gt;

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

&lt;p&gt;Nest it under the user, alongside &lt;code&gt;ssh_authorized_keys&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pi&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;users,adm,sudo&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;
    &lt;span class="na"&gt;ssh_authorized_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterialHere&lt;/span&gt;
    &lt;span class="na"&gt;ssh_import_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gh:mitoyosh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both keys are then in the branch that gets read, and both apply.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you rely on it. &lt;code&gt;ssh-import-id&lt;/code&gt; must be installed or the module warns and stops — that one &lt;em&gt;is&lt;/em&gt; at warning level, so it will show up. And the import needs working networking and reachable GitHub/Launchpad at first boot; if the machine comes up on WiFi that takes a moment, the keys can be a boot behind.&lt;/p&gt;

&lt;p&gt;Verify on the machine rather than by asking cloud-init how the run went. The skip records no error, so a successful-looking status is exactly what you would expect in both cases — it is not evidence either way:&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;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; ~/.ssh/authorized_keys
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ssh_import_id'&lt;/span&gt; /var/log/cloud-init.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first number only accounts for the keys you pasted literally, the import did not happen — regardless of what anything else reported.&lt;/p&gt;

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

&lt;p&gt;Schema validation answers "is this a well-formed document?" It cannot answer "will anything read this?", and the two feel like the same question when the validator is shipped by the same project as the code. Here they diverge completely: the key is in the schema, the schema is right, and the code that consumes it has a condition the schema knows nothing about.&lt;/p&gt;

&lt;p&gt;So the habit is narrow: &lt;strong&gt;a validator passing tells you the file parsed, not that the field is live on your path.&lt;/strong&gt; When a config option quietly does nothing, do not re-read the file looking for a typo — go find the code that consumes the key and check what it requires. Here it was one &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The wider one is about partial success. One SSH directive worked and one did not, and that is the shape that costs the most time, because working evidence is louder than absent evidence. You look at &lt;code&gt;authorized_keys&lt;/code&gt;, see a key, and conclude the mechanism works — when what you have confirmed is that a &lt;em&gt;different&lt;/em&gt; mechanism works. The same trap as &lt;a href="https://homelabpostmortem.com/2026/08/19/nmcli-abbreviation-ambiguity-trixie/" rel="noopener noreferrer"&gt;a WiFi profile that exists with the right SSID and no credentials&lt;/a&gt;: the thing you can see being right is not evidence about the thing you cannot.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>iptables says your kernel needs upgrading. Upgrading the kernel is what broke it.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Thu, 27 Aug 2026 02:14:13 +0000</pubDate>
      <link>https://dev.to/homelabpm/iptables-says-your-kernel-needs-upgrading-upgrading-the-kernel-is-what-broke-it-10le</link>
      <guid>https://dev.to/homelabpm/iptables-says-your-kernel-needs-upgrading-upgrading-the-kernel-is-what-broke-it-10le</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On Raspberry Pi's 6.18 kernel line, &lt;code&gt;ip_tables.ko&lt;/code&gt;, &lt;code&gt;iptable_nat.ko&lt;/code&gt; and &lt;code&gt;iptable_filter.ko&lt;/code&gt; are not built. This is deliberate — legacy iptables was deprecated in favour of nftables. But &lt;code&gt;CONFIG_IP_NF_IPTABLES=m&lt;/code&gt; is &lt;em&gt;still set&lt;/em&gt; in the shipped kernel config, because the symbol that actually builds the legacy modules is now the separately-named &lt;code&gt;CONFIG_IP_NF_IPTABLES_LEGACY&lt;/code&gt;, and that one is unset. So the config appears to promise a module that no longer comes from it, the module directory is still full of &lt;code&gt;ipt_*&lt;/code&gt; files, and the error you get tells you to upgrade the kernel you just upgraded.&lt;/p&gt;

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

&lt;p&gt;A Pi 4B on the current kernel:&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;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
6.18.34+rpt-rpi-v8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that shells out to legacy &lt;code&gt;iptables&lt;/code&gt; — a WireGuard container's &lt;code&gt;PostUp&lt;/code&gt;, a Docker setup pinned to the legacy backend, Waydroid's install script — fails the moment it tries to write a NAT rule. Reproduced directly, with the same binary on both backends:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;--version&lt;/span&gt;
iptables v1.8.11 &lt;span class="o"&gt;(&lt;/span&gt;nf_tables&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine. Now switch the alternative, change nothing else, and run the identical command:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;update-alternatives &lt;span class="nt"&gt;--set&lt;/span&gt; iptables /usr/sbin/iptables-legacy
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;--version&lt;/span&gt;
iptables v1.8.11 &lt;span class="o"&gt;(&lt;/span&gt;legacy&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;
modprobe: FATAL: Module ip_tables not found &lt;span class="k"&gt;in &lt;/span&gt;directory /lib/modules/6.18.34+rpt-rpi-v8
iptables v1.8.11 &lt;span class="o"&gt;(&lt;/span&gt;legacy&lt;span class="o"&gt;)&lt;/span&gt;: can&lt;span class="s1"&gt;'t initialize iptables table `nat'&lt;/span&gt;: Table does not exist &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;do &lt;/span&gt;you need to insmod?&lt;span class="o"&gt;)&lt;/span&gt;
Perhaps iptables or your kernel needs to be upgraded.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that last line again. &lt;strong&gt;The kernel is the newest one available, and upgrading to it is what caused this.&lt;/strong&gt; Doing what the error suggests moves you further from a working system, and it is the first thing anyone will try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to misdiagnose
&lt;/h2&gt;

&lt;p&gt;The error offers two suggestions and both are dead ends. "Do you need to insmod?" points at a module that will never exist on this kernel; "your kernel needs to be upgraded" points backwards. Neither mentions the word &lt;code&gt;nftables&lt;/code&gt;, which is the actual answer.&lt;/p&gt;

&lt;p&gt;Then you go looking, and the evidence on disk agrees with the error rather than with reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The module directory is not empty.&lt;/strong&gt; This is the part that really costs time:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; /lib/modules/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/kernel/net/ipv4/netfilter/
arpt_mangle.ko.xz
ipt_ah.ko.xz
ipt_ECN.ko.xz
ipt_REJECT.ko.xz
ipt_rpfilter.ko.xz
ipt_SYNPROXY.ko.xz
nf_defrag_ipv4.ko.xz
nf_dup_ipv4.ko.xz
nf_nat_h323.ko.xz
nf_nat_pptp.ko.xz
nf_nat_snmp_basic.ko.xz
nf_reject_ipv4.ko.xz
nf_socket_ipv4.ko.xz
nft_dup_ipv4.ko.xz
nft_fib_ipv4.ko.xz
nf_tproxy_ipv4.ko.xz
nft_reject_ipv4.ko.xz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventeen files, five of them named &lt;code&gt;ipt_*&lt;/code&gt;. It looks like a healthy legacy-iptables install. The &lt;code&gt;ipt_*&lt;/code&gt; entries are &lt;em&gt;matches and targets&lt;/em&gt; — &lt;code&gt;REJECT&lt;/code&gt;, &lt;code&gt;SYNPROXY&lt;/code&gt;, &lt;code&gt;rpfilter&lt;/code&gt; — and they still ship. What is missing is the three modules that provide the tables those targets would go into, and nothing about the listing draws your eye to an absence.&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ip_tables
modprobe: FATAL: Module ip_tables not found &lt;span class="k"&gt;in &lt;/span&gt;directory /lib/modules/6.18.34+rpt-rpi-v8
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; iptable_nat
modprobe: FATAL: Module iptable_nat not found &lt;span class="k"&gt;in &lt;/span&gt;directory /lib/modules/6.18.34+rpt-rpi-v8
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; iptable_filter
modprobe: FATAL: Module iptable_filter not found &lt;span class="k"&gt;in &lt;/span&gt;directory /lib/modules/6.18.34+rpt-rpi-v8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;And the kernel config says the module is built.&lt;/strong&gt; This is where most people stop and conclude they have found a packaging bug:&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;&lt;span class="nb"&gt;grep &lt;/span&gt;CONFIG_IP_NF_IPTABLES /boot/config-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CONFIG_IP_NF_IPTABLES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;=m&lt;/code&gt; means "build as a loadable module." The module is not there. That looks exactly like a broken kernel build, it is a reasonable thing to file a bug about, and people have — against four separate projects, none of which own the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;The config is not lying. It is answering about a different thing than the one you are asking about, because the meaning of that symbol changed underneath the name.&lt;/p&gt;

&lt;p&gt;Two more lines from the same file settle it:&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;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'CONFIG_IP_NF_IPTABLES_LEGACY|CONFIG_NFT_COMPAT'&lt;/span&gt; /boot/config-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CONFIG_NFT_COMPAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CONFIG_NFT_COMPAT=m&lt;/code&gt; is present. &lt;code&gt;CONFIG_IP_NF_IPTABLES_LEGACY&lt;/code&gt; produces no output at all — it is unset, and &lt;strong&gt;that&lt;/strong&gt; is the symbol that builds &lt;code&gt;ip_tables.ko&lt;/code&gt; today. &lt;code&gt;CONFIG_IP_NF_IPTABLES&lt;/code&gt; kept the historic name and now selects the nftables-backed path instead.&lt;/p&gt;

&lt;p&gt;Upstream said so plainly, over six months ago. &lt;a href="https://github.com/raspberrypi/linux/issues/7220" rel="noopener noreferrer"&gt;Phil Elwell answered this on &lt;code&gt;raspberrypi/linux#7220&lt;/code&gt;&lt;/a&gt; in February 2026:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ip_tables has been deprecated in favour of nf_tables. CONFIG_IP_NF_IPTABLES enables an ip_tables-like shim over nf_tables.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That issue is closed as &lt;em&gt;completed&lt;/em&gt;, which is worth pausing on: on GitHub, &lt;code&gt;closed as completed&lt;/code&gt; normally means the bug was fixed. Here it means the question was answered and the behaviour is intended. If you check only the state and not the thread, you will conclude this was fixed and stop looking — and the modules will still be missing.&lt;/p&gt;

&lt;p&gt;The nftables side of the same kernel is fully populated — 28 modules matching &lt;code&gt;nf_tables&lt;/code&gt;/&lt;code&gt;nft_&lt;/code&gt; under &lt;code&gt;kernel/net/netfilter/&lt;/code&gt; — so this is not a kernel with its firewalling removed. It is a kernel with exactly one way to do firewalling, and a userspace shim (&lt;code&gt;iptables-nft&lt;/code&gt;) that makes the old commands work against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this actually hits
&lt;/h2&gt;

&lt;p&gt;Not everyone, and the reports do not make that clear.&lt;/p&gt;

&lt;p&gt;A stock Raspberry Pi OS Lite install &lt;a href="https://homelabpostmortem.com/2026/08/22/docker-publishes-past-ufw/" rel="noopener noreferrer"&gt;ships no &lt;code&gt;iptables&lt;/code&gt; binary at all&lt;/a&gt; — it arrives as a dependency of something else, usually &lt;code&gt;ufw&lt;/code&gt;, and &lt;code&gt;update-alternatives&lt;/code&gt; resolves it to &lt;code&gt;iptables-nft&lt;/code&gt;. On that path nothing ever touches a legacy module and none of this happens. A default Docker install on a default image does not hit it either.&lt;/p&gt;

&lt;p&gt;What hits it is anything that &lt;strong&gt;deliberately selects the legacy backend&lt;/strong&gt;, which used to be sound advice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container images whose startup scripts call legacy &lt;code&gt;iptables&lt;/code&gt; for MASQUERADE/FORWARD rules — this is why WireGuard front-ends broke.&lt;/li&gt;
&lt;li&gt;Distributions and provisioning scripts that pin &lt;code&gt;update-alternatives&lt;/code&gt; to &lt;code&gt;iptables-legacy&lt;/code&gt;, a long-standing workaround for older Docker/nftables incompatibilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those was a reasonable decision when it was made. They break now because the thing they pinned to stopped existing, and the failure surfaces inside whichever tool made the call rather than at the pin.&lt;/p&gt;

&lt;p&gt;That is also why the fix information is so hard to find. The canonical report is &lt;a href="https://github.com/wg-easy/wg-easy/issues/2614" rel="noopener noreferrer"&gt;&lt;code&gt;wg-easy#2614&lt;/code&gt;&lt;/a&gt;, which the maintainers closed as &lt;strong&gt;not planned&lt;/strong&gt; — correctly, since it is not their bug. So the thread with twenty comments of people rediscovering the same thing sits in a project that has, accurately, disclaimed it.&lt;/p&gt;

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

&lt;p&gt;If you control the alternative, point it at the shim. This is the whole fix for most cases:&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 &lt;/span&gt;update-alternatives &lt;span class="nt"&gt;--set&lt;/span&gt; iptables /usr/sbin/iptables-nft
&lt;span class="nb"&gt;sudo &lt;/span&gt;update-alternatives &lt;span class="nt"&gt;--set&lt;/span&gt; ip6tables /usr/sbin/ip6tables-nft
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart whatever failed. Verify you are actually on the shim rather than trusting the command's exit code:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;--version&lt;/span&gt;
iptables v1.8.11 &lt;span class="o"&gt;(&lt;/span&gt;nf_tables&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;(nf_tables)&lt;/code&gt; in that output is the thing to check. &lt;code&gt;(legacy)&lt;/code&gt; there means you are still pointed at a backend with no kernel behind it, whatever else looks right.&lt;/p&gt;

&lt;p&gt;If the caller is inside a container image you do not control, the alternative is not yours to set — the container has its own userspace. There the answer is to replace the legacy calls with &lt;code&gt;nft&lt;/code&gt; equivalents in whatever hook the image exposes, which is what the wg-easy threads eventually converged on, or to run the container with host networking so the host's own rules apply.&lt;/p&gt;

&lt;p&gt;And before you rely on any of this on a Pi, the check 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;modprobe &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ip_tables 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-n&lt;/code&gt; is dry-run: it resolves the module without loading it. If that prints &lt;code&gt;FATAL: Module ip_tables not found&lt;/code&gt;, then every legacy-iptables tool on that machine is going to fail, and you know it before you have installed one.&lt;/p&gt;

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

&lt;p&gt;The trap here is a config symbol that kept its name while changing what it produces. &lt;code&gt;CONFIG_IP_NF_IPTABLES=m&lt;/code&gt; was true and useful for years, it is still present, and it now means something else. Nothing in the file marks the change — there is no deprecated flag, no comment, no warning at build time. The only way to see it is to know that a second symbol exists and to look for its absence, and you cannot grep for the absence of a name you have never heard of.&lt;/p&gt;

&lt;p&gt;So the habit: &lt;strong&gt;when a config says a thing exists and the thing does not exist, suspect the symbol before you suspect the build.&lt;/strong&gt; A missing artifact with a present config is much more often a renamed or re-scoped option than a broken toolchain — the toolchain failing loudly is the common case, and this failed quietly.&lt;/p&gt;

&lt;p&gt;The wider one is about reading closed issues. &lt;code&gt;closed as completed&lt;/code&gt; on an upstream tracker is a strong signal and it is genuinely useful — it correctly killed an investigation for me two days earlier, on this same tracker, where a &lt;code&gt;bcmgenet&lt;/code&gt; regression really had been fixed and shipped to apt while the reports about it were still circulating. Here the same state on the same tracker meant "answered, working as intended." &lt;strong&gt;The state field tells you the thread is over. It does not tell you which way it ended.&lt;/strong&gt; That is in the comments, usually the last few, and there is no way to skip reading them.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>networking</category>
      <category>docker</category>
    </item>
    <item>
      <title>tar backed up your Pi's WiFi config and exited 0. The archive holds one empty directory.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 26 Aug 2026 02:26:38 +0000</pubDate>
      <link>https://dev.to/homelabpm/tar-backed-up-your-pis-wifi-config-and-exited-0-the-archive-holds-one-empty-directory-42gk</link>
      <guid>https://dev.to/homelabpm/tar-backed-up-your-pis-wifi-config-and-exited-0-the-archive-holds-one-empty-directory-42gk</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On a stock Trixie Pi OS image, the WiFi credentials for the connection this Pi actually uses are stored in &lt;code&gt;/etc/netplan/90-NM-&amp;lt;uuid&amp;gt;.yaml&lt;/code&gt;, not in &lt;code&gt;/etc/NetworkManager/system-connections/&lt;/code&gt;. That second directory — the one every guide, every backup snippet and every "copy your config to the new card" workflow names — is &lt;strong&gt;completely empty&lt;/strong&gt;. &lt;code&gt;tar&lt;/code&gt; archives it happily, exits 0, and produces a 10 KB file containing a single directory entry and no credentials. You find out after you have already reimaged.&lt;/p&gt;

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

&lt;p&gt;The machine is a Pi 4B on Raspberry Pi OS Lite, current image:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/rpi-issue | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
Raspberry Pi reference 2026-06-18
&lt;span class="nv"&gt;$ &lt;/span&gt;nmcli &lt;span class="nt"&gt;--version&lt;/span&gt;
nmcli tool, version 1.52.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WiFi works. NetworkManager is running and knows about the connection:&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;nmcli &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; NAME,TYPE,DEVICE con show
netplan-wlan0-HOMEWIFI:802-11-wireless:wlan0
lo:loopback:lo
netplan-eth0:802-3-ethernet:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you do the sensible thing before reimaging — save the network config:&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;&lt;span class="nb"&gt;sudo tar &lt;/span&gt;cf /tmp/wifi-backup.tar &lt;span class="nt"&gt;-C&lt;/span&gt; /etc/NetworkManager system-connections
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /tmp/wifi-backup.tar
&lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1 root root 10240 Aug 26 11:06 /tmp/wifi-backup.tar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten kilobytes. Exit zero. No warning. Here is what is in it:&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;&lt;span class="nb"&gt;tar &lt;/span&gt;tvf /tmp/wifi-backup.tar
drwxr-xr-x root/root         0 2026-08-26 10:09 system-connections/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One directory entry. Nothing else. Because:&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;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/NetworkManager/system-connections/
total 8
drwxr-xr-x 2 root root 4096 Aug 26 10:09 &lt;span class="nb"&gt;.&lt;/span&gt;
drwxr-xr-x 8 root root 4096 Aug 26 10:09 ..
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The directory is empty, has always been empty on this image, and the connection that is currently carrying your SSH session is not in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to miss
&lt;/h2&gt;

&lt;p&gt;Three things line up to keep you from noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The archive isn't empty.&lt;/strong&gt; A 0-byte file would make you look. &lt;code&gt;tar&lt;/code&gt; pads to a 10240-byte block, so an archive of one empty directory is the same size as an archive of a few small files. It looks exactly like a successful backup of a handful of &lt;code&gt;.nmconnection&lt;/code&gt; files, which is what you expected to get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NetworkManager's own config points you at the wrong place.&lt;/strong&gt; If you go looking for where connections are stored, this is what you find:&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;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*#|^$'&lt;/span&gt; /etc/NetworkManager/NetworkManager.conf
&lt;span class="o"&gt;[&lt;/span&gt;main]
&lt;span class="nv"&gt;plugins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ifupdown,keyfile
&lt;span class="o"&gt;[&lt;/span&gt;ifupdown]
&lt;span class="nv"&gt;managed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keyfile&lt;/code&gt; is the plugin that reads and writes &lt;code&gt;.nmconnection&lt;/code&gt; files. It is enabled, and &lt;code&gt;NetworkManager --print-config&lt;/code&gt; — the effective configuration, drop-ins included — agrees:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;NetworkManager &lt;span class="nt"&gt;--print-config&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; &lt;span class="s1"&gt;'^\[main\]'&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;main]
&lt;span class="nv"&gt;plugins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ifupdown,keyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of that is wrong, which is what makes it unhelpful. The keyfile plugin reads &lt;em&gt;three&lt;/em&gt; directories — &lt;code&gt;/etc/NetworkManager/system-connections&lt;/code&gt;, &lt;code&gt;/run/NetworkManager/system-connections&lt;/code&gt;, and &lt;code&gt;/usr/lib/...&lt;/code&gt; — and only the first is writable and persistent. Netplan renders its YAML into the &lt;code&gt;/run&lt;/code&gt; one at boot, so the keyfile plugin is genuinely doing all the work, on files it did not write, in a directory the config never names. There is a &lt;code&gt;netplan.conf&lt;/code&gt; drop-in, but it lives in &lt;code&gt;/run/NetworkManager/conf.d/&lt;/code&gt; and only assigns device management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[device-netplan.wifis.wlan0]&lt;/span&gt;
&lt;span class="py"&gt;match-device&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;type:wifi&lt;/span&gt;
&lt;span class="py"&gt;managed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in any file under &lt;code&gt;/etc&lt;/code&gt; tells you netplan is holding your PSK.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There &lt;em&gt;are&lt;/em&gt; &lt;code&gt;.nmconnection&lt;/code&gt; files, and finding them makes you think you had the wrong path, not the wrong idea:&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;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /run/NetworkManager/system-connections/
total 12
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 314 Aug 25 00:56 lo.nmconnection
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 174 Aug 26 10:09 netplan-eth0.nmconnection
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 295 Aug 26 10:09 netplan-wlan0-HOMEWIFI.nmconnection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There they are, in the format you expected, with the names you expected. It is easy to conclude the directory just moved and get on with your day. It didn't:&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;findmnt &lt;span class="nt"&gt;-no&lt;/span&gt; FSTYPE /run
tmpfs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are runtime copies on a tmpfs. They are regenerated at every boot from something else, and if you back those up instead you have captured a derived artifact rather than the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;The source is in &lt;code&gt;/etc/netplan&lt;/code&gt;:&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;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /etc/netplan/
total 8
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 275 Aug 26 10:09 90-NM-75a1216a-9d1a-30cd-8aca-ace5526ec021.yaml
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 590 Aug 26 10:09 90-NM-be1282f3-d98b-3db6-9c1f-0cd80398f4f5.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two files, mode 600, named after connection UUIDs rather than after anything a human would search for. The second one is the WiFi:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;wifis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;wlan0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;renderer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkManager&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="na"&gt;dhcp4&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;access-points&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HOMEWIFI"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;key-management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psk"&lt;/span&gt;
            &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;REDACTED&amp;gt;"&lt;/span&gt;
          &lt;span class="na"&gt;networkmanager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;uuid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;be1282f3-d98b-3db6-9c1f-0cd80398f4f5"&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;netplan-wlan0-HOMEWIFI"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pre-shared key is right there in plaintext, which is why the file is 600. This is the file that matters, and it is the one your backup missed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Both stores are live, and you cannot predict which one a connection is in
&lt;/h3&gt;

&lt;p&gt;This is the part that makes the situation genuinely confusing rather than merely surprising: &lt;strong&gt;both stores are live at once, and nothing on the machine announces which connection is in which.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;wlan0&lt;/code&gt; and &lt;code&gt;eth0&lt;/code&gt; profiles on this machine are netplan YAML. A connection created with &lt;code&gt;nmcli&lt;/code&gt; is not — it lands in the keyfile directory, and that holds for real device types, not just a synthetic one:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli con add &lt;span class="nb"&gt;type &lt;/span&gt;wifi con-name pm-grill-wifi ifname wlan0 &lt;span class="se"&gt;\&lt;/span&gt;
    ssid PM-GRILL-TEST autoconnect no wifi-sec.key-mgmt wpa-psk wifi-sec.psk ...
Connection &lt;span class="s1"&gt;'pm-grill-wifi'&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;82da931e-...&lt;span class="o"&gt;)&lt;/span&gt; successfully added.

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli con add &lt;span class="nb"&gt;type &lt;/span&gt;ethernet con-name pm-grill-eth ifname eth0 &lt;span class="se"&gt;\&lt;/span&gt;
    autoconnect no ipv4.method manual ipv4.addresses 10.98.98.2/24
Connection &lt;span class="s1"&gt;'pm-grill-eth'&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;ce3c1462-...&lt;span class="o"&gt;)&lt;/span&gt; successfully added.

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; /etc/NetworkManager/system-connections/
pm-grill-eth.nmconnection
pm-grill-wifi.nmconnection
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; /etc/netplan/
90-NM-75a1216a-9d1a-30cd-8aca-ace5526ec021.yaml
90-NM-be1282f3-d98b-3db6-9c1f-0cd80398f4f5.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A WiFi connection and an ethernet connection — the same two types the netplan-backed profiles use — both went to the keyfile directory. The netplan files were untouched.&lt;/p&gt;

&lt;p&gt;I am deliberately not going to tell you which tool put the &lt;code&gt;wlan0&lt;/code&gt; profile into netplan, because I could not establish it and I would rather say so. &lt;code&gt;raspi-config&lt;/code&gt; sets WiFi by shelling out to &lt;code&gt;nmcli&lt;/code&gt; — there is not one reference to netplan in it — and &lt;code&gt;nmtui&lt;/code&gt; is part of &lt;code&gt;network-manager&lt;/code&gt; and goes through the same library. Both of those should therefore land in the keyfile directory, exactly as my test connections did. They did not, for this machine's WiFi profile.&lt;/p&gt;

&lt;p&gt;Take that as the practical finding rather than a loose end: &lt;strong&gt;a Pi that has been configured over time can have some of its network state in each store, and which is which is not something you can reason out from the tool you remember using.&lt;/strong&gt; You have to look. Backing up either directory alone silently captures part of the picture, and the part it misses is not predictable.&lt;/p&gt;

&lt;p&gt;Modifying an existing netplan-backed connection, on the other hand, edits the YAML in place rather than migrating it to a keyfile. Setting a static IP on the (unused, disconnected) &lt;code&gt;eth0&lt;/code&gt; profile:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli con modify netplan-eth0 ipv4.method manual &lt;span class="se"&gt;\&lt;/span&gt;
    ipv4.addresses 10.99.99.5/24 ipv4.gateway 10.99.99.1
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;rewrites the &lt;code&gt;ethernets:&lt;/code&gt; block in &lt;code&gt;90-NM-75a1216a-....yaml&lt;/code&gt; and leaves &lt;code&gt;system-connections/&lt;/code&gt; empty. Worth stating plainly, because it is the thing people expect to be broken and it isn't: &lt;strong&gt;&lt;code&gt;nmcli&lt;/code&gt; works.&lt;/strong&gt; Setting a static IP on Trixie behaves exactly as documented, and &lt;code&gt;nmcli -g ipv4.addresses con show&lt;/code&gt; reads the value back. The failure here is not in configuring the network. It is in &lt;em&gt;finding&lt;/em&gt; the configuration afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  The received answer is the wrong one
&lt;/h3&gt;

&lt;p&gt;The standard answer to "where does Raspberry Pi OS keep my WiFi password" has been &lt;code&gt;/etc/NetworkManager/system-connections/&lt;/code&gt; since Bookworm moved off &lt;code&gt;dhcpcd&lt;/code&gt; in 2023, and write-ups as recent as July 2026 still give that answer without qualification. On this image it is wrong, and nothing on the machine contradicts it loudly enough to notice — the directory exists, the plugin that owns it is enabled, and &lt;code&gt;.nmconnection&lt;/code&gt; files really do exist a few paths away.&lt;/p&gt;

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

&lt;p&gt;The obvious move is to stop guessing at paths and ask NetworkManager where the connection lives. It has a field for exactly that, and on this machine it does not help:&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;nmcli &lt;span class="nt"&gt;-f&lt;/span&gt; NAME,FILENAME con show
NAME                    FILENAME
netplan-wlan0-HOMEWIFI  /run/NetworkManager/system-connections/netplan-wlan0-HOMEWIFI.nmconnection
lo                      /run/NetworkManager/system-connections/lo.nmconnection
netplan-eth0            /run/NetworkManager/system-connections/netplan-eth0.nmconnection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FILENAME&lt;/code&gt; reports the file NetworkManager actually loaded the profile from, which is the truth — and for a netplan-sourced connection the truth is the tmpfs copy. Back up what that column names and you have backed up the derived artifact that disappears at the next boot.&lt;/p&gt;

&lt;p&gt;What makes this genuinely treacherous rather than merely wrong is that the column is &lt;em&gt;correct&lt;/em&gt; for connections you created yourself. Add one with &lt;code&gt;nmcli con add&lt;/code&gt; and it reports the real, persistent path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pm-grill-wifi  /etc/NetworkManager/system-connections/pm-grill-wifi.nmconnection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;FILENAME&lt;/code&gt; is reliable exactly for the connections you already know where to find, and misleading for the one that came with the image and holds your WiFi password.&lt;/p&gt;

&lt;p&gt;There is no single field that names the persistent source. What works is mapping by UUID, which is stable across both stores:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; NAME,UUID con show | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;: &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; name uuid&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$uuid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /etc/netplan/ 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$uuid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /etc/NetworkManager/system-connections/ 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-26s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&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;src&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;runtime-generated, no persistent source&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;netplan-wlan0-HOMEWIFI     /etc/netplan/90-NM-be1282f3-d98b-3db6-9c1f-0cd80398f4f5.yaml
lo                         &amp;lt;runtime-generated, no persistent source&amp;gt;
netplan-eth0               /etc/netplan/90-NM-75a1216a-9d1a-30cd-8aca-ace5526ec021.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It finds keyfile-backed connections too — adding one with &lt;code&gt;nmcli con add&lt;/code&gt; makes it show up under &lt;code&gt;/etc/NetworkManager/system-connections/&lt;/code&gt; on the next run — and it correctly reports &lt;code&gt;lo&lt;/code&gt; as having no persistent source, because it doesn't.&lt;/p&gt;

&lt;p&gt;Run that &lt;em&gt;before&lt;/em&gt; you reimage rather than after. To capture everything regardless of origin:&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 tar &lt;/span&gt;czf wifi-backup.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/netplan &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/NetworkManager/system-connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify the archive contains files and not just directories — the whole point of this post is that the command succeeding proves 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;tar &lt;/span&gt;tzvf wifi-backup.tar.gz | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'/$'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints nothing, you have backed up nothing.&lt;/p&gt;

&lt;p&gt;Size is not the signal either, and on this machine it points the wrong way. The correct archive — both netplan YAMLs, PSK included, gzipped — is &lt;strong&gt;566 bytes&lt;/strong&gt;. The one that captured nothing is &lt;strong&gt;10,240 bytes&lt;/strong&gt;, because &lt;code&gt;tar&lt;/code&gt; pads to a 10 KB block whether or not it found anything to put in it. The bigger file is the empty one.&lt;/p&gt;

&lt;p&gt;Two cautions on the archive itself. It contains your PSK in plaintext, so it inherits the 600 that the source files carry for a reason — don't leave it in &lt;code&gt;/tmp&lt;/code&gt; or commit it anywhere. And when restoring onto a new image, put each file back where it came from: a netplan YAML dropped into &lt;code&gt;system-connections/&lt;/code&gt; is not read, and vice versa.&lt;/p&gt;

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

&lt;p&gt;The failure here is a specific instance of a general one: &lt;strong&gt;an operation that has nothing to do can't tell you apart from an operation that succeeded.&lt;/strong&gt; &lt;code&gt;tar&lt;/code&gt; was asked to archive a directory. It did. Zero files matched, and zero matched is indistinguishable from "there was nothing that needed archiving" — the same shape as &lt;a href="https://homelabpostmortem.com/2026/08/16/rpi-clone-partuuid-trap/" rel="noopener noreferrer"&gt;a clone tool whose search-and-replace matches nothing and reports success&lt;/a&gt;, and the same shape as &lt;a href="https://homelabpostmortem.com/2026/08/18/trixie-journald-volatile-logs/" rel="noopener noreferrer"&gt;a journal directory that exists to reassure you persistence is on when it isn't&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the narrow habit, which costs one command: &lt;strong&gt;after any backup, list the archive.&lt;/strong&gt; Not the exit code, not the file size — the contents. Both of those were fine here.&lt;/p&gt;

&lt;p&gt;And the wider one, which is the part I got wrong on the first pass: &lt;strong&gt;asking the tool is better than asking the internet, but "the tool told me" is not the same as "this is the file I need."&lt;/strong&gt; &lt;code&gt;nmcli&lt;/code&gt;'s &lt;code&gt;FILENAME&lt;/code&gt; is accurate — it answers "where did I load this from?" precisely. My question was "what do I have to copy so this survives a reimage?", and those turn out to be different files. Every layer here answers its own question correctly. The gap is between the question the tool answers and the one you meant, and nothing in the output marks where that gap is.&lt;/p&gt;

&lt;p&gt;That is the same failure as trusting &lt;code&gt;tar&lt;/code&gt;'s exit code, one level up. Both times the machine was right and the inference was wrong.&lt;/p&gt;

&lt;p&gt;A ready-to-run version of this check — which finds every connection's real backing file across both stores, warns when &lt;code&gt;/etc/NetworkManager/system-connections/&lt;/code&gt; is empty, and verifies that a backup archive actually contains files — 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;
  
  
  Toolkit
&lt;/h2&gt;

&lt;p&gt;This post's fix is available as a tested, ready-to-run script in the toolkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>networking</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Podman publishes the same port and your firewall still holds. Even as root.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Mon, 24 Aug 2026 15:28:56 +0000</pubDate>
      <link>https://dev.to/homelabpm/podman-publishes-the-same-port-and-your-firewall-still-holds-even-as-root-31on</link>
      <guid>https://dev.to/homelabpm/podman-publishes-the-same-port-and-your-firewall-still-holds-even-as-root-31on</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On the same box, same port, same &lt;code&gt;ufw&lt;/code&gt; config where &lt;a href="https://homelabpostmortem.com/2026/08/22/docker-publishes-past-ufw/" rel="noopener noreferrer"&gt;Docker published straight past the firewall&lt;/a&gt;, Podman does not — the port times out from another machine exactly as &lt;code&gt;ufw status&lt;/code&gt; claims. Rootless Podman writes no firewall rules at all. But &lt;strong&gt;rootful Podman does write them, and the port is still blocked&lt;/strong&gt;, so "it has no root" is not the explanation. Docker inserts an ACCEPT into &lt;code&gt;FORWARD&lt;/code&gt; ahead of ufw's chains; netavark keeps its rules in its own table and never overrides ufw's &lt;code&gt;policy drop&lt;/code&gt;. Also: Podman never needed &lt;code&gt;iptables&lt;/code&gt; installed at any point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;The Docker writeup got a comment on r/selfhosted saying, roughly: this is why I use rootless Podman — it can't modify the system firewall if it doesn't have root.&lt;/p&gt;

&lt;p&gt;That is a satisfying explanation, and I wanted it to be true, which is a good reason to go and check it rather than repeat it. It turns out to be right about the outcome and wrong about the mechanism, and the wrong part is the more interesting half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Identical to the Docker test, deliberately. Raspberry Pi OS Lite (Trixie) on a Pi 4B, &lt;code&gt;ufw&lt;/code&gt; active, default deny incoming, one rule for SSH:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose
Status: active
Default: deny &lt;span class="o"&gt;(&lt;/span&gt;incoming&lt;span class="o"&gt;)&lt;/span&gt;, allow &lt;span class="o"&gt;(&lt;/span&gt;outgoing&lt;span class="o"&gt;)&lt;/span&gt;, disabled &lt;span class="o"&gt;(&lt;/span&gt;routed&lt;span class="o"&gt;)&lt;/span&gt;

To                         Action      From
&lt;span class="nt"&gt;--&lt;/span&gt;                         &lt;span class="nt"&gt;------&lt;/span&gt;      &lt;span class="nt"&gt;----&lt;/span&gt;
22/tcp                     ALLOW IN    Anywhere
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Baseline, before any container runtime: a plain listener on 8080 is blocked from another machine on the LAN.&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;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 8080 &lt;span class="nt"&gt;--bind&lt;/span&gt; 0.0.0.0
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;from the laptop:
&lt;span class="go"&gt;192.168.128.154:8080 → Connection timed out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;apt install podman&lt;/code&gt; — 26 packages, none of them &lt;code&gt;iptables&lt;/code&gt;. It pulls &lt;code&gt;netavark&lt;/code&gt; (Podman's network backend), &lt;code&gt;aardvark-dns&lt;/code&gt;, and &lt;code&gt;passt&lt;/code&gt;/&lt;code&gt;slirp4netns&lt;/code&gt; for rootless networking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rootless: no firewall rules at all
&lt;/h2&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;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pmtest &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 docker.io/library/nginx:alpine
&lt;span class="nv"&gt;$ &lt;/span&gt;podman ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Ports}}'&lt;/span&gt;
0.0.0.0:8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is byte-identical to what Docker printed. Bound to all interfaces, same port, same everything. Locally it answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;localhost:8080 → HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From another machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.168.128.154:8080 → timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the firewall is untouched:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
387                      &lt;span class="c"&gt;# unchanged from before the container started&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 8080
0
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list tables
table ip filter
table ip6 filter         &lt;span class="c"&gt;# ufw's. No nat table, no netavark table.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing. No DNAT, no ACCEPT, no new table. The commenter's description holds exactly here: rootless has no way to write these rules, so it doesn't, and &lt;code&gt;ufw&lt;/code&gt; decides — correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rootful: rules appear, and the port is &lt;em&gt;still&lt;/em&gt; blocked
&lt;/h2&gt;

&lt;p&gt;This is the part that decides whether "no root" is really the explanation.&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pmroot &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 docker.io/library/nginx:alpine
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;podman ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Ports}}'&lt;/span&gt;
0.0.0.0:8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the firewall &lt;em&gt;does&lt;/em&gt; change:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
447                      &lt;span class="c"&gt;# was 387&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 8080
4
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list tables
table ip filter
table ip6 filter
table inet netavark      &lt;span class="c"&gt;# new&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules written, DNAT in place. By the Docker precedent this is the moment the port becomes reachable. It doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.168.128.154:8080 → timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the privilege level is not what saved us. Something about &lt;em&gt;which&lt;/em&gt; rules get written is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What netavark actually writes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="n"&gt;inet&lt;/span&gt; &lt;span class="n"&gt;netavark&lt;/span&gt; {
    &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="n"&gt;FORWARD&lt;/span&gt; {
        &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="n"&gt;forward&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;accept&lt;/span&gt;;
        &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="n"&gt;drop&lt;/span&gt;
        &lt;span class="n"&gt;jump&lt;/span&gt; &lt;span class="n"&gt;NETAVARK&lt;/span&gt;-&lt;span class="n"&gt;ISOLATION&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="n"&gt;daddr&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;88&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;established&lt;/span&gt;,&lt;span class="n"&gt;related&lt;/span&gt; &lt;span class="n"&gt;accept&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="n"&gt;saddr&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;88&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="n"&gt;accept&lt;/span&gt;
    }

    &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="n"&gt;PREROUTING&lt;/span&gt; {
        &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="n"&gt;nat&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="n"&gt;prerouting&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="n"&gt;dstnat&lt;/span&gt;; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;accept&lt;/span&gt;;
        &lt;span class="n"&gt;fib&lt;/span&gt; &lt;span class="n"&gt;daddr&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;jump&lt;/span&gt; &lt;span class="n"&gt;NETAVARK&lt;/span&gt;-&lt;span class="n"&gt;HOSTPORT&lt;/span&gt;-&lt;span class="n"&gt;DNAT&lt;/span&gt;
    }

    &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="n"&gt;NETAVARK&lt;/span&gt;-&lt;span class="n"&gt;HOSTPORT&lt;/span&gt;-&lt;span class="n"&gt;DNAT&lt;/span&gt; {
        &lt;span class="n"&gt;tcp&lt;/span&gt; &lt;span class="n"&gt;dport&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt; &lt;span class="n"&gt;jump&lt;/span&gt; &lt;span class="n"&gt;nv_2f259bab_10_88_0_0_nm16_dnat&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DNAT is there and it works — more on proving that below. What matters is the FORWARD chain. netavark's accepts are scoped to its own container network, &lt;code&gt;10.88.0.0/16&lt;/code&gt;. It has &lt;code&gt;policy accept&lt;/code&gt;, but it only &lt;em&gt;accepts&lt;/em&gt; traffic that belongs to it.&lt;/p&gt;

&lt;p&gt;ufw's FORWARD chain, in a separate table, is not so relaxed:&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="n"&gt;chain&lt;/span&gt; &lt;span class="n"&gt;FORWARD&lt;/span&gt; {
    &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="n"&gt;forward&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;drop&lt;/span&gt;;
    &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="n"&gt;packets&lt;/span&gt; &lt;span class="m"&gt;19&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="m"&gt;2309&lt;/span&gt; &lt;span class="n"&gt;jump&lt;/span&gt; &lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;logging&lt;/span&gt;-&lt;span class="n"&gt;forward&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="n"&gt;packets&lt;/span&gt; &lt;span class="m"&gt;19&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="m"&gt;2309&lt;/span&gt; &lt;span class="n"&gt;jump&lt;/span&gt; &lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;forward&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="n"&gt;packets&lt;/span&gt; &lt;span class="m"&gt;19&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="m"&gt;2309&lt;/span&gt; &lt;span class="n"&gt;jump&lt;/span&gt; &lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;after&lt;/span&gt;-&lt;span class="n"&gt;forward&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;policy drop&lt;/code&gt;, and a counter that is climbing — those 19 packets are the connection attempts from my laptop being dropped.&lt;/p&gt;

&lt;p&gt;Both chains hang off the same &lt;code&gt;forward&lt;/code&gt; hook at the same priority. In nftables that means both run, and a drop anywhere is final. netavark never tries to be first, never inserts anything into ufw's chains, and never short-circuits them. It manages its own traffic and leaves the host's policy alone.&lt;/p&gt;

&lt;p&gt;Docker's &lt;code&gt;-A FORWARD -j DOCKER&lt;/code&gt; with an ACCEPT inside is the outlier here, not the norm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the DNAT genuinely works
&lt;/h2&gt;

&lt;p&gt;"It's blocked" could just mean the port forwarding never worked. It isn't that. Turn ufw off with the same rootful container still running:&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;sudo &lt;/span&gt;ufw disable
&lt;span class="go"&gt;192.168.128.154:8080 → HTTP 200
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;span class="go"&gt;192.168.128.154:8080 → timed out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redirect is fine. ufw is doing the blocking, which is the entire point — the firewall is back to being the thing that decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing: iptables never appeared
&lt;/h2&gt;

&lt;p&gt;Worth noting because it changes what a Podman box looks like. On the Docker test, &lt;code&gt;iptables&lt;/code&gt; arrived as a dependency of &lt;code&gt;ufw&lt;/code&gt; and Docker used it. Here, after installing Podman and running containers both rootless and rootful:&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;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; iptables
&lt;span class="nv"&gt;$ &lt;/span&gt;                       &lt;span class="c"&gt;# nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;netavark talks to nftables directly. There is no legacy-vs-nft backend question to get wrong, because there is no iptables layer at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The comment was right and worth acting on, and the reason it gave was wrong.&lt;/strong&gt; If I'd repeated it without checking, I'd have published "rootless protects you because it can't write rules" — true as far as it goes, and it would have left people thinking rootful Podman is as dangerous as Docker. It isn't.&lt;/p&gt;

&lt;p&gt;That distinction is practical. Plenty of people run rootful Podman because they need low ports, or systemd integration, or just inherited it that way. They get the firewall behaviour too.&lt;/p&gt;

&lt;p&gt;The narrower lesson is about &lt;strong&gt;what "publishes a port" means&lt;/strong&gt;. Docker, rootless Podman and rootful Podman all print &lt;code&gt;0.0.0.0:8080-&amp;gt;80/tcp&lt;/code&gt;. Three different security outcomes behind one identical string. The output tells you the intent, not the result — same as &lt;code&gt;ufw status&lt;/code&gt; telling you its rules rather than your exposure.&lt;/p&gt;

&lt;p&gt;Which loops back to the habit from the Docker post, now with a second data point behind it: &lt;strong&gt;curl the port from another machine.&lt;/strong&gt; It is the only check that has been right about every one of these configurations.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>podman</category>
      <category>docker</category>
      <category>security</category>
      <category>linux</category>
    </item>
    <item>
      <title>UFW says the port is closed. Docker published it to your whole network anyway.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Sat, 22 Aug 2026 01:59:07 +0000</pubDate>
      <link>https://dev.to/homelabpm/ufw-says-the-port-is-closed-docker-published-it-to-your-whole-network-anyway-3ome</link>
      <guid>https://dev.to/homelabpm/ufw-says-the-port-is-closed-docker-published-it-to-your-whole-network-anyway-3ome</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;ufw enable&lt;/code&gt; on a Pi does what it says — until Docker is installed. Docker inserts its own chains ahead of UFW's in &lt;code&gt;FORWARD&lt;/code&gt;, and DNATs published ports before UFW is ever consulted. Same port, same firewall, same &lt;code&gt;deny (incoming)&lt;/code&gt; default: a host process is blocked and a container is reachable from the entire LAN. &lt;code&gt;ufw status&lt;/code&gt; reports the port as not allowed in both cases. Bind to &lt;code&gt;127.0.0.1&lt;/code&gt; explicitly, or put a rule in &lt;code&gt;DOCKER-USER&lt;/code&gt; — both verified below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demonstration
&lt;/h2&gt;

&lt;p&gt;One Pi 4B, Raspberry Pi OS Lite (Trixie), &lt;code&gt;ufw&lt;/code&gt; active with a single rule for SSH:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose
Status: active
Logging: on &lt;span class="o"&gt;(&lt;/span&gt;low&lt;span class="o"&gt;)&lt;/span&gt;
Default: deny &lt;span class="o"&gt;(&lt;/span&gt;incoming&lt;span class="o"&gt;)&lt;/span&gt;, allow &lt;span class="o"&gt;(&lt;/span&gt;outgoing&lt;span class="o"&gt;)&lt;/span&gt;, deny &lt;span class="o"&gt;(&lt;/span&gt;routed&lt;span class="o"&gt;)&lt;/span&gt;
New profiles: skip

To                         Action      From
&lt;span class="nt"&gt;--&lt;/span&gt;                         &lt;span class="nt"&gt;------&lt;/span&gt;      &lt;span class="nt"&gt;----&lt;/span&gt;
22/tcp                     ALLOW IN    Anywhere
22/tcp &lt;span class="o"&gt;(&lt;/span&gt;v6&lt;span class="o"&gt;)&lt;/span&gt;                ALLOW IN    Anywhere &lt;span class="o"&gt;(&lt;/span&gt;v6&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 8080 is not in that list, so it is denied. Start a plain listener on it:&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;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 8080 &lt;span class="nt"&gt;--bind&lt;/span&gt; 0.0.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From another machine on the same LAN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.168.128.154:8080 → Connection timed out after 6010 milliseconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blocked, exactly as configured. Now stop that, and publish the same port from a container instead:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; ufwtest &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 nginx:alpine
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Ports}}'&lt;/span&gt;
0.0.0.0:8080-&amp;gt;80/tcp, :::8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ufw status&lt;/code&gt; is unchanged — still no rule for 8080, still &lt;code&gt;deny (incoming)&lt;/code&gt;. From the same other machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;192.168.128.154:8080 → HTTP 200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same port. Same firewall. Same policy. Opposite outcome, and the firewall's own status output cannot tell the two situations apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;Docker writes iptables rules when it starts, and it puts them at the front of the chains that matter:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-S&lt;/span&gt; FORWARD
&lt;span class="nt"&gt;-P&lt;/span&gt; FORWARD DROP
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-j&lt;/span&gt; DOCKER-USER
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-j&lt;/span&gt; DOCKER-ISOLATION-STAGE-1
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-o&lt;/span&gt; docker0 &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-o&lt;/span&gt; docker0 &lt;span class="nt"&gt;-j&lt;/span&gt; DOCKER
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; docker0 &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; docker0 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; docker0 &lt;span class="nt"&gt;-o&lt;/span&gt; docker0 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-j&lt;/span&gt; ufw-before-logging-forward
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DOCKER-USER&lt;/code&gt; and &lt;code&gt;DOCKER&lt;/code&gt; come first. &lt;code&gt;ufw-before-logging-forward&lt;/code&gt; — the entry point to everything UFW manages — comes after them. Traffic that Docker accepts never reaches a UFW rule.&lt;/p&gt;

&lt;p&gt;The actual redirection happens earlier still, in &lt;code&gt;nat&lt;/code&gt;:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-S&lt;/span&gt; DOCKER
&lt;span class="nt"&gt;-A&lt;/span&gt; DOCKER &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; docker0 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 8080 &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; 172.17.0.2:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The packet is DNAT'd to the container before filtering decides anything. By the time UFW's rules are consulted, the destination is no longer the host.&lt;/p&gt;

&lt;p&gt;None of this is a bug in the strict sense. Docker documents that it manipulates iptables, and publishing a port is a request to make it reachable. The problem is that &lt;strong&gt;the tool you use to check whether a port is exposed does not model the mechanism that exposed it.&lt;/strong&gt; &lt;code&gt;ufw status&lt;/code&gt; reports UFW's rules, correctly, and those rules genuinely do not allow 8080.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a stock Pi actually starts with
&lt;/h2&gt;

&lt;p&gt;Worth knowing, because it changes who this applies to. On a fresh Raspberry Pi OS Lite (Trixie) install, &lt;code&gt;iptables&lt;/code&gt; is not present at all:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /usr/sbin/iptables /sbin/iptables
&lt;span class="nb"&gt;ls&lt;/span&gt;: cannot access &lt;span class="s1"&gt;'/usr/sbin/iptables'&lt;/span&gt;: No such file or directory
&lt;span class="nb"&gt;ls&lt;/span&gt;: cannot access &lt;span class="s1"&gt;'/sbin/iptables'&lt;/span&gt;: No such file or directory

&lt;span class="nv"&gt;$ &lt;/span&gt;dpkg-query &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'${Status}'&lt;/span&gt; iptables
unknown ok not-installed

&lt;span class="nv"&gt;$ &lt;/span&gt;update-alternatives &lt;span class="nt"&gt;--list&lt;/span&gt; iptables
update-alternatives: error: no alternatives &lt;span class="k"&gt;for &lt;/span&gt;iptables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nftables&lt;/code&gt; is installed but its service is disabled and the ruleset is empty. So the box ships with no firewall running and no iptables binary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;iptables&lt;/code&gt; arrives as a dependency of whatever you install first. &lt;code&gt;apt install ufw&lt;/code&gt; pulls it in, and the alternative resolves to the nft backend:&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;update-alternatives &lt;span class="nt"&gt;--display&lt;/span&gt; iptables
iptables - auto mode
  &lt;span class="nb"&gt;link &lt;/span&gt;best version is /usr/sbin/iptables-nft
  &lt;span class="nb"&gt;link &lt;/span&gt;currently points to /usr/sbin/iptables-nft

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;--version&lt;/span&gt;
iptables v1.8.11 &lt;span class="o"&gt;(&lt;/span&gt;nf_tables&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Installing Docker afterwards did &lt;strong&gt;not&lt;/strong&gt; change that — the alternative stayed on &lt;code&gt;iptables-nft&lt;/code&gt;, and Debian's &lt;code&gt;docker.io&lt;/code&gt; package does not depend on &lt;code&gt;iptables-legacy&lt;/code&gt;. Advice that says Docker forces the legacy backend did not hold here. It may have been true on earlier releases; on this image it wasn't, and the UFW-bypass problem happens regardless of which backend is selected.&lt;/p&gt;

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

&lt;p&gt;Two things work. Both were tested against the same container and the same firewall state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bind the published port to localhost when you don't want it on the network.&lt;/strong&gt; The &lt;code&gt;-p&lt;/code&gt; flag takes an address, and most examples omit it, which means &lt;code&gt;0.0.0.0&lt;/code&gt;:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; fixA &lt;span class="nt"&gt;-p&lt;/span&gt; 127.0.0.1:8080:80 nginx:alpine
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Ports}}'&lt;/span&gt;
127.0.0.1:8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;localhost:8080            → HTTP 200
192.168.128.154:8080      → timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DNAT rule still exists; it's just scoped to loopback. This is the right default for anything behind a reverse proxy, which on a homelab box is most things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or filter in &lt;code&gt;DOCKER-USER&lt;/code&gt;, which Docker evaluates first and never rewrites.&lt;/strong&gt; That chain exists specifically so you have somewhere to put rules that survive Docker restarts:&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 &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; DOCKER-USER 1 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; NEW &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-j&lt;/span&gt; DROP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-S&lt;/span&gt; DOCKER-USER
&lt;span class="nt"&gt;-N&lt;/span&gt; DOCKER-USER
&lt;span class="nt"&gt;-A&lt;/span&gt; DOCKER-USER &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 127.0.0.1/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; NEW &lt;span class="nt"&gt;-j&lt;/span&gt; DROP
&lt;span class="nt"&gt;-A&lt;/span&gt; DOCKER-USER &lt;span class="nt"&gt;-j&lt;/span&gt; RETURN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.168.128.154:8080      → timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the port in that rule is the &lt;strong&gt;container's&lt;/strong&gt; port (80), not the published one (8080) — &lt;code&gt;DOCKER-USER&lt;/code&gt; is in &lt;code&gt;FORWARD&lt;/code&gt;, so it sees the packet after DNAT has already rewritten the destination. Writing &lt;code&gt;--dport 8080&lt;/code&gt; there matches nothing and silently does not protect you, which is its own small trap.&lt;/p&gt;

&lt;p&gt;Rules added this way are not persistent across reboots on their own; &lt;code&gt;iptables-persistent&lt;/code&gt;, a systemd unit, or a tool like &lt;code&gt;ufw-docker&lt;/code&gt; handles that. Whichever route you take, verify from another machine rather than from the Pi — &lt;code&gt;curl localhost&lt;/code&gt; will succeed in every configuration above and tells you nothing.&lt;/p&gt;

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

&lt;p&gt;This is the same shape as &lt;a href="https://homelabpostmortem.com/2026/08/19/trixie-rpi-swap-writeback-file/" rel="noopener noreferrer"&gt;the swap file that isn't swap&lt;/a&gt; and &lt;a href="https://homelabpostmortem.com/2026/08/19/pi-memory-cgroup-disabled-by-firmware/" rel="noopener noreferrer"&gt;the memory limit that isn't enforced&lt;/a&gt;: &lt;strong&gt;a tool reports on its own model of the world, and something outside that model is what actually decides.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ufw status&lt;/code&gt; is not lying. It is answering "what rules does UFW have?" precisely. The question you had was "what can reach this machine?", and no single tool on the box answers that once two things are both writing firewall rules.&lt;/p&gt;

&lt;p&gt;So the habit worth building is narrow and cheap: &lt;strong&gt;test exposure from a different machine.&lt;/strong&gt; One &lt;code&gt;curl&lt;/code&gt; from a laptop settles in three seconds what an hour of reading rule listings will not, because it exercises the whole stack instead of one layer's opinion of it.&lt;/p&gt;

&lt;p&gt;And when you install something that manages its own firewall rules — Docker, Tailscale, libvirt, k3s — assume it did, and go look at the chain order once. The ordering is the entire behaviour, and it is visible in one command.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>linux</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>A new NetworkManager property broke a decade of headless WiFi scripts</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 19 Aug 2026 03:27:08 +0000</pubDate>
      <link>https://dev.to/homelabpm/a-new-networkmanager-property-broke-a-decade-of-headless-wifi-scripts-3hh8</link>
      <guid>https://dev.to/homelabpm/a-new-networkmanager-property-broke-a-decade-of-headless-wifi-scripts-3hh8</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: NetworkManager 1.52 (in Trixie) added an &lt;code&gt;autoconnect-ports&lt;/code&gt; property. That makes &lt;code&gt;autoconnect-p&lt;/code&gt; — an abbreviation that worked for years — ambiguous against &lt;code&gt;autoconnect-priority&lt;/code&gt;, and &lt;code&gt;nmcli&lt;/code&gt; rejects it with exit 2. &lt;code&gt;nmcli connection modify&lt;/code&gt; is atomic, so every other property in that same command is discarded too, &lt;em&gt;including ones listed before it&lt;/em&gt;. If your provisioning script created the profile in an earlier command, you're left with a WiFi profile that exists, shows the right SSID, and has no &lt;code&gt;key-mgmt&lt;/code&gt; and no &lt;code&gt;psk&lt;/code&gt;. It will never connect, and nothing about the profile looks broken.&lt;/p&gt;

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

&lt;p&gt;A headless provisioning script that has worked for years stops working against a Trixie image. The Pi comes up, the WiFi profile is there, and it never associates.&lt;/p&gt;

&lt;p&gt;Listing the profile shows nothing obviously wrong:&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;nmcli connection show pm-test | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(connection.id|connection.type|802-11-wireless.ssid)'&lt;/span&gt;
connection.id:                          pm-test
connection.type:                        802-11-wireless
802-11-wireless.ssid:                   PM-TEST-SSID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right name, right type, right SSID. But:&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;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; 802-11-wireless-security.key-mgmt connection show pm-test
&lt;span class="nv"&gt;$ &lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; 802-11-wireless-security.psk connection show pm-test
&lt;span class="err"&gt;$&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both empty. The profile has no security configuration at all — no key management, no pre-shared key. On a WPA2 network that profile cannot work, and the profile itself gives no hint that it's incomplete unless you go looking for the specific fields that are missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nmcli&lt;/code&gt; accepts abbreviated property names as long as they're unambiguous. On this machine:&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;nmcli &lt;span class="nt"&gt;--version&lt;/span&gt;
nmcli tool, version 1.52.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1.52 introduced &lt;code&gt;autoconnect-ports&lt;/code&gt;. There was already &lt;code&gt;autoconnect-priority&lt;/code&gt;. Both exist in &lt;code&gt;nm-settings-nmcli(5)&lt;/code&gt; now, and both start with &lt;code&gt;autoconnect-p&lt;/code&gt;:&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;nmcli connection modify pm-test conn.autoconnect-p 10
Error: invalid property &lt;span class="s1"&gt;'autoconnect-p'&lt;/span&gt;: &lt;span class="s1"&gt;'autoconnect-p'&lt;/span&gt; is ambiguous: autoconnect-priority, autoconnect-ports.
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the abbreviation that scripts have used since long before either property collided now resolves to nothing. That part is loud — it's an error on stderr with a non-zero exit.&lt;/p&gt;

&lt;p&gt;The damaging part is what it does to the rest of the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The command is all-or-nothing, in both directions
&lt;/h2&gt;

&lt;p&gt;The obvious assumption is that &lt;code&gt;nmcli&lt;/code&gt; processes properties left to right and stops when it hits a bad one, so anything before the failure got applied. It doesn't work that way. Putting a perfectly valid property &lt;em&gt;first&lt;/em&gt;:&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;nmcli connection modify pm-test wifi-sec.key-mgmt wpa-psk conn.autoconnect-p 10
Error: invalid property &lt;span class="s1"&gt;'autoconnect-p'&lt;/span&gt;: &lt;span class="s1"&gt;'autoconnect-p'&lt;/span&gt; is ambiguous: autoconnect-priority, autoconnect-ports.
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
2
&lt;span class="nv"&gt;$ &lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; 802-11-wireless-security.key-mgmt connection show pm-test
&lt;span class="err"&gt;$&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;key-mgmt&lt;/code&gt; was listed before the ambiguous property and was still not applied. &lt;code&gt;nmcli connection modify&lt;/code&gt; validates the whole argument list before committing anything, so one bad name discards all of it.&lt;/p&gt;

&lt;p&gt;That's a reasonable design — a half-applied network config would be worse. But combined with the usual scripting pattern, it produces the failure above. Headless WiFi setup scripts overwhelmingly look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli connection add &lt;span class="nb"&gt;type &lt;/span&gt;wifi con-name &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ifname wlan0 ssid &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
nmcli connection modify &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  conn.autoconnect-p 10 &lt;span class="se"&gt;\&lt;/span&gt;
  wifi-sec.key-mgmt wpa-psk &lt;span class="se"&gt;\&lt;/span&gt;
  wifi-sec.psk &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;add&lt;/code&gt; succeeds — that's a separate command and it doesn't mention &lt;code&gt;autoconnect-p&lt;/code&gt;. The &lt;code&gt;modify&lt;/code&gt; fails in its entirety. The result is a profile that exists with an SSID and no credentials, which is exactly the state above.&lt;/p&gt;

&lt;p&gt;If the script checks exit codes it fails loudly, and you find this in a minute. Plenty of provisioning scripts don't — they run under &lt;code&gt;set +e&lt;/code&gt;, or pipe output away, or run each step under a supervisor that only cares that the script finished. Those report success and hand you a Pi that won't join the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to misattribute
&lt;/h2&gt;

&lt;p&gt;Everything about the symptom points somewhere else. The machine is headless, so the first evidence you get is "it didn't come up on the network," and the natural suspects are the password, the SSID, 2.4 vs 5 GHz, the regulatory domain, the router. All of those are more common than a CLI abbreviation collision.&lt;/p&gt;

&lt;p&gt;The profile actively supports the wrong theory. It exists. It has the SSID you expect. &lt;code&gt;nmcli connection show&lt;/code&gt; on it prints dozens of populated fields. Two empty ones in the middle of that output do not stand out, and if you don't already know that &lt;code&gt;key-mgmt&lt;/code&gt; and &lt;code&gt;psk&lt;/code&gt; are the two that matter, there is nothing to draw your eye to them.&lt;/p&gt;

&lt;p&gt;And the change is invisible from the Pi side of things. This isn't in any Raspberry Pi changelog — it's an upstream NetworkManager property addition. The behaviour of abbreviations is documented (they must be unambiguous), but no documentation says "this specific abbreviation stopped working," because from NetworkManager's perspective nothing broke: a new property was added, and abbreviation resolution worked as specified.&lt;/p&gt;

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

&lt;p&gt;Use full property names in anything non-interactive:&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;nmcli connection modify pm-test &lt;span class="se"&gt;\&lt;/span&gt;
    connection.autoconnect-priority 10 &lt;span class="se"&gt;\&lt;/span&gt;
    wifi-sec.key-mgmt wpa-psk &lt;span class="se"&gt;\&lt;/span&gt;
    wifi-sec.psk testpassword123
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;span class="nv"&gt;$ &lt;/span&gt;nmcli &lt;span class="nt"&gt;-f&lt;/span&gt; connection.autoconnect-priority,802-11-wireless-security.key-mgmt connection show pm-test
connection.autoconnect-priority:        10
802-11-wireless-security.key-mgmt:      wpa-psk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Abbreviations aren't broken in general — only ones that became ambiguous. A longer abbreviation still resolves:&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;nmcli connection modify pm-test conn.autoconnect-pri 20
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;span class="nv"&gt;$ &lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; connection.autoconnect-priority connection show pm-test
20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;autoconnect-pri&lt;/code&gt; is unique again, so it works. But that's the trap in miniature: &lt;code&gt;autoconnect-pri&lt;/code&gt; is fine &lt;em&gt;today&lt;/em&gt;, and it is fine for exactly as long as nobody adds &lt;code&gt;autoconnect-primary&lt;/code&gt;. Every abbreviation in a committed script is a bet on a namespace you don't control.&lt;/p&gt;

&lt;p&gt;The section prefixes (&lt;code&gt;conn.&lt;/code&gt;, &lt;code&gt;wifi-sec.&lt;/code&gt;) are a different thing and are fine — those are documented setting-name aliases, not prefix matches.&lt;/p&gt;

&lt;p&gt;If you maintain provisioning scripts, this greps them:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"nmcli.*&lt;/span&gt;&lt;span class="se"&gt;\b\(&lt;/span&gt;&lt;span class="s2"&gt;conn&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;connection&lt;/span&gt;&lt;span class="se"&gt;\)\.&lt;/span&gt;&lt;span class="s2"&gt;autoconnect-p&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after any scripted profile creation, verify the fields that actually matter rather than trusting the exit code alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; 802-11-wireless-security.key-mgmt,802-11-wireless-security.psk &lt;span class="se"&gt;\&lt;/span&gt;
  connection show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output there means the profile will never associate, regardless of what the script reported.&lt;/p&gt;

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

&lt;p&gt;Abbreviations are a convenience for humans at a prompt. They're a liability in a file, because the thing that makes an abbreviation valid isn't your input — it's the set of every other name that exists, and that set grows without your involvement. A script using &lt;code&gt;autoconnect-p&lt;/code&gt; didn't change. Its meaning did, when someone upstream added a property that happened to share a prefix.&lt;/p&gt;

&lt;p&gt;The narrower habit worth taking from this: &lt;strong&gt;when a tool validates a batch, find out whether it's atomic before you rely on ordering.&lt;/strong&gt; The intuition that "the stuff before the error went through" is wrong here, and it's wrong in the safer direction — but if it had been right, this same bug would have produced a profile with a password and no key management, which is a stranger thing to debug than a profile with neither.&lt;/p&gt;

&lt;p&gt;And the one that generalises furthest: a resource that &lt;em&gt;exists&lt;/em&gt; is not a resource that is &lt;em&gt;configured&lt;/em&gt;. The provisioning script's job wasn't to create a profile, it was to create a working connection. Checking for the former and reporting success is how you end up with a headless box on the bench and no idea why it's silent.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>networking</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>sysctl -p says it worked. The next reboot disagrees.</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 19 Aug 2026 03:15:10 +0000</pubDate>
      <link>https://dev.to/homelabpm/sysctl-p-says-it-worked-the-next-reboot-disagrees-5eph</link>
      <guid>https://dev.to/homelabpm/sysctl-p-says-it-worked-the-next-reboot-disagrees-5eph</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; doesn't ship on Raspberry Pi OS Trixie, and if you create it, the thing that runs at boot — &lt;code&gt;systemd-sysctl&lt;/code&gt; — never reads it. The &lt;code&gt;sysctl&lt;/code&gt; CLI from procps still does, so &lt;code&gt;sudo sysctl -p&lt;/code&gt; applies your setting and prints it back at you like it worked. It did work, until the next reboot. Verified both directions on hardware: identical content in &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; and &lt;code&gt;/etc/sysctl.d/99-local.conf&lt;/code&gt;, one ignored at boot, one applied. &lt;code&gt;systemd-sysctl.service&lt;/code&gt; exits &lt;code&gt;0/SUCCESS&lt;/code&gt; either way.&lt;/p&gt;

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

&lt;p&gt;You want a kernel parameter to stick. Every guide written in the last fifteen years says the same thing: put it in &lt;code&gt;/etc/sysctl.conf&lt;/code&gt;, run &lt;code&gt;sysctl -p&lt;/code&gt;. So:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'printf "vm.swappiness = 42\n" &amp;gt; /etc/sysctl.conf'&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-p&lt;/span&gt;
vm.swappiness &lt;span class="o"&gt;=&lt;/span&gt; 42
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness
42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool echoes the setting back. The kernel confirms it. This is as clear a success signal as Linux gives you.&lt;/p&gt;

&lt;p&gt;Reboot, and the file is still sitting there, unchanged:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/sysctl.conf
vm.swappiness &lt;span class="o"&gt;=&lt;/span&gt; 42
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness
60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back to the default. Nothing removed the file. Nothing rewrote it. It was simply never read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to misdiagnose
&lt;/h2&gt;

&lt;p&gt;The first thing you'd suspect is that something overrode you later in boot, so you'd go looking for a conflicting drop-in and find nothing relevant:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/sysctl.d/
98-rpi.conf  README.sysctl
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/sysctl.d/98-rpi.conf
kernel.printk &lt;span class="o"&gt;=&lt;/span&gt; 3 4 1 3
vm.min_free_kbytes &lt;span class="o"&gt;=&lt;/span&gt; 16384
net.ipv4.ping_group_range &lt;span class="o"&gt;=&lt;/span&gt; 0 2147483647
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;vm.swappiness&lt;/code&gt; anywhere. Nothing competing with your setting.&lt;/p&gt;

&lt;p&gt;The second thing you'd check is whether the service that applies sysctls failed. It didn't:&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;systemctl status systemd-sysctl.service
● systemd-sysctl.service - Apply Kernel Variables
     Loaded: loaded &lt;span class="o"&gt;(&lt;/span&gt;/usr/lib/systemd/system/systemd-sysctl.service&lt;span class="p"&gt;;&lt;/span&gt; static&lt;span class="o"&gt;)&lt;/span&gt;
     Active: active &lt;span class="o"&gt;(&lt;/span&gt;exited&lt;span class="o"&gt;)&lt;/span&gt; since Wed 2026-08-19 12:11:49 JST
   Main PID: 323 &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;exited, &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0/SUCCESS&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; systemd-sysctl.service
Aug 19 12:11:49 mitoyosh-pi4b systemd[1]: Finished systemd-sysctl.service - Apply Kernel Variables.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit status 0. One log line, and it says "Finished". There is no warning that a file was skipped, because from &lt;code&gt;systemd-sysctl&lt;/code&gt;'s point of view no file was skipped — it read every file it knows about, and &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; is not one of them.&lt;/p&gt;

&lt;p&gt;There's a third clue that isn't there either: on a stock Trixie image, &lt;strong&gt;&lt;code&gt;/etc/sysctl.conf&lt;/code&gt; doesn't exist in the first place&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;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/sysctl.conf
&lt;span class="nb"&gt;ls&lt;/span&gt;: cannot access &lt;span class="s1"&gt;'/etc/sysctl.conf'&lt;/span&gt;: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;procps&lt;/code&gt; ships &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; and a &lt;code&gt;README.sysctl&lt;/code&gt;, but not the file itself. So you don't edit an existing file with existing content — you create a new one, from scratch, at a path the boot process ignores. Nothing about that act feels like it needs verifying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;Two different programs are called "sysctl" here, and they disagree about which files matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At boot&lt;/strong&gt;, systemd runs its own implementation:&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;systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;systemd-sysctl.service | &lt;span class="nb"&gt;grep &lt;/span&gt;ExecStart
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/lib/systemd/systemd-sysctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That binary contains exactly four config paths:&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;strings /usr/lib/systemd/systemd-sysctl | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^/(etc|run|usr)/.*sysctl'&lt;/span&gt;
/etc/sysctl.d
/run/sysctl.d
/usr/lib/sysctl.d
/usr/local/lib/sysctl.d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four directories. &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; is not among them, and &lt;code&gt;sysctl.d(5)&lt;/code&gt;'s SYNOPSIS lists the same four. The boot-time path has no concept of that file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From your shell&lt;/strong&gt;, &lt;code&gt;sysctl&lt;/code&gt; is a different program entirely — &lt;code&gt;/usr/sbin/sysctl&lt;/code&gt;, from procps. Its &lt;code&gt;--system&lt;/code&gt; precedence list &lt;em&gt;does&lt;/em&gt; include the file, last:&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="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;sysctl&lt;/span&gt;.&lt;span class="n"&gt;d&lt;/span&gt;/*.&lt;span class="n"&gt;conf&lt;/span&gt;
/&lt;span class="n"&gt;run&lt;/span&gt;/&lt;span class="n"&gt;sysctl&lt;/span&gt;.&lt;span class="n"&gt;d&lt;/span&gt;/*.&lt;span class="n"&gt;conf&lt;/span&gt;
/&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;local&lt;/span&gt;/&lt;span class="n"&gt;lib&lt;/span&gt;/&lt;span class="n"&gt;sysctl&lt;/span&gt;.&lt;span class="n"&gt;d&lt;/span&gt;/*.&lt;span class="n"&gt;conf&lt;/span&gt;
/&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;lib&lt;/span&gt;/&lt;span class="n"&gt;sysctl&lt;/span&gt;.&lt;span class="n"&gt;d&lt;/span&gt;/*.&lt;span class="n"&gt;conf&lt;/span&gt;
/&lt;span class="n"&gt;lib&lt;/span&gt;/&lt;span class="n"&gt;sysctl&lt;/span&gt;.&lt;span class="n"&gt;d&lt;/span&gt;/*.&lt;span class="n"&gt;conf&lt;/span&gt;
/&lt;span class="n"&gt;etc&lt;/span&gt;/&lt;span class="n"&gt;sysctl&lt;/span&gt;.&lt;span class="n"&gt;conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And plain &lt;code&gt;sysctl -p&lt;/code&gt; with no argument defaults to reading &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; specifically. So the interactive tool honours the file, the boot process doesn't, and the gap between them is exactly one reboot wide.&lt;/p&gt;

&lt;p&gt;On many Debian systems this is papered over by a compatibility symlink at &lt;code&gt;/etc/sysctl.d/99-sysctl.conf&lt;/code&gt; pointing back to &lt;code&gt;/etc/sysctl.conf&lt;/code&gt;, which drags the file into a directory systemd does read. This image doesn't have one:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/sysctl.d/99-sysctl.conf
&lt;span class="nb"&gt;ls&lt;/span&gt;: cannot access &lt;span class="s1"&gt;'/etc/sysctl.d/99-sysctl.conf'&lt;/span&gt;: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth checking on your own machine before assuming either way — its presence or absence is the whole difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demonstration
&lt;/h2&gt;

&lt;p&gt;Run the boot-time binary by hand and you can watch it ignore the file, without waiting for a reboot. Start from the file in place and the value at its default:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/sysctl.conf
vm.swappiness &lt;span class="o"&gt;=&lt;/span&gt; 42
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness
60
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/lib/systemd/systemd-sysctl
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness
60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output, no error, no change. Now put byte-identical content in the directory it does read:&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'printf "vm.swappiness = 42\n" &amp;gt; /etc/sysctl.d/99-local-test.conf'&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/lib/systemd/systemd-sysctl
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness
42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same setting, same syntax, same nineteen bytes. The only variable is which path it sits at.&lt;/p&gt;

&lt;p&gt;And the end-to-end version — drop-in removed, &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; left in place, real reboot:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/sysctl.d/
98-rpi.conf  README.sysctl
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/sysctl.conf
vm.swappiness &lt;span class="o"&gt;=&lt;/span&gt; 42
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness
60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Put it in &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; and give it a numeric prefix:&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 &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'printf "vm.swappiness = 42\n" &amp;gt; /etc/sysctl.d/99-local.conf'&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/lib/systemd/systemd-sysctl        &lt;span class="c"&gt;# apply now, same as boot will&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/vm/swappiness                 &lt;span class="c"&gt;# verify it took&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files across all the sysctl directories are sorted lexicographically by filename regardless of which directory they're in, and the last one to set a given key wins. &lt;code&gt;99-&lt;/code&gt; puts you after the vendor's &lt;code&gt;98-rpi.conf&lt;/code&gt;, which is what you want if you ever need to override one of its three settings.&lt;/p&gt;

&lt;p&gt;Two things worth doing differently from the old habit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apply with &lt;code&gt;systemd-sysctl&lt;/code&gt;, not &lt;code&gt;sysctl -p&lt;/code&gt;.&lt;/strong&gt; Not because &lt;code&gt;sysctl -p&lt;/code&gt; is broken, but because running the boot-time binary tests the boot-time path. If it applies now, it will apply at boot. &lt;code&gt;sysctl -p&lt;/code&gt; proves less than it appears to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify by reading &lt;code&gt;/proc/sys/...&lt;/code&gt;, not by trusting the command's output.&lt;/strong&gt; &lt;code&gt;sysctl -p&lt;/code&gt; echoing your setting back means it parsed your file, not that the value survives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have an existing &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; from an older install or a config-management tool, the minimal migration is to make the file visible to systemd rather than move its contents:&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 ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/sysctl.conf /etc/sysctl.d/99-sysctl.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the same compatibility symlink other Debian systems ship, and it's the smallest change that makes both tools agree.&lt;/p&gt;

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

&lt;p&gt;The trap here isn't the missing file. It's that &lt;strong&gt;the tool you use to apply a config and the code that applies it at boot were two different programs with two different opinions&lt;/strong&gt;, and only one of them ever spoke to you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sysctl -p&lt;/code&gt; isn't lying. It read the file you gave it and set the value, and it said so. It just has no authority over what happens at boot, and no reason to mention that. The success message is scoped to the current kernel, and you read it as scoped to the machine.&lt;/p&gt;

&lt;p&gt;So the reflex: when you make a setting persistent, verify persistence specifically, not application. Those are different claims. The cheapest version is to run whatever the boot path actually runs — here &lt;code&gt;systemd-sysctl&lt;/code&gt;, one command, no reboot — and confirm the value moves. If you can't identify what runs at boot, that's worth five minutes with &lt;code&gt;systemctl cat&lt;/code&gt;, because it also tells you which files it reads, which is the question you actually had.&lt;/p&gt;

&lt;p&gt;This is the third instance of the same shape on this platform in a week, after &lt;a href="https://homelabpostmortem.com/2026/08/18/trixie-journald-volatile-logs/" rel="noopener noreferrer"&gt;journald keeping logs in RAM&lt;/a&gt; and &lt;a href="https://homelabpostmortem.com/2026/08/19/trixie-rpi-swap-writeback-file/" rel="noopener noreferrer"&gt;swap config needing a reboot&lt;/a&gt;. Correct config, no error, no effect. The common thread is a config file whose reader isn't the program you were talking to.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>systemd</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Your Pi accepts every memory limit you set and enforces none of them</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 19 Aug 2026 02:57:41 +0000</pubDate>
      <link>https://dev.to/homelabpm/your-pi-accepts-every-memory-limit-you-set-and-enforces-none-of-them-1j6e</link>
      <guid>https://dev.to/homelabpm/your-pi-accepts-every-memory-limit-you-set-and-enforces-none-of-them-1j6e</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: On stock Raspberry Pi OS the memory cgroup controller is disabled, so &lt;code&gt;docker run --memory=512m&lt;/code&gt;, systemd's &lt;code&gt;MemoryMax=&lt;/code&gt;, and k3s pod limits are all accepted without complaint and enforced not at all — the kernel has nowhere to write the limit. The disable comes from &lt;code&gt;cgroup_disable=memory&lt;/code&gt;, which &lt;strong&gt;the firmware injects ahead of your &lt;code&gt;cmdline.txt&lt;/code&gt;&lt;/strong&gt; and which never appears in that file. Adding &lt;code&gt;cgroup_enable=memory cgroup_memory=1&lt;/code&gt; fixes it, costs no measurable RAM, and needs a reboot. And &lt;code&gt;/proc/cgroups&lt;/code&gt; — the file a lot of guides tell you to check — prints byte-identical output before and after the fix.&lt;/p&gt;

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

&lt;p&gt;Nothing errors. That's the entire problem.&lt;/p&gt;

&lt;p&gt;On a Pi 4B running Trixie (&lt;code&gt;6.18.34+rpt-rpi-v8&lt;/code&gt;), ask systemd to cap a process at 100 MB:&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;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M &lt;span class="nb"&gt;sleep &lt;/span&gt;30
Running as unit: run-p1753-i1754.scope&lt;span class="p"&gt;;&lt;/span&gt; invocation ID: f8ecc5cb8b884fd68fd9eb2cccbdb72d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No warning. Ask systemd whether the limit is in place, and it says yes:&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;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; show run-p1753-i1754.scope &lt;span class="nt"&gt;-p&lt;/span&gt; MemoryMax &lt;span class="nt"&gt;-p&lt;/span&gt; MemoryAccounting
&lt;span class="nv"&gt;MemoryAccounting&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes
&lt;/span&gt;&lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;104857600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;104857600 bytes. Exactly the 100 MB requested. Except the limit does not exist, because there is nowhere to put it:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /sys/fs/cgroup/user.slice/user-1000.slice/session-22.scope/
cgroup.controllers   cgroup.max.depth        cgroup.stat.local
cgroup.events        cgroup.max.descendants  cgroup.subtree_control
cgroup.freeze        cgroup.procs            cgroup.threads
cgroup.kill          cgroup.stat             cgroup.type
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;memory.max&lt;/code&gt; file in that directory. There is no &lt;code&gt;memory.&lt;/code&gt; anything. systemd reported a limit it had no way to apply, and reported it as successfully set.&lt;/p&gt;

&lt;p&gt;The same applies to &lt;code&gt;docker run --memory=512m&lt;/code&gt; and to k3s pod memory limits. This is not a Docker bug — it's one layer below Docker, which is why it hits every container runtime on the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;The memory controller isn't in the active 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;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cgroup.controllers
cpuset cpu io pids
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cpuset&lt;/code&gt;, &lt;code&gt;cpu&lt;/code&gt;, &lt;code&gt;io&lt;/code&gt;, &lt;code&gt;pids&lt;/code&gt; — no &lt;code&gt;memory&lt;/code&gt;. So the natural next step is to look at the kernel command line, and this is where it gets interesting:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /boot/firmware/cmdline.txt
&lt;span class="nv"&gt;console&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;serial0,115200 &lt;span class="nv"&gt;console&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tty1 &lt;span class="nv"&gt;root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;PARTUUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;b11c96d4-02 &lt;span class="nv"&gt;rootfstype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ext4 fsck.repair&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes &lt;/span&gt;rootwait &lt;span class="nv"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nocloud&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rpi-imager-1786770245730 cfg80211.ieee80211_regdom&lt;span class="o"&gt;=&lt;/span&gt;JP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about cgroups at all. No &lt;code&gt;cgroup_disable&lt;/code&gt;, no &lt;code&gt;cgroup_enable&lt;/code&gt;. If &lt;code&gt;cmdline.txt&lt;/code&gt; is your model of the kernel command line, there is nothing here to explain the missing controller.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cmdline.txt&lt;/code&gt; is not the kernel command line. It's a fragment the firmware appends to its own. Here is what the kernel actually received:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/cmdline
&lt;span class="nv"&gt;coherent_pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M 8250.nr_uarts&lt;span class="o"&gt;=&lt;/span&gt;0 snd_bcm2835.enable_headphones&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;cgroup_disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory &lt;span class="nv"&gt;numa_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;interleave nvme.max_host_mem_size_mb&lt;span class="o"&gt;=&lt;/span&gt;32 ... &lt;span class="nv"&gt;console&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ttyS0,115200 &lt;span class="nv"&gt;console&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tty1 &lt;span class="nv"&gt;root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;PARTUUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;b11c96d4-02 ... cfg80211.ieee80211_regdom&lt;span class="o"&gt;=&lt;/span&gt;JP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cgroup_disable=memory&lt;/code&gt;, fourth parameter in, put there by the firmware. It is not in any file on the boot partition. You cannot grep for it in &lt;code&gt;/boot/firmware/&lt;/code&gt; and find it. The only place it is visible is &lt;code&gt;/proc/cmdline&lt;/code&gt;, after boot.&lt;/p&gt;

&lt;p&gt;This is a deliberate platform default, not an accident — the memory controller has a small per-page overhead, and Pi images have historically optimised for the machine having as much usable RAM as possible. The request to flip it for 64-bit Lite builds was filed as &lt;a href="https://github.com/RPi-Distro/pi-gen/issues/917" rel="noopener noreferrer"&gt;RPi-Distro/pi-gen#917&lt;/a&gt; and closed &lt;strong&gt;not planned&lt;/strong&gt; in April 2026. It isn't going to change upstream, so every Pi that runs containers has to fix it locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that lies to you
&lt;/h2&gt;

&lt;p&gt;A lot of guides tell you to inspect &lt;code&gt;/proc/cgroups&lt;/code&gt;. On this machine, before the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#subsys_name    hierarchy   num_cgroups enabled
cpuset  0   44  1
cpu 0   44  1
cpuacct 0   44  1
blkio   0   44  1
devices 0   44  1
freezer 0   44  1
net_cls 0   44  1
perf_event  0   44  1
net_prio    0   44  1
pids    0   44  1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;memory&lt;/code&gt; row — consistent with the problem, so far so good. After the fix, rebooted, with the controller confirmed working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#subsys_name    hierarchy   num_cgroups enabled
cpuset  0   75  1
cpu 0   75  1
cpuacct 0   75  1
blkio   0   75  1
devices 0   75  1
freezer 0   75  1
net_cls 0   75  1
perf_event  0   75  1
net_prio    0   75  1
pids    0   75  1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still no &lt;code&gt;memory&lt;/code&gt; row. Same ten subsystems, same order. The only thing that changed is a counter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/proc/cgroups&lt;/code&gt; reports cgroup &lt;strong&gt;v1&lt;/strong&gt; subsystems, and this system runs a pure v2 unified hierarchy:&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;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-fc&lt;/span&gt; %T /sys/fs/cgroup
cgroup2fs
&lt;span class="nv"&gt;$ &lt;/span&gt;mount | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;' /sys/fs/cgroup '&lt;/span&gt;
cgroup2 on /sys/fs/cgroup &lt;span class="nb"&gt;type &lt;/span&gt;cgroup2 &lt;span class="o"&gt;(&lt;/span&gt;rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;/proc/cgroups&lt;/code&gt; is answering a question about a hierarchy this machine doesn't use. It is not wrong, it's irrelevant — and it is irrelevant in a way that looks exactly like a failed fix. Check &lt;code&gt;/sys/fs/cgroup/cgroup.controllers&lt;/code&gt; instead. That one changes.&lt;/p&gt;

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

&lt;p&gt;Append two parameters to &lt;code&gt;cmdline.txt&lt;/code&gt;. It must stay a &lt;strong&gt;single line&lt;/strong&gt; — a stray newline here is a machine that doesn't boot, so back it up first and read it back before rebooting:&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 cp&lt;/span&gt; /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/$/ cgroup_enable=memory cgroup_memory=1/'&lt;/span&gt; /boot/firmware/cmdline.txt
&lt;span class="nb"&gt;cat&lt;/span&gt; /boot/firmware/cmdline.txt          &lt;span class="c"&gt;# one line, params at the end&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'END{print NR}'&lt;/span&gt; /boot/firmware/cmdline.txt   &lt;span class="c"&gt;# must print 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you're in there, confirm &lt;code&gt;root=PARTUUID=&lt;/code&gt; still matches the disk you actually boot from — &lt;code&gt;blkid -s PARTUUID -o value /dev/sda2&lt;/code&gt; — because if that's ever been wrong you'll find out at the same reboot and misattribute it to this change.&lt;/p&gt;

&lt;p&gt;Then reboot, and verify against the right file:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory pids
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;memory&lt;/code&gt; is now present. The end-to-end check is that a limit lands somewhere real:&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;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'CG=$(cut -d: -f3 /proc/self/cgroup); echo memory.max=$(cat /sys/fs/cgroup$CG/memory.max)'&lt;/span&gt;
memory.max&lt;span class="o"&gt;=&lt;/span&gt;104857600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the fix that file did not exist. Now it holds the number.&lt;/p&gt;

&lt;p&gt;Note what the fix is actually doing. &lt;code&gt;cgroup_disable=memory&lt;/code&gt; is still on the kernel command line — the firmware still injects it:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/cmdline | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;cgroup
&lt;span class="nv"&gt;cgroup_disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory
&lt;span class="nv"&gt;cgroup_enable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory
&lt;span class="nv"&gt;cgroup_memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are not removing the disable. You are overriding it by landing later on the command line, which works precisely because &lt;code&gt;cmdline.txt&lt;/code&gt; is appended after the firmware's own parameters. That ordering is the whole reason a two-word edit is sufficient.&lt;/p&gt;

&lt;p&gt;The RAM cost people worry about did not materialise here — &lt;code&gt;free -m&lt;/code&gt; reported a total of 3794 MB both before and after.&lt;/p&gt;

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

&lt;p&gt;The reflex worth building: &lt;strong&gt;the config file you edit is not always the config the system reads.&lt;/strong&gt; &lt;code&gt;cmdline.txt&lt;/code&gt; reads like the kernel command line — it has the right shape, the right contents, the right name. It is a fragment. The authoritative version lived at &lt;code&gt;/proc/cmdline&lt;/code&gt; the whole time, and one look at it turns an unexplained missing controller into an obvious &lt;code&gt;cgroup_disable=memory&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The narrower lesson is about verification. Two different files here will tell you about cgroups, and only one of them is about the hierarchy your machine actually runs. Picking the wrong one gives you a confident, stable, identical answer before and after a fix that worked. When a check shows no change after a change you believe in, consider that the check may be measuring the wrong thing before concluding the fix failed — the previous &lt;a href="https://homelabpostmortem.com/2026/08/18/trixie-journald-volatile-logs/" rel="noopener noreferrer"&gt;journald&lt;/a&gt; and &lt;a href="https://homelabpostmortem.com/2026/08/19/trixie-rpi-swap-writeback-file/" rel="noopener noreferrer"&gt;swap&lt;/a&gt; posts on this site are the same shape, and that's three for three on this platform.&lt;/p&gt;

&lt;p&gt;And the one that costs real money in production: a limit that is accepted is not a limit that is enforced. If you rely on &lt;code&gt;--memory&lt;/code&gt; to stop one container taking down a box, verify once that the enforcement path exists. &lt;code&gt;systemd-run&lt;/code&gt; will tell you in a single command, without installing anything.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>docker</category>
      <category>linux</category>
      <category>containers</category>
    </item>
    <item>
      <title>Your Pi's 2 GB swap file isn't swap, and no swap command will tell you that</title>
      <dc:creator>The Homelab Postmortem</dc:creator>
      <pubDate>Wed, 19 Aug 2026 02:05:53 +0000</pubDate>
      <link>https://dev.to/homelabpm/your-pis-2-gb-swap-file-isnt-swap-and-no-swap-command-will-tell-you-that-1m3j</link>
      <guid>https://dev.to/homelabpm/your-pis-2-gb-swap-file-isnt-swap-and-no-swap-command-will-tell-you-that-1m3j</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Raspberry Pi OS Trixie ships &lt;code&gt;rpi-swap&lt;/code&gt; instead of &lt;code&gt;dphys-swapfile&lt;/code&gt;, defaulting to a &lt;code&gt;zram+file&lt;/code&gt; hybrid. &lt;code&gt;/var/swap&lt;/code&gt; is still there and still costs 2 GB of real disk, but it is zram's &lt;em&gt;writeback target&lt;/em&gt;, not a swap area — so it never appears in &lt;code&gt;swapon&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt;, or &lt;code&gt;/proc/swaps&lt;/code&gt;. On the reference Pi that file was 43% of all disk in use, and every swap tool reported the identical output with it present and with it gone. The only way to see the link is &lt;code&gt;/sys/block/zram0/backing_dev&lt;/code&gt;. Config moved to &lt;code&gt;/etc/rpi/swap.conf&lt;/code&gt;, and changes need a full reboot — &lt;code&gt;systemctl daemon-reload&lt;/code&gt; applies nothing and reports no error.&lt;/p&gt;

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

&lt;p&gt;There isn't a crash here. That's what makes it worth writing down: the numbers just quietly stop adding up.&lt;/p&gt;

&lt;p&gt;A stock Trixie install on a Pi 4B with 3.7 GB of RAM, booting from a USB SSD:&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;free &lt;span class="nt"&gt;-h&lt;/span&gt;
               total        used        free      shared  buff/cache   available
Mem:           3.7Gi       220Mi       2.8Gi        35Mi       802Mi       3.5Gi
Swap:          2.0Gi          0B       2.0Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2 GB of swap. Fine. And there's a swap file where you'd expect one:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; /var/swap
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 2.0G Aug 16 23:01 /var/swap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also 2 GB. So that's the swap, sitting in a file, exactly as it's worked for the last decade.&lt;/p&gt;

&lt;p&gt;Except it isn't, and those two 2 GB figures are not the same 2 GB.&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;swapon &lt;span class="nt"&gt;--show&lt;/span&gt;
NAME       TYPE      SIZE USED PRIO
/dev/zram0 partition   2G   0B  100

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/swaps
Filename        Type       Size      Used  Priority
/dev/zram0      partition  2097148   0     100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only swap device on the machine is &lt;code&gt;/dev/zram0&lt;/code&gt; — compressed RAM. The 2 GB file does not appear in the swap tables at all. But it is fully allocated on disk, not sparse:&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;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="nt"&gt;--apparent-size&lt;/span&gt; /var/swap &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /var/swap
2.0G    /var/swap
2.1G    /var/swap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this machine, that's 43% of everything on the disk:&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;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda2       219G  4.7G  203G   3% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4.7 GB used, 2.1 GB of which is a file that no swap command acknowledges. On a 32 GB SD card the same file is 6.3% of the entire card.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really going on
&lt;/h2&gt;

&lt;p&gt;Trixie replaced &lt;code&gt;dphys-swapfile&lt;/code&gt; with a new package. The old one is not installed, not deprecated-but-present — simply gone:&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;dpkg &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'dphys-swapfile|rpi-swap'&lt;/span&gt;
ii  rpi-swap  1.2.2  all  early-boot swap configuration

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dphys-swapfile
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/dphys-swapfile
&lt;span class="nb"&gt;ls&lt;/span&gt;: cannot access &lt;span class="s1"&gt;'/etc/dphys-swapfile'&lt;/span&gt;: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rpi-swap&lt;/code&gt; defaults to a mechanism called &lt;code&gt;zram+file&lt;/code&gt;, and &lt;code&gt;swap.conf(5)&lt;/code&gt; is direct about what that means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;zram+file&lt;/strong&gt;: A compressed RAM-based swap device is created, with the file specified in File::Path used for writeback storage. The file is not used as a traditional swap device; instead, zram occasionally writes idle pages to it to free up RAM, reducing SD card wear through infrequent writes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the file's job is to be somewhere zram can evict cold pages to. You can watch the chain yourself:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/block/zram0/backing_dev
/dev/loop0

&lt;span class="nv"&gt;$ &lt;/span&gt;losetup &lt;span class="nt"&gt;-a&lt;/span&gt;
/dev/loop0: &lt;span class="o"&gt;[]&lt;/span&gt;: &lt;span class="o"&gt;(&lt;/span&gt;/var/swap&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zram0&lt;/code&gt; → &lt;code&gt;loop0&lt;/code&gt; → &lt;code&gt;/var/swap&lt;/code&gt;. That's the entire relationship, and &lt;code&gt;/sys&lt;/code&gt; is the only place it's visible. It is a real mechanism doing a sensible thing — batching writes to spare the SD card — and the writeback timer is genuinely running on a 24-hour cycle:&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;systemctl list-timers rpi-zram-writeback.timer
NEXT                        LEFT LAST                        PASSED UNIT
Wed 2026-08-19 23:01:49 JST  15h Tue 2026-08-18 23:01:49 JST 8h ago rpi-zram-writeback.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 2 GB zram size is also not arbitrary. With &lt;code&gt;RamMultiplier=1&lt;/code&gt; on a 3794 MiB machine you'd expect ~3.7 GB, but &lt;code&gt;MaxSizeMiB&lt;/code&gt; defaults to 2048 and caps it. Both defaults are visible, commented out, in &lt;code&gt;/etc/rpi/swap.conf&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's easy to misread
&lt;/h2&gt;

&lt;p&gt;Every tool that has ever answered "how much swap do I have and where does it live" answers this configuration wrongly, or at least uselessly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;free&lt;/code&gt; reports 2.0Gi of swap. True — that's zram's decompressed capacity. It says nothing about the 2 GB on disk. &lt;code&gt;swapon&lt;/code&gt; and &lt;code&gt;/proc/swaps&lt;/code&gt; list &lt;code&gt;/dev/zram0&lt;/code&gt; and stop. &lt;code&gt;ls /var/swap&lt;/code&gt; shows a 2 GB file that looks exactly like the swap file that has been on Pis for years. Put those together and the obvious reading is "2 GB of swap, stored in /var/swap" — which is wrong in a way that no single command contradicts.&lt;/p&gt;

&lt;p&gt;The strongest demonstration of this: I switched the machine to &lt;code&gt;Mechanism=zram&lt;/code&gt;, rebooted, and compared. The file was removed and 2 GB of disk came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    before      after
/var/swap           2.0G        (deleted)
df / used           4.7G        2.7G
backing_dev         /dev/loop0  none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;swapon --show&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME       TYPE      SIZE USED PRIO
/dev/zram0 partition   2G   0B  100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Byte-for-byte identical output, before and after. The swap subsystem was reconfigured, 2 GB of disk changed hands, and the tool you'd use to check swap could not tell the two states apart.&lt;/p&gt;

&lt;p&gt;None of this is undocumented — &lt;code&gt;swap.conf(5)&lt;/code&gt; is clear and thorough. But nothing routes you to the man page for a package you didn't know was installed, replacing a package you assumed still existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: nothing applies until you reboot
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;swap.conf(5)&lt;/code&gt; warns about this, and it's worth confirming because it is the part that will bite a provisioning script rather than a person:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;WARNING&lt;/strong&gt;: After modifying any swap configuration, you must reboot the system for changes to take effect.&lt;/p&gt;

&lt;p&gt;While running &lt;code&gt;systemctl daemon-reload&lt;/code&gt; after configuration changes will generate new swap units, existing swap units are not typically stopped by systemd, and new units will not be started automatically as they are pulled in by &lt;code&gt;swap.target&lt;/code&gt; which has already been reached during boot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Observed exactly as described. With a drop-in in place selecting &lt;code&gt;Mechanism=zram&lt;/code&gt;, after &lt;code&gt;systemctl daemon-reload&lt;/code&gt;:&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;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/rpi/swap.conf.d/90-test-zram-only.conf
&lt;span class="o"&gt;[&lt;/span&gt;Main]
&lt;span class="nv"&gt;Mechanism&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;zram

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; /var/swap
&lt;span class="nt"&gt;-rw-------&lt;/span&gt; 1 root root 2.0G Aug 16 23:01 /var/swap
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/block/zram0/backing_dev
/dev/loop0
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;used / | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
 4.7G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing changed. No error, no warning, no non-zero exit. The config was valid and correctly placed; it simply does not take effect until &lt;code&gt;rpi-swap&lt;/code&gt;'s generator runs during early boot. A script that writes a drop-in, reloads, checks the exit code and reports success will report success — and be wrong until something reboots the box, possibly weeks later.&lt;/p&gt;

&lt;p&gt;This is the same shape as the &lt;a href="https://homelabpostmortem.com/2026/08/18/trixie-journald-volatile-logs/" rel="noopener noreferrer"&gt;journald volatile-storage problem&lt;/a&gt; on the same OS: a correct config change that produces no visible effect and no error until a second, separate step happens.&lt;/p&gt;

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

&lt;p&gt;First, find out what's actually managing swap before trusting any tutorial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dpkg &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'dphys-swapfile|rpi-swap'&lt;/span&gt;
swapon &lt;span class="nt"&gt;--show&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/block/zram0/backing_dev 2&amp;gt;/dev/null   &lt;span class="c"&gt;# a path here means zram+file&lt;/span&gt;
losetup &lt;span class="nt"&gt;-a&lt;/span&gt;                                      &lt;span class="c"&gt;# resolves that path to the real file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;backing_dev&lt;/code&gt; names a loop device, you have a writeback file consuming disk that won't show up in any swap listing.&lt;/p&gt;

&lt;p&gt;To change it, write a drop-in rather than editing the main file — &lt;code&gt;/etc/rpi/swap.conf.d/&lt;/code&gt; is read after &lt;code&gt;/etc/rpi/swap.conf&lt;/code&gt;, sorted lexicographically across all the config directories, last-one-wins:&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 mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/rpi/swap.conf.d/
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/rpi/swap.conf.d/90-local-swap.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Main]
Mechanism=zram
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Mechanism=&lt;/code&gt; takes &lt;code&gt;auto&lt;/code&gt;, &lt;code&gt;swapfile&lt;/code&gt;, &lt;code&gt;zram&lt;/code&gt;, &lt;code&gt;zram+file&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;. &lt;code&gt;zram&lt;/code&gt; keeps compressed-RAM swap and drops the writeback file. &lt;code&gt;swapfile&lt;/code&gt; gives you the traditional pre-Trixie behaviour back. &lt;code&gt;none&lt;/code&gt; removes swap entirely and deletes the file.&lt;/p&gt;

&lt;p&gt;To resize rather than switch mechanism, &lt;code&gt;FixedSizeMiB&lt;/code&gt; under &lt;code&gt;[File]&lt;/code&gt; or &lt;code&gt;[Zram]&lt;/code&gt; sets an exact size and overrides the &lt;code&gt;RamMultiplier&lt;/code&gt; calculation. Undo is just deleting the drop-in and rebooting — that restored this machine to stock, &lt;code&gt;/var/swap&lt;/code&gt; and all, in one boot.&lt;/p&gt;

&lt;p&gt;The reboot is not optional and not a formality. Verify after it, not before.&lt;/p&gt;

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

&lt;p&gt;The failure mode worth extracting isn't "Trixie changed swap." It's that &lt;strong&gt;a tool's output can stay identical across a change it doesn't model.&lt;/strong&gt; &lt;code&gt;swapon&lt;/code&gt; isn't broken or lying; it reports swap areas, and a writeback device isn't one. It answers its own question correctly while the question you actually had — "where did my disk go" — goes unanswered by every tool you'd naturally reach for.&lt;/p&gt;

&lt;p&gt;That's the class of thing worth a reflex: when a number doesn't reconcile, don't re-run the tool that already gave you an answer you don't believe. Go to the layer underneath it. Here that's &lt;code&gt;/sys/block/zram0/backing_dev&lt;/code&gt; and &lt;code&gt;losetup -a&lt;/code&gt; — two commands nobody thinks of as swap commands, which is precisely why the link stays invisible.&lt;/p&gt;

&lt;p&gt;The related habit: when a package you rely on is missing, check what replaced it before concluding your install is broken. &lt;code&gt;dphys-swapfile: command not found&lt;/code&gt; reads like damage. It's a deliberate migration with a man page.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://homelabpostmortem.com/toolkit/" rel="noopener noreferrer"&gt;See the toolkit →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>debugging</category>
    </item>
    <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>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/homelabpm/raspberry-pi-os-trixie-throws-away-your-logs-on-reboot-and-varlogjournal-exists-anyway-to-5ag0</link>
      <guid>https://dev.to/homelabpm/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>The Homelab Postmortem</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/homelabpm/stripe-retries-a-failed-webhook-for-three-days-and-i-found-out-by-getting-the-same-email-three-g1n</link>
      <guid>https://dev.to/homelabpm/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>
  </channel>
</rss>
