<?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: Maxime Baelde</title>
    <description>The latest articles on DEV Community by Maxime Baelde (@mbaelde).</description>
    <link>https://dev.to/mbaelde</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%2F4027700%2F55b6b903-8fa6-4704-91bd-e3769d825c2a.jpg</url>
      <title>DEV Community: Maxime Baelde</title>
      <link>https://dev.to/mbaelde</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mbaelde"/>
    <language>en</language>
    <item>
      <title>4 self-hosting failures that return success</title>
      <dc:creator>Maxime Baelde</dc:creator>
      <pubDate>Mon, 13 Jul 2026 18:32:19 +0000</pubDate>
      <link>https://dev.to/mbaelde/4-self-hosting-failures-that-return-success-1go5</link>
      <guid>https://dev.to/mbaelde/4-self-hosting-failures-that-return-success-1go5</guid>
      <description>&lt;p&gt;The failures that cost me the most in three years of self-hosting were never the ones that threw an error. An error is a gift: it tells you where to look. The expensive ones are the failures that &lt;strong&gt;report success while being broken&lt;/strong&gt;. A page that returns &lt;code&gt;200 OK&lt;/code&gt;. A healthcheck that says the container is fine. A backup that exits cleanly. A command that prints nothing wrong. Everything green, everything lying.&lt;/p&gt;

&lt;p&gt;Here are four of them, all from the same box (a 2016 desktop, i7-6700 / 32 GB, Docker behind Caddy, reachable only over Tailscale). Each fails by handing you a success signal. Each cost me an evening the first time. The fixes are boring once you know them, the point is knowing the failure exists.&lt;/p&gt;

&lt;p&gt;Sanitized skeleton with all the config at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. A loading page that returns 200
&lt;/h2&gt;

&lt;p&gt;This one I could find nothing written about, so it cost me the most.&lt;/p&gt;

&lt;p&gt;To keep the box quiet, I run the heavy services on-demand: &lt;a href="https://sablierapp.dev/" rel="noopener noreferrer"&gt;Sablier&lt;/a&gt; stops idle containers and starts them on the first request. Caddy (with the Sablier plugin) gates a virtual host behind a container group, serves a "please wait, starting up" page while the group boots, then proxies through:&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;myhost&lt;/span&gt;.&lt;span class="n"&gt;my&lt;/span&gt;-&lt;span class="n"&gt;tailnet&lt;/span&gt;.&lt;span class="n"&gt;ts&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;:&lt;span class="m"&gt;8081&lt;/span&gt; {
    &lt;span class="n"&gt;route&lt;/span&gt; {
        &lt;span class="n"&gt;sablier&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;://&lt;span class="n"&gt;sablier&lt;/span&gt;:&lt;span class="m"&gt;10000&lt;/span&gt; {
            &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="n"&gt;office&lt;/span&gt;
            &lt;span class="n"&gt;session_duration&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;
        }
        &lt;span class="n"&gt;reverse_proxy&lt;/span&gt; &lt;span class="n"&gt;nextcloud&lt;/span&gt;:&lt;span class="m"&gt;80&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I gate my whole Nextcloud vhost, WebDAV included, this way. And here is the silent failure: &lt;strong&gt;if the gated group is not healthy, Sablier serves that HTML loading page for every request, and it serves it with &lt;code&gt;200 OK&lt;/code&gt;.&lt;/strong&gt; A browser shows a spinner, fine. But my Obsidian vault syncs over WebDAV, and a WebDAV client asking for a directory listing got a &lt;code&gt;200&lt;/code&gt; with a chunk of HTML instead of the XML it expected. Sync died with a cryptic &lt;code&gt;no root multistatus found&lt;/code&gt;. Nextcloud itself was up and perfectly healthy the whole time. Every uptime check I had was green, because the gate in front kept answering &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The structural lesson: &lt;strong&gt;the moment you put a service on-demand behind a reverse proxy, that gate's health is the service's health&lt;/strong&gt;, and the gate fails by returning success with the wrong body. Which is exactly how failure 2 kept the gate stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A healthcheck that lies
&lt;/h2&gt;

&lt;p&gt;The group above stayed unhealthy because Docker had marked Collabora &lt;code&gt;unhealthy&lt;/code&gt;, and Docker had marked it unhealthy because its healthcheck was quietly broken, while the container served traffic perfectly.&lt;/p&gt;

&lt;p&gt;Collabora's documented healthcheck probes its discovery endpoint with &lt;code&gt;curl&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;# Silently fails on collabora/code 25.04 and later&lt;/span&gt;
&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:9980/hosting/discovery"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since the 25.04 image, &lt;code&gt;curl&lt;/code&gt; was removed to shrink it (&lt;a href="https://github.com/CollaboraOnline/online/issues/11856" rel="noopener noreferrer"&gt;CollaboraOnline/online#11856&lt;/a&gt;). The healthcheck does not complain that &lt;code&gt;curl&lt;/code&gt; is gone, it just exits non-zero every interval, forever. So you have a container that answers every real request correctly, flagged &lt;code&gt;unhealthy&lt;/code&gt; by a check that is testing nothing. Sablier trusts the flag, keeps the group "starting", and failure 1 does the rest.&lt;/p&gt;

&lt;p&gt;This is not a Collabora quirk, the same missing-&lt;code&gt;curl&lt;/code&gt; break hit Ollama (&lt;a href="https://github.com/ollama/ollama/issues/9781" rel="noopener noreferrer"&gt;#9781&lt;/a&gt;), litellm, and others: minimal images drop &lt;code&gt;curl&lt;/code&gt;, and every healthcheck copy-pasted from an old tutorial silently rots. The fix is to depend on nothing the image might not ship. &lt;code&gt;/dev/tcp&lt;/code&gt; is a bash builtin that opens a TCP socket with no external binary:&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;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bash&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'exec&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&amp;lt;&amp;gt;/dev/tcp/localhost/9980&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&amp;gt;/dev/null&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ok'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Socket opens, service is listening, healthcheck honest. Use it for anything too minimal to include an HTTP client.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A backup that is empty
&lt;/h2&gt;

&lt;p&gt;My offsite job dumps every database, then &lt;code&gt;rclone&lt;/code&gt;-syncs to an encrypted remote. The dumps exit &lt;code&gt;0&lt;/code&gt;. The files exist. The sync reports success. And two of them were empty for a while before I noticed.&lt;/p&gt;

&lt;p&gt;Postgres is the honest one, &lt;code&gt;pg_dumpall&lt;/code&gt; in the running container. MariaDB lied to me twice: &lt;strong&gt;a MariaDB container reports "started" a few seconds before it actually accepts connections.&lt;/strong&gt; A script that starts a stopped DB and dumps immediately gets a truncated or empty &lt;code&gt;.sql&lt;/code&gt;, &lt;code&gt;mariadb-dump&lt;/code&gt; exits &lt;code&gt;0&lt;/code&gt;, and you find out the day you try to restore. Poll before dumping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dump_mariadb&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;_ &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;15&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; mariadb-admin ping &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;--silent&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break
        sleep &lt;/span&gt;2
    &lt;span class="k"&gt;done
    &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; mariadb-dump &lt;span class="nt"&gt;--all-databases&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; root &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pass&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite (Vaultwarden) failed differently: the DB file in the volume is owned by root, so the host user cannot read it to run &lt;code&gt;.backup&lt;/code&gt;. Instead of &lt;code&gt;sudo&lt;/code&gt;, borrow a throwaway container that already ships &lt;code&gt;sqlite3&lt;/code&gt;, mount the volume, run the backup there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'/usr/bin/sqlite3'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; ./data/vaultwarden&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/data &lt;span class="se"&gt;\&lt;/span&gt;
    louislam/uptime-kuma:1 /data/db.sqlite3 &lt;span class="s2"&gt;".backup '/data/db.sqlite3.bak'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any image with &lt;code&gt;sqlite3&lt;/code&gt; works. And use &lt;code&gt;.backup&lt;/code&gt;, not &lt;code&gt;cp&lt;/code&gt;: it gives a consistent snapshot even while the app is writing, which a file copy silently does not. The rule that saved me: a backup you have never restored is a rumor. Test-restore it, or you are trusting an exit code.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A packet that vanishes
&lt;/h2&gt;

&lt;p&gt;A quiet box spends most of its time asleep, so I lean on Wake-on-LAN to bring machines up remotely. My first attempt: send the magic packet to the target's Tailscale IP from wherever I was. It ran, printed nothing wrong, and nothing happened. No error, no wake.&lt;/p&gt;

&lt;p&gt;The reason is a layer confusion that WoL never warns you about: &lt;strong&gt;a sleeping machine has no OS running, so there is no IP stack and no Tailscale to receive anything.&lt;/strong&gt; WoL only works as a broadcast on the local L2 segment, caught by the network card in hardware. A packet aimed at a layer-3 address the machine no longer has just evaporates, silently.&lt;/p&gt;

&lt;p&gt;The fix is a relay through a node that stays on. SSH into it over Tailscale from anywhere, and have it send the local broadcast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;you (remote) --Tailscale SSH--&amp;gt; always-on node (on LAN) --broadcast--&amp;gt; sleeping target
&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="c"&gt;# run on the always-on node, on the target's LAN&lt;/span&gt;
wakeonlan &lt;span class="nt"&gt;-i&lt;/span&gt; 192.168.1.255 AA:BB:CC:DD:EE:FF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to enable once on the target. Arm the NIC at the OS level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli con modify &amp;lt;profile&amp;gt; 802-3-ethernet.wake-on-lan magic
&lt;span class="c"&gt;# verify: ethtool &amp;lt;iface&amp;gt; shows "Wake-on: g"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And enable "Power On By PCI-E" (or "Wake on LAN") in the BIOS. This is the one people miss: it is what allows waking from a full shutdown (S5), not just from sleep (S3). Without it the card is powered down and, again, the packet vanishes with no complaint.&lt;/p&gt;




&lt;p&gt;The pattern across all four: the system returned a success signal (&lt;code&gt;200&lt;/code&gt;, exit &lt;code&gt;0&lt;/code&gt;, a printed command, a green dashboard) while being broken underneath. That is the failure class worth training your instincts on. When something in a self-hosted stack misbehaves, my first question now is not "what threw an error", it is "what is reporting success that I never actually verified end to end".&lt;/p&gt;

&lt;p&gt;Sanitized skeleton, compose + Caddyfile + backup and WoL scripts, MIT:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/mbaelde/sober-selfhost" rel="noopener noreferrer"&gt;github.com/mbaelde/sober-selfhost&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you have hit failure 1 with a different sync client, I would like to hear it in the comments, I suspect it is more common than the zero search results suggest.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>homelab</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
