<?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: Hoelee</title>
    <description>The latest articles on DEV Community by Hoelee (@hoelee).</description>
    <link>https://dev.to/hoelee</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%2F4190165%2Fa89ac5f2-a532-49d4-912e-5396b9dcadf2.png</url>
      <title>DEV Community: Hoelee</title>
      <link>https://dev.to/hoelee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hoelee"/>
    <language>en</language>
    <item>
      <title>The Forward-Auth Gate That Verified Perfectly — and Wasn't Live</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Mon, 02 Nov 2026 04:25:39 +0000</pubDate>
      <link>https://dev.to/hoelee/the-forward-auth-gate-that-verified-perfectly-and-wasnt-live-5g0a</link>
      <guid>https://dev.to/hoelee/the-forward-auth-gate-that-verified-perfectly-and-wasnt-live-5g0a</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/authentik-forward-auth-gate-wasnt-live/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I finished the gate, ran the check, and it passed.&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;302&lt;/span&gt; &lt;span class="ne"&gt;Found&lt;/span&gt;
&lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://app.example.com/outpost.goauthentik.io/start?rd=...&lt;/span&gt;
&lt;span class="na"&gt;x-powered-by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unauthenticated request was being pushed to my authentik login page. Branded, correct, working. I was about to move on — except that my own rule for anything user-facing is to hit it the way a user hits it, from outside, over the public internet. So I did.&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;GET https://app.example.com/          → 200
x-powered-by: Express
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app answered directly. No login page, no redirect, no gate. The exact same hostname, one request path gated and one wide open, depending on where the request entered my network.&lt;/p&gt;

&lt;p&gt;That was the whole day: not the SSO setup, which worked, but the fact that &lt;strong&gt;a hostname can be served by more than one ingress layer, and I had only gated one of them&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters beyond my one app
&lt;/h2&gt;

&lt;p&gt;If you self-host anything behind a tunnel, a reverse proxy, or both, you have probably already made this assumption: &lt;em&gt;"the reverse proxy is what serves my domains."&lt;/em&gt; It's an assumption that holds right up until it doesn't, and it fails in the most dangerous direction — everything you change looks correct, because the path you're inspecting is correct.&lt;/p&gt;

&lt;p&gt;The failure mode is silent by construction. There is no error, no log line, no hint. Your configuration check passes because you're checking the configuration you edited. Meanwhile the real traffic — the traffic you were protecting — never touches it.&lt;/p&gt;

&lt;p&gt;I've since found two other gates on the same host with the same latent problem, built the same way, waiting for someone to trust them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: SSO for an app that has no SSO
&lt;/h2&gt;

&lt;p&gt;The app was a self-hosted database UI on my NAS (Docker, published on a host port, sitting behind nginx and a Cloudflare Tunnel — the usual shape of the infrastructure I manage). I wanted a login wall in front of it so that "reachable from the internet" wouldn't mean "anyone who guesses the hostname sees the login form and can start guessing passwords".&lt;/p&gt;

&lt;p&gt;The app's own documentation advertises SSO. I'll keep this post focused on the ingress trap and put the licensing story in its own write-up, but the short version is that the advertised SSO is a licensed feature, the open-source build ships the code path but not the permission to use it, and the community fork that used to fill the gap hasn't been touched in two years. Twenty minutes of research, two abandoned approaches, one conclusion: &lt;strong&gt;the app would never do SSO for me, so the gate had to live in front of it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which is a normal, well-trodden thing to do. authentik calls it a proxy provider, and the flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A login flow&lt;/strong&gt; for this app — its own title, its own background, so a user who lands on the login page knows which system they're being asked to authenticate for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A proxy provider&lt;/strong&gt; — the object that knows the app's external hostname (&lt;code&gt;https://app.example.com&lt;/code&gt;) and its internal address (&lt;code&gt;http://app-container:8080&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An application&lt;/strong&gt; linking the two, attached to the embedded outpost. The outpost is the component that actually receives traffic, checks for a session, and proxies the request to the app's internal host after login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ingress&lt;/strong&gt; — pointing that hostname at the outpost instead of at the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–3 live in authentik and are configuration. Step 4 is the one that decides whether any of it is real, and it's the one I got wrong.&lt;/p&gt;

&lt;p&gt;Two smaller things from steps 1–3, since they cost me time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A partial PATCH on a proxy provider is rejected.&lt;/strong&gt; Updating one field returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"internal_host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Internal host cannot be empty when forward auth is disabled."&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The serializer re-validates the object, and a proxy provider without an internal host is invalid — so a one-field update fails as if you'd cleared a field you never touched. Send the identity fields together with the field you're changing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AUTH&lt;/span&gt;&lt;span class="s2"&gt;/api/v3/providers/proxy/&lt;/span&gt;&lt;span class="nv"&gt;$PK&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"mode":"proxy","external_host":"https://app.example.com",
       "internal_host":"http://app-container:8080","skip_path_regex":""}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;authentik API tokens can expire mid-session.&lt;/strong&gt; After working for an hour, every call started returning &lt;code&gt;403 {"detail":"Token invalid/expired"}&lt;/code&gt;. Note the status code: &lt;code&gt;403&lt;/code&gt; means "we know who you are, and you may not do this" — the response body is the only thing that tells you the token died. If you cache a token in a script, re-read it from the database when calls start failing instead of assuming a permissions problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: I verified the layer I changed
&lt;/h2&gt;

&lt;p&gt;Here is the check I ran, from the host itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} -&amp;gt; %{redirect_url}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Host: app.example.com'&lt;/span&gt; https://localhost/
&lt;span class="c"&gt;# 302 -&amp;gt; https://app.example.com/outpost.goauthentik.io/start?rd=...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a &lt;em&gt;good&lt;/em&gt; check. It proves the nginx vhost serves the outpost, that the outpost recognises the hostname, that the provider is linked to the application, and that the login flow is alive. Everything I had configured was proven correct in one command.&lt;/p&gt;

&lt;p&gt;It says nothing whatsoever about whether public traffic reaches that vhost.&lt;/p&gt;

&lt;p&gt;Here's why. My hostnames are served by &lt;strong&gt;two&lt;/strong&gt; independent ingress paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What serves it&lt;/th&gt;
&lt;th&gt;Where it lands&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct WAN IP → nginx (443)&lt;/td&gt;
&lt;td&gt;the vhost I edited&lt;/td&gt;
&lt;td&gt;the outpost ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Tunnel (&lt;code&gt;cloudflared&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;a rule inside the tunnel's own config&lt;/td&gt;
&lt;td&gt;the app's port, directly ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tunnel's ingress is a small ordered list evaluated per hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.example.com     -&amp;gt; http://app-container:8080     ← straight to the app
*                   -&amp;gt; http_status:404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule was written years earlier, when the goal was simply "make this reachable". It never mentioned nginx, because it never needed to. So my beautiful vhost sat off to the side of the real request path, gating a route that public traffic doesn't take.&lt;/p&gt;

&lt;p&gt;The two paths even answer on the same hostname, so nothing about the URL hints at the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What finally made it obvious was a response header.&lt;/strong&gt; The gated check returned &lt;code&gt;x-powered-by: authentik&lt;/code&gt;. The public check returned &lt;code&gt;x-powered-by: Express&lt;/code&gt;. Same URL, same second, two different servers answering. When you suspect you're looking at the wrong layer, read the headers — they name the software that answered, which is exactly the question you're asking.&lt;/p&gt;

&lt;p&gt;To see your own tunnel's rules rather than guessing (Cloudflare's API, not the dashboard):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;/cfd_tunnel/&lt;/span&gt;&lt;span class="nv"&gt;$TUNNEL_ID&lt;/span&gt;&lt;span class="s2"&gt;/configurations"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sys,json; [print(r.get('hostname','*'), '-&amp;gt;', r.get('service','')) for r in json.load(sys.stdin)['result']['config']['ingress']]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vcl"&gt;&lt;code&gt;git.example.com   &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; http:&lt;span class="c1"&gt;//10.0.0.10:6880&lt;/span&gt;
app.example.com   &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; http:&lt;span class="c1"&gt;//app-container:8080&lt;/span&gt;
bot.example.com   &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; http:&lt;span class="c1"&gt;//bot-container:8080&lt;/span&gt;
&lt;span class="o"&gt;*&lt;/span&gt;                 &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; http_status:&lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is, in one line, after a day of looking at nginx.&lt;/p&gt;

&lt;h3&gt;
  
  
  The permission wall on the way to the fix
&lt;/h3&gt;

&lt;p&gt;Editing that rule needs a token with &lt;strong&gt;Account → Cloudflare Tunnel → Edit&lt;/strong&gt;. Mine didn't have it, and no zone-scoped token can substitute: tunnels are account-scoped objects, so a Zone-scoped token fails the &lt;em&gt;read&lt;/em&gt; as well, with &lt;code&gt;1001 Not authorized&lt;/code&gt;. If you only have zone-level tokens, open the dashboard instead: &lt;strong&gt;Zero Trust → Networks → Tunnels → your tunnel → Public Hostnames&lt;/strong&gt; and change the rule's service to your outpost.&lt;/p&gt;

&lt;p&gt;For me the correct target was the outpost on the same Docker network as &lt;code&gt;cloudflared&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;app.example.com  -&amp;gt;  http://authentik-server:9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outpost reads the &lt;code&gt;Host&lt;/code&gt; header (which the tunnel preserves) to pick the application, then proxies to the internal host from the provider config. That's the whole change: one service value. Everything I'd built in authentik was already correct — it was simply never on the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional SSO: gate the UI, leave the API alone
&lt;/h2&gt;

&lt;p&gt;Once the gate is really in front of the traffic, the next problem is the API. A hostname-level gate can't tell a script from a browser, so token-only API calls get redirected to a login page — they don't fail with a JSON error, they get HTML.&lt;/p&gt;

&lt;p&gt;If you want SSO for humans and no SSO for machines, authentik's proxy provider has a per-path exemption. The UI calls it &lt;strong&gt;Unauthenticated Paths&lt;/strong&gt;; the API field is &lt;code&gt;skip_path_regex&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"skip_path_regex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^/api/.*"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified result on my instance, same hostname, same session:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;No session&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/dashboard&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;302&lt;/code&gt; → authentik login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/v1/health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; from the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/api/v2/tables/.../records&lt;/code&gt; + API token&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; with real data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two caveats worth knowing before you rely on it. This exemption applies at the outpost, so unlike a path split at the CDN it works on &lt;strong&gt;every&lt;/strong&gt; ingress path. And it is not per-user: the gate applies to the request, not the person. There is no "some users can skip SSO" — either a path is exempt for everyone, or everyone needs a session.&lt;/p&gt;

&lt;p&gt;I used a simpler form of the same idea first: &lt;strong&gt;move every machine caller off the public hostname before the gate exists.&lt;/strong&gt; My automation was calling the app through the public URL and being routed out to Cloudflare and back into my own network for no reason. Pointing it at the container name (&lt;code&gt;http://app-container:8080&lt;/code&gt;) shortened the path, removed a failure mode, and meant the gate couldn't break it. If a login wall takes down your integration, the integration was depending on the door being unlocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: this is not single sign-on
&lt;/h2&gt;

&lt;p&gt;I have to be straight about what a forward-auth gate gives you, because "SSO" oversells it.&lt;/p&gt;

&lt;p&gt;The outpost knows who you are. The app does not. Unless the app can consume an upstream identity — header-based auth, or native OIDC/SAML — it will show its own login form after the gate lets you through. So the user experience is: authentik login, then the app's login.&lt;/p&gt;

&lt;p&gt;For a user who is already signed in to authentik, the first step is often silent. But it's still two systems, two session lifetimes, and two places a password can be wrong. In my case the app has no header auth and its own OIDC is a licensed feature, so the gate would have been an &lt;strong&gt;extra&lt;/strong&gt; door bolted in front of the existing one — no reduction in logins at all.&lt;/p&gt;

&lt;p&gt;That's a legitimate trade for some setups: keep the app's login for its own users, and stop the internet from reaching the login form. It's a poor trade if what you actually wanted was "my team types one password". Which meant the honest answer for me was to turn the whole thing back off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverting it properly (and the arithmetic that saved ten other apps)
&lt;/h2&gt;

&lt;p&gt;Undoing the gate is where I nearly caused a much bigger outage than the one I was preventing.&lt;/p&gt;

&lt;p&gt;The nginx change was one line in a large generated vhost file. The obvious revert is to substitute the port back:&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;# DON'T — this is the version that would have hurt&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s#proxy_pass http://localhost:10000;#proxy_pass http://localhost:10380;#'&lt;/span&gt; vhost.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before running it, I counted how many server blocks point at that same port:&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;-c&lt;/span&gt; &lt;span class="s1"&gt;'proxy_pass http://localhost:10000;'&lt;/span&gt; vhost.conf
&lt;span class="c"&gt;# 11&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven. My app was one of them. &lt;strong&gt;Ten other applications are gated through that outpost&lt;/strong&gt;, and a blanket substitution would have silently un-gated every one of them at once — including the ones doing real authentication work. The "fix" would have looked successful and removed authentication from most of my infrastructure.&lt;/p&gt;

&lt;p&gt;What I did instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot the current state first&lt;/strong&gt;, so the revert was itself reversible: &lt;code&gt;cp -a vhost.conf vhost.conf.bak-$(date +%Y%m%d-%H%M)-revert&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the exact line by diffing against the backup taken before the original change.&lt;/strong&gt; That diff showed one changed line, which is proof of exactly how much I had touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit that line only&lt;/strong&gt;, then re-diff against the original backup and require the result to be identical before going further.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the second file&lt;/strong&gt; that mirrors the same setting — the NAS's own reverse-proxy database, which regenerates the vhost on DSM events. I edited the single key and asserted the hostname before writing, never restoring the whole file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nginx -t&lt;/code&gt; then reload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And then a detail that cost me another twenty minutes of doubt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Host: app.example.com'&lt;/span&gt; https://localhost/
&lt;span class="c"&gt;# still 302 -&amp;gt; /outpost.goauthentik.io/start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The revert looked like it had failed. It hadn't. A request issued microseconds after the reload can still be answered by the old worker while it drains. The re-test a few seconds later returned &lt;code&gt;200 x-powered-by: Express&lt;/code&gt; — direct to the app, gate gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a config change appears not to apply, re-test after a beat before you go looking for a second cause.&lt;/strong&gt; With a request that fast, "the config is wrong" and "the old process is still answering" are indistinguishable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enumerate every ingress path for a hostname before building anything on top of it.&lt;/strong&gt; My checklist is now four questions, asked in this order:

&lt;ul&gt;
&lt;li&gt;Is the hostname proxied by a CDN/tunnel? If yes, its rules are the real ones — check them first, not last.&lt;/li&gt;
&lt;li&gt;What does the reverse proxy vhost say?&lt;/li&gt;
&lt;li&gt;Does the platform mirror that vhost somewhere else that can regenerate it?&lt;/li&gt;
&lt;li&gt;Is there another hop (VPS, second proxy, vendor tunnel) with its own copy of the routing table?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify from outside, and verify which software answered.&lt;/strong&gt; &lt;code&gt;curl&lt;/code&gt; from the host with a &lt;code&gt;Host:&lt;/code&gt; header proves your config is &lt;em&gt;self-consistent&lt;/em&gt;. Only a request from the public internet, plus &lt;code&gt;x-powered-by&lt;/code&gt;/&lt;code&gt;server&lt;/code&gt; headers, proves it's &lt;em&gt;in force&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find out whether the app can consume an upstream identity before you build the gate.&lt;/strong&gt; If it can't, you're adding a login, not removing one — a decision to make deliberately, not to discover afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count before you substitute.&lt;/strong&gt; Any port or hostname you're replacing globally probably appears in more places than you think. &lt;code&gt;grep -c&lt;/code&gt; costs a second and prevents an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot, diff, edit one line, re-diff.&lt;/strong&gt; The backup's only job is to answer "how much did I change?" — and a diff answers it exactly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The gate worked. It was also invisible to every real user, and after working out what it actually delivered — a second login in front of an app that already had one — I turned it off again: one field cleared, one application unlinked, one line restored, verified by a request from outside my network.&lt;/p&gt;

&lt;p&gt;The two checks that would have caught the whole thing on day one take about thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://app.example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^x-powered-by'&lt;/span&gt;   &lt;span class="c"&gt;# who is actually answering?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt;  https://app.example.com/ &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt;  &lt;span class="c"&gt;# what does a stranger get?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the answer isn't what your configuration says it should be, you're editing the wrong layer. That's worth knowing before you spend the day on it — and definitely before you tell anyone the gate is live.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want this for your business?
&lt;/h2&gt;

&lt;p&gt;If you need single sign-on in front of internal tools, a self-hosted app exposed safely to the internet, or someone to audit which ingress path is actually serving your domains, that's the work I do.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp:&lt;/strong&gt; &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt; — tap to chat&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; &lt;a href="mailto:me@hoelee.com?subject=Reverse%20proxy%20%26%20SSO%20enquiry"&gt;me@hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://www.hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I set up authentik single sign-on, hardened reverse proxies and Cloudflare tunnels, and self-hosted Docker stacks for small businesses in Malaysia — and I write up what I learn while doing it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>docker</category>
      <category>linux</category>
    </item>
    <item>
      <title>Your Disk-Full Alert Is Lying: Stop Alerting on Percentages</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:25:47 +0000</pubDate>
      <link>https://dev.to/hoelee/your-disk-full-alert-is-lying-stop-alerting-on-percentages-3jaa</link>
      <guid>https://dev.to/hoelee/your-disk-full-alert-is-lying-stop-alerting-on-percentages-3jaa</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/your-disk-full-alert-is-lying/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a monitoring stack, wrote a reasonable set of rules, and then spent an&lt;br&gt;
afternoon deleting most of my own thresholds. Every one of them was a percentage, and&lt;br&gt;
every one of them was wrong in the same specific way.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three alerts that taught me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. "Filesystem above 90% used."&lt;/strong&gt; It fired on a 17 TB volume that had ~500 GB free.&lt;br&gt;
Nothing was wrong. Nothing was &lt;em&gt;about&lt;/em&gt; to be wrong. On a volume that size, a single&lt;br&gt;
large backup can swing the percentage by several points — the ratio is noise, and the&lt;br&gt;
fact that 500 GB is still available is the actual state of the world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "Memory above 90% used."&lt;/strong&gt; Fired constantly on a host with 4.8 GB &lt;em&gt;available&lt;/em&gt;.&lt;br&gt;
Linux uses free RAM for page cache and gives it back on demand; &lt;code&gt;MemTotal - MemFree&lt;/code&gt;&lt;br&gt;
describes your filesystem cache, not your risk. The number that predicts an OOM is&lt;br&gt;
&lt;code&gt;MemAvailable&lt;/code&gt;. (Even if one alerts on &lt;code&gt;MemAvailable&lt;/code&gt; exceeding 90% capacity, on a 64 GB host it still fires an alert when 4.8 GB of usable headroom remains.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "CPU steal above 25%."&lt;/strong&gt; Fired on a VPS that had been running its mail stack&lt;br&gt;
perfectly at 41% steal. Steal means the hypervisor is busy — it is a &lt;em&gt;capacity&lt;/em&gt; signal,&lt;br&gt;
not a &lt;em&gt;failure&lt;/em&gt; signal, and 41% steal on a 7-vCPU box was simply what that host costs.&lt;br&gt;
The rule produced alerts that were always true and never actionable.&lt;/p&gt;

&lt;p&gt;The common thread: &lt;strong&gt;I was alerting on a ratio, and the ratio doesn't know how big the&lt;br&gt;
thing is.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Alert on the consequence, not the ratio
&lt;/h2&gt;

&lt;p&gt;The test I now apply to every threshold: &lt;em&gt;if this condition persists, what will actually&lt;br&gt;
happen?&lt;/em&gt; That answer is almost always expressible in absolute units.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instead of&lt;/th&gt;
&lt;th&gt;Alert on&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem &amp;gt; 90%&lt;/td&gt;
&lt;td&gt;Free space &amp;lt; N GB&lt;/td&gt;
&lt;td&gt;"Can I still write?" is the question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory &amp;gt; 90% used&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MemAvailable&lt;/code&gt; &amp;lt; 512 MB&lt;/td&gt;
&lt;td&gt;about to OOM, not "cache is warm"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU steal &amp;gt; 25%&lt;/td&gt;
&lt;td&gt;Steal &amp;gt; 50%&lt;/td&gt;
&lt;td&gt;capacity cost vs. starvation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load average &amp;gt; N&lt;/td&gt;
&lt;td&gt;&lt;code&gt;load1 / cores &amp;gt; 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;load is meaningless without core count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In PromQL, before and after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# BEFORE — percentage of a 17 TB pool; fires with half a terabyte still free
(1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 &amp;gt; 90

# AFTER — capacity left, on the volumes that hold data
min by (instance, mountpoint) (
  node_filesystem_avail_bytes{mountpoint=~"/volume[0-9]+|/mnt/ssd|/mnt/disk[0-9]+"}
) &amp;lt; 25e9
&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;# BEFORE — page cache counted as "pressure" (misusing MemFree)
(1 - node_memory_MemFree_bytes / node_memory_MemTotal_bytes) * 100 &amp;gt; 90

# AFTER — the actual OOM precursor based on kernel-reclaimable MemAvailable
node_memory_MemAvailable_bytes &amp;lt; 512 * 1024 * 1024
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that make these rules survive contact with a real dashboard:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the comparison in the threshold condition, not inside the expression.&lt;/strong&gt; Query&lt;br&gt;
&lt;code&gt;min by (mountpoint) (node_filesystem_avail_bytes{...})&lt;/code&gt;, then let the alert rule&lt;br&gt;
compare it to &lt;code&gt;25000000000&lt;/code&gt;. If you bake &lt;code&gt;&amp;lt; 25e9&lt;/code&gt; into the PromQL &lt;em&gt;and&lt;/em&gt; leave an&lt;br&gt;
inherited &lt;code&gt;&amp;gt; 90&lt;/code&gt; in the rule, the rule still evaluates — but it displays a nonsense&lt;br&gt;
threshold, and the next person to read it has to reconstruct your intent from two&lt;br&gt;
contradictory conditions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exclude the mounts that are views of other mounts.&lt;/strong&gt; A "free space" rule is only as&lt;br&gt;
honest as its selector. On my own setup, one NAS volume appears three times (as&lt;br&gt;
&lt;code&gt;/volume1&lt;/code&gt;, as &lt;code&gt;/opt&lt;/code&gt;, and again on another host as a CIFS re-mount), and unRaid's shfs&lt;br&gt;
union mount reports &lt;code&gt;avail=0&lt;/code&gt; — a rule without exclusions would either fire forever or&lt;br&gt;
double-count capacity. One explicit selector beats a clever generic one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_filesystem_avail_bytes{mountpoint=~"/volume[0-9]+|/mnt/ssd|/mnt/disk[0-9]+"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Noise is not harmless
&lt;/h2&gt;

&lt;p&gt;The cost of a bad threshold isn't the alert itself — it's the &lt;em&gt;training&lt;/em&gt;. Every alert&lt;br&gt;
that fires while nothing is wrong teaches you to skim, then to ignore, and then the one&lt;br&gt;
that matters arrives in a channel you've stopped reading. My rule count went &lt;strong&gt;down&lt;/strong&gt;&lt;br&gt;
while coverage went up: fewer, each tied to a named consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thresholds are personal, shapes are not
&lt;/h2&gt;

&lt;p&gt;The specific numbers above are mine — 25 GB free before a NAS volume bothers me, 512 MB&lt;br&gt;
available before I care about memory, 50% steal before a VPS is being starved. Yours&lt;br&gt;
will differ with your hardware and your tolerance.&lt;/p&gt;

&lt;p&gt;What transfers is the shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;absolute units over ratios,&lt;/li&gt;
&lt;li&gt;the consequence written into the alert's summary,&lt;/li&gt;
&lt;li&gt;a selector that states exactly which mounts it speaks for,&lt;/li&gt;
&lt;li&gt;and a rule count small enough that you still read every message.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The same stack that was producing three permanent, uninformative warnings now runs 16&lt;br&gt;
rules that stay silent when the infrastructure is healthy — and one of them immediately surfaced a&lt;br&gt;
NAS volume sitting at 21 GB free, which is the kind of thing a percentage rule had been&lt;br&gt;
hiding behind a 99%-full giant that I'd learned to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want this for your business?
&lt;/h2&gt;

&lt;p&gt;If your monitoring sends you alerts you've learned to ignore, or you have none and would&lt;br&gt;
rather find out about a full disk from a rule than from a failed backup, I set up&lt;br&gt;
self-hosted monitoring and alerting (Prometheus + Grafana, alerts to Telegram and email)&lt;br&gt;
with thresholds tied to what actually breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WhatsApp: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;Email: &lt;a href="mailto:me@hoelee.com?subject=Monitoring%20and%20alerting"&gt;me@hoelee.com&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Website design and development is my main line of work; server hardening and&lt;br&gt;
self-hosted infrastructure is the other half of it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
      <category>backup</category>
    </item>
    <item>
      <title>Why Your Grafana Dashboard Shows No Data (When Prometheus Is Fine)</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sat, 31 Oct 2026 04:25:13 +0000</pubDate>
      <link>https://dev.to/hoelee/why-your-grafana-dashboard-shows-no-data-when-prometheus-is-fine-59hb</link>
      <guid>https://dev.to/hoelee/why-your-grafana-dashboard-shows-no-data-when-prometheus-is-fine-59hb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/why-your-grafana-dashboard-shows-no-data/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You open the dashboard you use every day and it's empty. Not "one panel broke" empty —&lt;br&gt;
&lt;strong&gt;every panel&lt;/strong&gt;, top to bottom, says &lt;em&gt;No data&lt;/em&gt;. You check Prometheus and everything is&lt;br&gt;
healthy. This post is the specific, non-obvious cause I hit, and the way my own&lt;br&gt;
verification convinced me the dashboard was fine while it wasn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  The searchable question
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;My Grafana panels show No data but the data is in Prometheus — why?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The usual answers (wrong datasource, wrong time range, &lt;code&gt;rate()&lt;/code&gt; on a counter that&lt;br&gt;
resets, missing scrape target) did not apply. Here is the checklist that narrowed it&lt;br&gt;
down, in the order that costs the least 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="c"&gt;# 1. is the target actually being scraped?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://prometheus:9090/api/v1/targets | jq &lt;span class="s1"&gt;'.data.activeTargets[] | {job:.labels.job, health}'&lt;/span&gt;

&lt;span class="c"&gt;# 2. does the metric exist right now?  (bypasses Grafana entirely)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; http://prometheus:9090/api/v1/query &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'query=node_uname_info{job="unraid-host"}'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.data.result[0].metric'&lt;/span&gt;

&lt;span class="c"&gt;# 3. is the panel's expression sound?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; http://prometheus:9090/api/v1/query &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'query=count(node_cpu_seconds_total{mode="idle",job="unraid-host"})'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three were green: 7 targets &lt;code&gt;up&lt;/code&gt;, 25k series ingested, and the panel's raw&lt;br&gt;
expression returned data when I ran it by hand. So the problem was not the metrics,&lt;br&gt;
the scrape, or the PromQL — it was &lt;strong&gt;the variable layer between them&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The actual cause: a variable that filtered on itself
&lt;/h2&gt;

&lt;p&gt;The dashboard had a host picker. Two variables chained: &lt;code&gt;$nodename&lt;/code&gt; (which machine)&lt;br&gt;
and &lt;code&gt;$node&lt;/code&gt; (its &lt;code&gt;instance&lt;/code&gt; label). The definition of the first one was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# BROKEN — the variable filters on its own value
label_values(node_uname_info{job="unraid-host", nodename=~"$nodename"}, nodename)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first load &lt;code&gt;$nodename&lt;/code&gt; has no value, so the selector becomes&lt;br&gt;
&lt;code&gt;nodename=~""&lt;/code&gt; — which matches &lt;strong&gt;nothing&lt;/strong&gt;. A variable whose query returns no rows has&lt;br&gt;
no options, so it stays empty. &lt;code&gt;$node&lt;/code&gt; is then defined against &lt;code&gt;$nodename&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;label_values(node_uname_info{job="unraid-host", nodename="$nodename"}, instance)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…which is also empty, so every panel filtering on &lt;code&gt;$node&lt;/code&gt; matches no series. Grafana&lt;br&gt;
isn't lying when it says &lt;em&gt;No data&lt;/em&gt;: the panel query genuinely has no data, because the&lt;br&gt;
variable that scopes it resolved to nothing.&lt;/p&gt;

&lt;p&gt;The fix is to stop the self-reference and give each variable an explicit saved default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# FIXED — no self-reference, one hop per variable
# $nodename
label_values(node_uname_info{job="unraid-host"}, nodename)
# $node
label_values(node_uname_info{job="unraid-host", nodename="$nodename"}, instance)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then save &lt;code&gt;current&lt;/code&gt; values for both so the dashboard opens populated instead of&lt;br&gt;
depending on a click. (This is also why the same 41-panel layout can be cloned to&lt;br&gt;
three hosts and just work.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Why my verification missed it
&lt;/h2&gt;

&lt;p&gt;This is the part worth stealing. I "verified" the dashboard by expanding each panel's&lt;br&gt;
expression &lt;strong&gt;by hand&lt;/strong&gt;, substituting the variable values I knew were correct&lt;br&gt;
(&lt;code&gt;$nodename=unRaid&lt;/code&gt;, &lt;code&gt;$node=host.docker.internal:9100&lt;/code&gt;), and asserting the query&lt;br&gt;
returned points. Thirteen of those checks passed. The dashboard was still blank,&lt;br&gt;
because the bug was upstream of the substitution: Grafana's &lt;em&gt;own&lt;/em&gt; resolution of the&lt;br&gt;
variables was empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verification that substitutes the very value under suspicion cannot detect that&lt;br&gt;
value failing to resolve.&lt;/strong&gt; To catch it, read the values Grafana actually saved and&lt;br&gt;
expand with those:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://grafana:4010/api/dashboards/uid/rYdddlPWk &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.dashboard.templating.list[] | {name, current: .current.value}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct check is on the variable query itself — run what Grafana runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# what does $nodename's label_values() actually return?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; http://prometheus:9090/api/v1/query &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'query=count by (nodename) (node_uname_info{job="unraid-host"})'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.data.result'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty there means the dashboard cannot possibly render, no matter how healthy the data is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second trap: &lt;code&gt;$__all&lt;/code&gt; is not &lt;code&gt;.*&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;While automating panel checks, a variable set to &lt;em&gt;All&lt;/em&gt; reports its current value as the&lt;br&gt;
sentinel string &lt;code&gt;$__all&lt;/code&gt; — not a regex. Expanding panel expressions naively turns&lt;br&gt;
&lt;code&gt;name=~"$__all"&lt;/code&gt; into a literal that matches nothing, so a perfectly healthy&lt;br&gt;
container dashboard "fails" every panel. Resolve &lt;code&gt;$__all&lt;/code&gt; to the variable's &lt;code&gt;allValue&lt;/code&gt;&lt;br&gt;
(usually &lt;code&gt;.*&lt;/code&gt;) before expanding, or you'll chase a bug that only exists in your checker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every empty panel is a bug
&lt;/h2&gt;

&lt;p&gt;After the fix, out of the 41 total layout panels, &lt;strong&gt;21 of the 25 core metric panels&lt;/strong&gt; returned data. The remaining four were empty &lt;strong&gt;by design&lt;/strong&gt; and&lt;br&gt;
worth naming honestly in the panel description:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PSI panels&lt;/strong&gt; (&lt;code&gt;Pressure&lt;/code&gt;, &lt;code&gt;Pressure Stall Information&lt;/code&gt;) need &lt;code&gt;/proc/pressure&lt;/code&gt;, which
the on-premise NAS storage node's kernel doesn't expose. The metric cannot exist there — the same panels on a
host that &lt;em&gt;does&lt;/em&gt; export &lt;code&gt;node_pressure_*&lt;/code&gt; render fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root filesystem panels&lt;/strong&gt; whose expression carries &lt;code&gt;fstype!="rootfs"&lt;/code&gt; can never match
on a host whose &lt;code&gt;/&lt;/code&gt; is a &lt;strong&gt;ramdisk&lt;/strong&gt;, which is what unRaid's root is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Distinguishing "broken because of a bug" from "empty because the metric cannot exist on&lt;br&gt;
this host" is the difference between a real fix and a wild goose chase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never define a template variable that filters on itself.&lt;/strong&gt; One hop per variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always save explicit &lt;code&gt;current&lt;/code&gt; values&lt;/strong&gt; for variables a dashboard depends on, so a
fresh load doesn't rely on a populated dropdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with Grafana's saved values, not my idea of them.&lt;/strong&gt; The masking is invisible
in the test result and obvious in the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Describe intentional gaps in the panel description.&lt;/strong&gt; Future-you will otherwise
spend an afternoon "fixing" a panel that was never meant to work there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;All three hosts now run the same instrumented layout — a 41-panel host dashboard (with 21/23/25 of the 25 core metric panels returning data respectively) and a&lt;br&gt;
10-panel container dashboard each — and the two structurally-empty classes documented in place instead of silently&lt;br&gt;
confusing whoever opens them next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want this for your business?
&lt;/h2&gt;

&lt;p&gt;If you have Grafana dashboards that nobody trusts — or servers and a NAS with no&lt;br&gt;
monitoring at all — I build self-hosted Prometheus + Grafana stacks (host and container&lt;br&gt;
metrics, sensible alert rules, alerts to Telegram and email) and I'll fix the ones that&lt;br&gt;
have quietly stopped showing data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WhatsApp: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;Email: &lt;a href="mailto:me@hoelee.com?subject=Grafana%20monitoring"&gt;me@hoelee.com&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Website design and development is my main line of work; server hardening and&lt;br&gt;
self-hosted infrastructure is the other half of it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>selfhosted</category>
      <category>linux</category>
    </item>
    <item>
      <title>Stop Writing Agent Prompts for Deterministic Work</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:25:11 +0000</pubDate>
      <link>https://dev.to/hoelee/stop-writing-agent-prompts-for-deterministic-work-5bbo</link>
      <guid>https://dev.to/hoelee/stop-writing-agent-prompts-for-deterministic-work-5bbo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/stop-writing-agent-prompts-for-deterministic-work/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One of my scheduled jobs failed 29 times in a row. Nobody told me, because&lt;br&gt;
it was designed to speak only when it had something to report — and a run&lt;br&gt;
that dies before it can speak looks exactly like a run that found nothing&lt;br&gt;
wrong.&lt;/p&gt;

&lt;p&gt;The job was a version check: ask npm for the latest published version of a&lt;br&gt;
CLI I use, compare it with the one installed here, and tell me if there is a&lt;br&gt;
newer one. That is the entire task. It is two strings and an inequality.&lt;/p&gt;

&lt;p&gt;I had built it as an agent job, because that was the pattern I had in my&lt;br&gt;
head at the time. Every tick, the model would read the injected script&lt;br&gt;
output and decide whether to write a notification. It worked for a while.&lt;br&gt;
Then my global default model changed, and the job — which records the model&lt;br&gt;
it was created with — stopped running entirely. It printed &lt;code&gt;[drift_skip]&lt;/code&gt;&lt;br&gt;
and exited. Twenty-nine times.&lt;/p&gt;

&lt;p&gt;The fix was not a better prompt. It was deleting the model from the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If the stop condition is a comparison — a string, a number, a status code — a model in that loop can only add failure modes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no judgment to make in "is &lt;code&gt;3.8.50&lt;/code&gt; newer than &lt;code&gt;3.8.50&lt;/code&gt;". The model&lt;br&gt;
was not deciding anything the code could not; it was adding a dependency (a&lt;br&gt;
model route, a provider, a config snapshot, a token bill) and a new way to&lt;br&gt;
fail. So I rewrote the checker as 125 lines of Python with an explicit exit&lt;br&gt;
path for every branch, and flipped the job to script mode: the script's&lt;br&gt;
stdout is delivered as the notification, with no LLM anywhere in the path.&lt;/p&gt;

&lt;p&gt;It has run clean ever since. Anthropic's own guidance says the same thing in&lt;br&gt;
one line — &lt;em&gt;use scripts for deterministic work; running a script is cheaper&lt;br&gt;
than reasoning through the steps&lt;/em&gt; — but the cost was never the interesting&lt;br&gt;
part for me. The reliability was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the model does earn its place
&lt;/h2&gt;

&lt;p&gt;Of the eleven loops I run, eight now have no model in the path. The three&lt;br&gt;
that do all handle something I genuinely cannot express as code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is this email important?&lt;/strong&gt; A mailbox produces promos, DMARC reports, container alerts and a customer asking for a quote. Fetching mail is deterministic. Ranking it is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this shop voucher about to overspend?&lt;/strong&gt; The arithmetic is trivial; deciding what a finding means for money I have already committed is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this project stalled?&lt;/strong&gt; Reading two markdown files is a script's job. Noticing that the plan has not moved in three weeks is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test I apply to a new loop: &lt;strong&gt;write down what the check would print on&lt;br&gt;
success and on failure.&lt;/strong&gt; If both answers are a number or a string, it is a&lt;br&gt;
script. If the answer is "it depends what it says", it needs a model — and&lt;br&gt;
then it needs a verifier I trust more than the agent, because I am no longer&lt;br&gt;
able to predict the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually bit me
&lt;/h2&gt;

&lt;p&gt;The failure mode was not the drift. It was the &lt;strong&gt;silence&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A loop that speaks only when something is wrong is the only kind of loop I&lt;br&gt;
can tolerate — I do not want a daily status report from eleven jobs. But&lt;br&gt;
"silent because healthy" and "silent because it crashed before it could&lt;br&gt;
speak" are the same observation from my side of the phone. Twenty-nine runs&lt;br&gt;
of evidence were sitting in the job's execution log the whole time; I was&lt;br&gt;
trusting the absence of a notification instead of reading the history.&lt;/p&gt;

&lt;p&gt;So every script in the fleet now has a second exit path: exit 0 means&lt;br&gt;
&lt;em&gt;measured, here is what I found&lt;/em&gt;, and exit 1 means &lt;em&gt;I could not measure&lt;/em&gt; —&lt;br&gt;
and exit 1 is never silent. Missing config, unparseable state, an unexpected&lt;br&gt;
exception: all of them print a line and exit 1. That one change would have&lt;br&gt;
surfaced this bug on the first run instead of the twenty-ninth.&lt;/p&gt;

&lt;p&gt;If you have a loop that has been "quiet for a while", go and read its last&lt;br&gt;
ten runs before you conclude it is working. That advice cost me three weeks.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Deterministic check → script. Judgment → model. Do not mix them for convenience.&lt;/li&gt;
&lt;li&gt;Give the silent-on-success convention one exception: "I could not measure" must always be loud.&lt;/li&gt;
&lt;li&gt;Read the runner's own execution history, not just your notifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Want a version of this for your own site or shop — uptime, certificates,&lt;br&gt;
backups, mail that only pings you when it matters?&lt;br&gt;
&lt;strong&gt;&lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp +60 12-797 2969&lt;/a&gt;&lt;/strong&gt; ·&lt;br&gt;
&lt;strong&gt;&lt;a href="mailto:me@hoelee.com?subject=Self-hosted%20monitoring"&gt;me@hoelee.com&lt;/a&gt;&lt;/strong&gt; ·&lt;br&gt;
&lt;strong&gt;&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>automation</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>Running Production Infrastructure Solo: 162 Containers Across Three Hosts</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Thu, 29 Oct 2026 04:25:39 +0000</pubDate>
      <link>https://dev.to/hoelee/running-production-infrastructure-solo-162-containers-across-three-hosts-28bl</link>
      <guid>https://dev.to/hoelee/running-production-infrastructure-solo-162-containers-across-three-hosts-28bl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/running-production-infrastructure-solo/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;A business that sells software needs two things at once: an application that&lt;br&gt;
does the job, and the machinery underneath that keeps it reachable, backed&lt;br&gt;
up and secure. Agencies usually split those two between a developer and a&lt;br&gt;
hosting company, which is how you end up with a developer who cannot see the&lt;br&gt;
server and a hosting company that cannot read the code.&lt;/p&gt;

&lt;p&gt;I run both. That means the person who wrote the application is also the&lt;br&gt;
person who gets woken up when TLS renewal stops working, and who can fix the&lt;br&gt;
application rather than blame the platform.&lt;/p&gt;

&lt;p&gt;This is my own production footprint: three self-managed Docker hosts running&lt;br&gt;
162 containers across 78 compose stacks, serving live public services —&lt;br&gt;
client websites, a mail platform, CI/CD, workflow automation, analytics and&lt;br&gt;
this blog. No cloud platform holds it together, and no team operates it.&lt;br&gt;
Here is the architecture, and the two failures that changed how I check my&lt;br&gt;
own work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;What it carries&lt;/th&gt;
&lt;th&gt;Containers&lt;/th&gt;
&lt;th&gt;Compose stacks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Synology DSM&lt;/td&gt;
&lt;td&gt;storage, SSO, the bulk of internal services&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unRaid&lt;/td&gt;
&lt;td&gt;CI runner, media pipeline, monitoring&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu 24.04 VPS&lt;/td&gt;
&lt;td&gt;public entry point, mail platform, client sites&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The split is deliberate rather than historical. The VPS carries what must&lt;br&gt;
stay reachable and fast independent of the on-premise uplink, because that is&lt;br&gt;
where public traffic and client mail land. The Synology carries what needs&lt;br&gt;
disk and a stable filesystem, plus the identity provider that every other&lt;br&gt;
service trusts. unRaid runs the CI runner and the heavy, spiky batch work&lt;br&gt;
that would otherwise compete with the services for RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes three hosts one system
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One ingress pattern.&lt;/strong&gt; Each host runs a Traefik reverse proxy with automated ACME TLS, so certificates renew themselves and no service owns a certificate file. Behind it there is one convention for routing and redirects, and Cloudflare sits in front of the public names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One identity provider.&lt;/strong&gt; authentik handles SSO with OIDC and proxy providers, so a new internal service is a new application in one place instead of a new password in a spreadsheet. Public services stay anonymous; everything else authenticates through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One monitoring stack.&lt;/strong&gt; Prometheus, Grafana, cAdvisor and node-exporter across the hosts, in a single Grafana rather than three dashboards, because the interesting failures are the ones that cross host boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One delivery path.&lt;/strong&gt; Self-hosted Gitea Action runners build and deploy. This blog ships through that pipeline — commit, runner picks it up, container rebuilds, Cloudflare serves the new build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups that are tested rather than assumed.&lt;/strong&gt; Snapshot-based backup on the NAS plus Duplicati and restic jobs, with restore procedures written down, because an untested backup is a feeling, not a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: the dashboard was lying to me
&lt;/h2&gt;

&lt;p&gt;When I built the monitoring stack I trusted what Grafana showed me, and&lt;br&gt;
three of the panels were wrong.&lt;/p&gt;

&lt;p&gt;cAdvisor reported "containers" that were really cgroup pseudo-entries, which&lt;br&gt;
inflated the container count. My total-storage panel counted one NAS volume&lt;br&gt;
three times, because three near-identical mount points were being scraped as&lt;br&gt;
separate filesystems — the number looked plausible, which is exactly what&lt;br&gt;
made it dangerous. And one virtual machine exported no CPU frequency at all,&lt;br&gt;
so its panel stayed empty and I read that as "no data yet" instead of "this&lt;br&gt;
exporter is misconfigured".&lt;/p&gt;

&lt;p&gt;None of those broke a service. All three would have made me report something&lt;br&gt;
false to a client, because a wrong number on a dashboard is&lt;br&gt;
indistinguishable from a right one. I wrote the whole thing up in&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/one-prometheus-for-unraid-synology-and-a-vps/" rel="noopener noreferrer"&gt;One Prometheus for unRaid, a Synology NAS, and a VPS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: one missing config file, three failure modes
&lt;/h2&gt;

&lt;p&gt;Passbolt, my team password manager, started returning 504s and its cron job&lt;br&gt;
appeared to hang. It turned out to be three separate failure modes with one&lt;br&gt;
root cause: a configuration value that existed inside the running container&lt;br&gt;
but never in the compose file.&lt;/p&gt;

&lt;p&gt;A null GPG fingerprint stalled mail delivery, and because the mail send sat&lt;br&gt;
inside the scheduled job, the whole job hung and took the UI with it. The&lt;br&gt;
fix worked — until the container was recreated, which wiped it and restored&lt;br&gt;
the original symptom, so the problem appeared to come back on its own. Then&lt;br&gt;
&lt;code&gt;ssl.force&lt;/code&gt; behind a reverse proxy produced a redirect loop that I had&lt;br&gt;
introduced myself while chasing the other two.&lt;/p&gt;

&lt;p&gt;Three symptoms, three sessions, one lesson: configuration that is not in&lt;br&gt;
version control is not configuration. The full sequence is in&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/passbolt-hang-three-failure-modes/" rel="noopener noreferrer"&gt;Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I keep the numbers honest
&lt;/h2&gt;

&lt;p&gt;Numbers on a CV go stale quietly. Mine are re-derivable: a small script&lt;br&gt;
reads the live Docker endpoints through the Portainer API and counts&lt;br&gt;
containers and compose stacks per host, so "162 containers across 78&lt;br&gt;
compose stacks" is re-measured on demand rather than remembered from the day&lt;br&gt;
it was true. The same script prints the list of services that must never&lt;br&gt;
appear in anything client-facing.&lt;/p&gt;

&lt;p&gt;The rule behind it is simple. A number that cannot be re-checked becomes a&lt;br&gt;
claim nobody can defend — in an interview, in a proposal, or on a call when&lt;br&gt;
a client asks how you got it. If I cannot re-measure a figure, it does not&lt;br&gt;
go in the document.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Every configuration value in the compose file from day one&lt;/strong&gt;, including the ones that feel like secrets of the machine rather than of the service. The Passbolt relapse existed only because a fix lived in a container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert on the absence of data, not just on bad data.&lt;/strong&gt; A blank panel and a healthy service look identical on a dashboard. If nothing arrives from an exporter for ten minutes, that is the alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure availability with something built to measure availability.&lt;/strong&gt; A metrics pipeline tells you how a system is behaving; it does not count successful requests against total requests. If a percentage is going to appear in front of a client, it needs a probe checking endpoints on a schedule and recording the result — otherwise the honest answer is "monitored", not a number. That distinction matters more than the number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Three hosts, 162 containers, 78 compose stacks, one operator — with&lt;br&gt;
automated TLS, central SSO, cross-host monitoring, tested backups and&lt;br&gt;
self-hosted CI/CD. It has run client websites, a mail platform, a Telegram&lt;br&gt;
support bot, scheduling automation and this blog at the same time, and it is&lt;br&gt;
the reason I can take a project from "here is the requirement" to "it is&lt;br&gt;
live, monitored and backed up" without handing the second half to somebody&lt;br&gt;
else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want this for your business?
&lt;/h2&gt;

&lt;p&gt;If you need someone who can build the application &lt;strong&gt;and&lt;/strong&gt; run the&lt;br&gt;
infrastructure it lives on — Docker and Traefik, automated TLS, SSO,&lt;br&gt;
monitoring, mail, backups, CI/CD — I do exactly this, and I would rather&lt;br&gt;
talk about your setup than send a brochure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WhatsApp: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;wa.me/60127972969&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email: &lt;a href="mailto:me@hoelee.com?subject=Infrastructure%20setup%20enquiry"&gt;me@hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;More: &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why Chrome Forgets Its Tabs in a Container — And How I Fixed It</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Wed, 28 Oct 2026 04:25:38 +0000</pubDate>
      <link>https://dev.to/hoelee/why-chrome-forgets-its-tabs-in-a-container-and-how-i-fixed-it-23am</link>
      <guid>https://dev.to/hoelee/why-chrome-forgets-its-tabs-in-a-container-and-how-i-fixed-it-23am</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/why-chrome-forgets-its-tabs-in-a-container/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run a real Google Chrome inside a Docker container on my NAS. It is not a scraping instance and not a screenshot service — it is a browser I actually use: signed into the accounts a headless bot should never touch, watchable through a web desktop from any device, and drivable over the Chrome DevTools Protocol when I want to automate part of a job.&lt;/p&gt;

&lt;p&gt;It works well, with one exception. &lt;strong&gt;Every time the container restarts, the browser comes back with a single blank tab.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Chrome has a setting for exactly this — "Continue where you left off". It was already enabled. It did nothing. What follows is three fixes that failed, the actual root cause, the two landmines I hit on the way, and the ~70-line keeper that finally made restarts survivable.&lt;/p&gt;

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

&lt;p&gt;When a container browser is your workspace, the tabs &lt;em&gt;are&lt;/em&gt; the state. Half-finished forms, a marketplace listing in progress, a dashboard filtered to the right date range, three tabs open on a customer's infrastructure — that is the work. A browser that opens on &lt;code&gt;about:blank&lt;/code&gt; means the first ten minutes after every restart are spent rebuilding context you already had.&lt;/p&gt;

&lt;p&gt;I looked for this answer more than once and found nothing but "set &lt;code&gt;restore_on_startup&lt;/code&gt;" advice that does not survive contact with a container. If you have hit the same wall, this is the wall explained.&lt;/p&gt;

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

&lt;p&gt;A single Docker stack on a Synology NAS, two browsers in it (real Chrome and Brave), each behind my own SSO gate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lscr.io/linuxserver/chrome&lt;/code&gt; — real Google Chrome 154&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop&lt;/td&gt;
&lt;td&gt;Selkies web desktop, container &lt;code&gt;3000&lt;/code&gt;/&lt;code&gt;3001&lt;/code&gt; → host &lt;code&gt;3030&lt;/code&gt;/&lt;code&gt;3031&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automation&lt;/td&gt;
&lt;td&gt;CDP published at &lt;code&gt;http://&amp;lt;nas&amp;gt;:9232/json/version&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile&lt;/td&gt;
&lt;td&gt;a bind mount, &lt;code&gt;/config/profile&lt;/code&gt; inside the container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Launch flags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--remote-debugging-port=9222 --user-data-dir=/config/profile --restore-last-session&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The profile is a bind mount, so the logins, cookies and history genuinely do persist across a container rebuild — that part was never broken. Only the &lt;em&gt;session&lt;/em&gt; (which tabs were open) was lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: the flag
&lt;/h2&gt;

&lt;p&gt;Chromium ships a switch that sounds like the answer: &lt;code&gt;--restore-last-session&lt;/code&gt;. It is documented as "restore the last session &lt;em&gt;after an unexpected exit&lt;/em&gt;".&lt;/p&gt;

&lt;p&gt;I added it via the image's CLI env var and confirmed it was actually applied to the running process, because these images build the command line from a wrapper script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;chrome sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'tr "\0" " " &amp;lt; /proc/$(pgrep -f "google-chrome --" | head -1)/cmdline'&lt;/span&gt; | &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; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'restore|user-data-dir|remote-debug'&lt;/span&gt;
&lt;span class="c"&gt;# --remote-debugging-port=9222&lt;/span&gt;
&lt;span class="c"&gt;# --user-data-dir=/config/profile&lt;/span&gt;
&lt;span class="c"&gt;# --restore-last-session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart. Blank tab. The flag was there; it just had nothing it was willing to restore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: edit the profile's preferences
&lt;/h2&gt;

&lt;p&gt;Chrome stores "Continue where you left off" in the profile's &lt;code&gt;Preferences&lt;/code&gt; JSON as &lt;code&gt;session.restore_on_startup: 1&lt;/code&gt;. So I stopped the container and edited it directly, along with the &lt;code&gt;exit_type&lt;/code&gt; marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/config/profile/Default/Preferences&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;session&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;restore_on_startup&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# 1 = restore last session
&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;profile&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;exit_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Normal&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the classic advice you will find online, and it half-worked: after the next boot, Chrome had &lt;strong&gt;kept&lt;/strong&gt; &lt;code&gt;restore_on_startup: 1&lt;/code&gt; in the file — and still opened a blank tab. What it did not keep was my &lt;code&gt;exit_type&lt;/code&gt;: it had already rewritten that to &lt;code&gt;Crashed&lt;/code&gt; at startup.&lt;/p&gt;

&lt;p&gt;That rewrite turned out to be the first real clue. Chrome treats startup state as a security boundary — the settings a hijacker would most like to change (homepage, search provider, startup pages) are MAC-checked against a separate &lt;code&gt;Secure Preferences&lt;/code&gt; file. A value edited on disk does not automatically become a value Chrome obeys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 3: a managed policy
&lt;/h2&gt;

&lt;p&gt;The supported way to force browser behaviour is a &lt;strong&gt;managed policy&lt;/strong&gt;, which is authoritative and not subject to that MAC check. On Linux, Chrome reads &lt;code&gt;/etc/opt/chrome/policies/managed/*.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"RestoreOnStartup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details before you bother: Chrome ignores a policy file that is writable by the user running the browser, so it needs &lt;code&gt;root:root&lt;/code&gt; and mode &lt;code&gt;644&lt;/code&gt; — and on a Synology share, &lt;code&gt;chmod&lt;/code&gt; refuses to run even under &lt;code&gt;sudo&lt;/code&gt; for some directories, so verify the mode you already have instead of assuming you changed it.&lt;/p&gt;

&lt;p&gt;Mounted into the container, verified present after boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;chrome sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ls -l /etc/opt/chrome/policies/managed/; cat /etc/opt/chrome/policies/managed/hoeleepolicy.json'&lt;/span&gt;
&lt;span class="c"&gt;# -rw-r--r-- 1 root root 28 hoeleepolicy.json&lt;/span&gt;
&lt;span class="c"&gt;# { "RestoreOnStartup": 1 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart. Blank tab. Still.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 4: prove the exit is ungraceful
&lt;/h2&gt;

&lt;p&gt;At this point the question stopped being "which setting do I need" and became "what does this browser actually see when the container stops".&lt;/p&gt;

&lt;p&gt;Chrome's restore machinery needs a session it considers &lt;em&gt;interrupted or resumable&lt;/em&gt;. It rolls &lt;code&gt;Current Session&lt;/code&gt; into &lt;code&gt;Last Session&lt;/code&gt; on a clean exit. I asked the CDP endpoint to close the browser gracefully instead of pulling the container out from under it:&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="c1"&gt;# browser-level target from /json/version
&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Browser.close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome exited — and the desktop session relaunched it within seconds. &lt;code&gt;exit_type&lt;/code&gt; was back to &lt;code&gt;Crashed&lt;/code&gt; immediately.&lt;/p&gt;

&lt;p&gt;That is the whole trick, and it explains all three failures: &lt;strong&gt;the browser never exits cleanly.&lt;/strong&gt; When you stop the container, the desktop session is torn down and the browser is killed with it. No clean exit means no &lt;code&gt;Last Session&lt;/code&gt; rollover, which means there is nothing for "continue where you left off" — or a &lt;code&gt;RestoreOnStartup&lt;/code&gt; policy — to restore. The flag, the preference and the policy were all working correctly and all had nothing to work with.&lt;/p&gt;

&lt;p&gt;The data was never missing. The profile's session directory was being written continuously, seconds apart, the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-rw------- 1 hoelee users 40722 Session_13434833255312492
-rw------- 1 hoelee users 58879 Tabs_13434833757632480
-rw------- 1 hoelee users 79361 Tabs_13434833833646133
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix: stop configuring, start keeping
&lt;/h2&gt;

&lt;p&gt;If the browser cannot be trusted to remember its own session, the container can remember it instead. Two jobs, both tiny:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On start&lt;/strong&gt;: wait for the browser, and if it came up with no real page open, re-open the URLs it had last time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forever after&lt;/strong&gt;: every 60 seconds, snapshot the open page URLs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a background script launched from the image's container-init hook. The version I run, trimmed to the useful parts:&lt;/p&gt;

&lt;p&gt;tabs_keeper.py — Tab restore &amp;amp; snapshot loop (42 lines)&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Restore last session&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s tabs on start, then snapshot open tabs every 60s.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;CDP&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:9222&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# loopback inside the container
&lt;/span&gt;&lt;span class="n"&gt;STORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/config/tabs-last.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# lives in the persistent profile mount
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_tabs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;http(s) page URLs, or None when the browser isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t answering yet.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/json/list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;tabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tabs&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STORE&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STORE&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="c1"&gt;# 1) restore — wait up to 3 minutes for the browser to come up
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;open_tabs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;open_tabs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;                      &lt;span class="c1"&gt;# nothing but a blank new tab
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;CDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/json/new?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2) snapshot — write even an empty list, so "I closed everything" is respected
&lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open_tabs&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;tabs&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;STORE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tmp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tabs&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STORE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# atomic; a kill can't truncate it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Two design choices worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restore only when the browser came up empty.&lt;/strong&gt; If a future Chrome version &lt;em&gt;does&lt;/em&gt; restore its own tabs, the keeper stays out of the way instead of duplicating every tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The snapshot is atomic and may be empty.&lt;/strong&gt; Writing through a temp file plus &lt;code&gt;os.replace&lt;/code&gt; means a container killed mid-write cannot leave a truncated list, and an empty snapshot is meaningful data: it means you closed everything on purpose, so the next start should stay empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The container-init hook that runs 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="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# clear stale singleton state (see below)&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /config/profile/Singleton&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="c"&gt;# long-running, so background it or you block container init&lt;/span&gt;
/usr/bin/env python3 /custom-cont-init.d/tabs_keeper.py &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /config/tabs-keeper.log 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Landmine 1: a stale &lt;code&gt;Singleton*&lt;/code&gt; stops Chrome from starting at all
&lt;/h3&gt;

&lt;p&gt;While testing restarts I hit a worse failure than a blank tab: the container came up &lt;strong&gt;healthy with no browser in it&lt;/strong&gt;. No &lt;code&gt;google-chrome&lt;/code&gt; process, CDP dead, the relay's healthcheck reporting &lt;code&gt;unhealthy&lt;/code&gt;, nothing in the logs beyond the desktop starting.&lt;/p&gt;

&lt;p&gt;The cause is the profile's &lt;code&gt;SingletonLock&lt;/code&gt;/&lt;code&gt;SingletonSocket&lt;/code&gt;/&lt;code&gt;SingletonCookie&lt;/code&gt; symlinks. They encode "this profile is owned by PID &lt;em&gt;n&lt;/em&gt; on host &lt;em&gt;h&lt;/em&gt;" — and inside a container, PIDs are reused across restarts, so the next boot can find its own PID already claiming the profile and exit immediately. Deleting them at container start, before the browser launches, is the fix. It is also why that &lt;code&gt;rm -f&lt;/code&gt; sits in the hook above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Landmine 2: &lt;code&gt;custom-cont-init.d&lt;/code&gt; only runs &lt;em&gt;executable&lt;/em&gt; files — and runs them with bash
&lt;/h3&gt;

&lt;p&gt;The LinuxServer images execute every file in &lt;code&gt;/custom-cont-init.d&lt;/code&gt;, but only if it is executable, and each one through &lt;code&gt;/bin/bash&lt;/code&gt;. Which means a Python file you drop in there must &lt;strong&gt;not&lt;/strong&gt; be executable, or it gets fed to bash and exits 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[custom-init] tabs_keeper.py: executing...
[custom-init] tabs_keeper.py: exited 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The split that works: a &lt;code&gt;.sh&lt;/code&gt; hook at &lt;code&gt;755&lt;/code&gt; that backgrounds the long-running work, and the &lt;code&gt;.py&lt;/code&gt; next to it at &lt;code&gt;644&lt;/code&gt;. And anything that does not return immediately has to be backgrounded, or container init waits on it forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plumbing trap that came with it
&lt;/h2&gt;

&lt;p&gt;The keeper talks to Chrome over CDP on loopback, which is why it needs no ports. If you also want to drive the browser from another machine, there is one more container in the picture, because Chrome ≥ 136 pins its devtools endpoint to &lt;code&gt;127.0.0.1:9222&lt;/code&gt; and ignores &lt;code&gt;--remote-debugging-address&lt;/code&gt;. The standard workaround is a &lt;code&gt;socat&lt;/code&gt; sidecar sharing the browser's network namespace:&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;chrome-relay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alpine/socat&lt;/span&gt;
  &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service:chrome&lt;/span&gt;            &lt;span class="c1"&gt;# must share the browser's netns&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222&lt;/span&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;nc&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-z&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9222&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port mapping does &lt;strong&gt;not&lt;/strong&gt; go on the relay (it has no namespace of its own) — it goes on the browser service: &lt;code&gt;9232:9223&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That sidecar has one behaviour you have to internalise: &lt;strong&gt;restart the browser and the relay dies with it.&lt;/strong&gt; The namespace it was living in is rebuilt underneath it, socat keeps running and its sockets stop working, so CDP answers with &lt;code&gt;connection reset&lt;/code&gt; while the container still looks fine. Fix the symptoms in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker restart chrome &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker restart chrome-relay   &lt;span class="c"&gt;# order matters&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The healthcheck above is what turns a silent failure into a visible &lt;code&gt;unhealthy&lt;/code&gt; flag. If you want to drive a container browser yourself, I wrote the client side up separately in &lt;a href="https://blog.hoelee.com/posts/scraping-bot-walled-marketplace-warm-browser-session/" rel="noopener noreferrer"&gt;Scraping a Bot-Walled Marketplace With a Warm Browser Session&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Determine whether the process can exit cleanly before trusting any "restore on startup" mechanism.&lt;/strong&gt; I spent three attempts configuring a feature whose precondition — an exit the browser notices — was never met. One &lt;code&gt;Browser.close&lt;/code&gt; test would have collapsed the whole search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't hand-edit browser preferences.&lt;/strong&gt; They are MAC-checked against &lt;code&gt;Secure Preferences&lt;/code&gt;; a plausible-looking value on disk is not a value Chrome obeys. If you need a switch to be authoritative, use a managed policy — and verify the policy is loaded instead of assuming the file is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check who is allowed to write the policy.&lt;/strong&gt; A root-owned &lt;code&gt;644&lt;/code&gt; file in &lt;code&gt;/etc/opt/chrome/policies/managed&lt;/code&gt; is the shape; a user-writable one is ignored silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "the browser is running" and "the container is running" as different questions.&lt;/strong&gt; The stale-singleton failure looked like a healthy container from the outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot state instead of asking software to remember it.&lt;/strong&gt; Anything with a hard kill in its shutdown path — containers, kiosks, desktop sessions — is a poor place to rely on clean-exit bookkeeping. A 60-second atomic snapshot is boring, and it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the empty-snapshot case.&lt;/strong&gt; Because step 2 writes even an empty list (that is how "I closed everything" gets respected), a browser that dies mid-session and gets relaunched blank by the desktop supervisor will have its empty tab state snapshotted 60 seconds later, erasing the last good file. The cheap guard is to record the browser's &lt;code&gt;webSocketDebuggerUrl&lt;/code&gt; from &lt;code&gt;/json/version&lt;/code&gt; and only overwrite the snapshot when it is the same instance you started.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tabs restored &lt;strong&gt;2/2 on both browsers&lt;/strong&gt; across a real container restart (&lt;code&gt;restored 2/2 tabs&lt;/code&gt; in the keeper log), verified over CDP afterwards&lt;/li&gt;
&lt;li&gt;Logins, cookies and &lt;code&gt;localStorage&lt;/code&gt; confirmed surviving the same restarts — the persistent profile was never the problem&lt;/li&gt;
&lt;li&gt;Three stacks (two browsers plus a throwaway experiment) collapsed into one, with the relay's healthcheck making the one restart-order trap visible instead of silent&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Want this for your business?
&lt;/h2&gt;

&lt;p&gt;If you want a browser that stays logged in — running on your own hardware, reachable from anywhere, behind SSO, with tab state that survives restarts and a CDP endpoint your scripts can drive — that is the setup I run and maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WhatsApp: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;Email: &lt;a href="mailto:me@hoelee.com?subject=Self-hosted%20container%20browser"&gt;me@hoelee.com&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Website design and development is my main line of work; self-hosted infrastructure and automation is the other half of it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How do you manage persistent browser user-data-dir volumes and state across container restarts without profile lock corruption?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/why-your-cdp-clicks-silently-fail-smooth-scroll-minimised-windows-and-js-dispatched-events-5o"&gt;Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/two-ways-a-javascript-file-stops-working-and-looks-fine-16ch"&gt;Two Ways a JavaScript File Stops Working and Looks Fine&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>browser</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
    <item>
      <title>Calibrating Intel Quick Sync HEVC: A Cliff at 28, and a Preset That Does Nothing</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Tue, 27 Oct 2026 04:26:26 +0000</pubDate>
      <link>https://dev.to/hoelee/calibrating-intel-quick-sync-hevc-a-cliff-at-28-and-a-preset-that-does-nothing-19k0</link>
      <guid>https://dev.to/hoelee/calibrating-intel-quick-sync-hevc-a-cliff-at-28-and-a-preset-that-does-nothing-19k0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/calibrating-intel-quick-sync-hevc-quality/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I could not answer: which number do I ship?
&lt;/h2&gt;

&lt;p&gt;My transcoding queue decides, per file, which encoder does the work: &lt;code&gt;copy&lt;/code&gt;&lt;br&gt;
when the source is already efficient, NVENC on the GPU, x265 on the CPU —&lt;br&gt;
and now Intel Quick Sync. Each tier has two knobs that decide quality, and&lt;br&gt;
every tier except QSV had numbers I had measured on my own library.&lt;/p&gt;

&lt;p&gt;QSV had none. And the code refused to guess: a shipping default of&lt;br&gt;
&lt;code&gt;qsv_quality = 0&lt;/code&gt; / &lt;code&gt;qsv_preset = ""&lt;/code&gt; means &lt;em&gt;nobody has calibrated this&lt;/em&gt;,&lt;br&gt;
and the engine treats that as a hard exclusion — QSV can exist in the code&lt;br&gt;
and never be selected on any machine, including the one that has the&lt;br&gt;
hardware.&lt;/p&gt;

&lt;p&gt;So the task was not "turn on QSV". It was "produce measured numbers for two&lt;br&gt;
knobs, so the encoder becomes selectable honestly".&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem one: the hardware is not in the machine where the app runs
&lt;/h2&gt;

&lt;p&gt;The queue runs on Windows 11 in a VM. That VM has an RTX 3060 passed through&lt;br&gt;
and no Intel graphics of any kind — which is exactly why QSV had never been&lt;br&gt;
measured: the obvious move is to run one test locally and read the numbers&lt;br&gt;
off.&lt;/p&gt;

&lt;p&gt;The iGPU is on the host instead (unRaid, i9-13900K), idle and unclaimed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00:02.0 VGA compatible controller: Intel Raptor Lake-S GT1 [UHD Graphics 770]
  driver: i915            refcount: 0
/dev/dri/card0  /dev/dri/renderD128        (present, world-accessible)
no running container declares a dri device
virsh dumpxml Win11: hostdevs = 01:00.0, 01:00.1 (the 3060 + its HDMI audio)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two conclusions, both useful. The VM's &lt;code&gt;Error creating a MFX session&lt;/code&gt; is&lt;br&gt;
&lt;strong&gt;expected&lt;/strong&gt; and not a bug to chase — the VM can never see that iGPU. And&lt;br&gt;
the only way to measure QSV is on the Linux side, in a container, on the&lt;br&gt;
host.&lt;/p&gt;
&lt;h2&gt;
  
  
  Measure with the engine's own gate, not with ffmpeg
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the numbers worth anything. I did not write&lt;br&gt;
ffmpeg commands and compute SSIM myself — the calibration runs &lt;strong&gt;the engine&lt;br&gt;
that will do the work&lt;/strong&gt;, with its own verification and its own accept/reject&lt;br&gt;
gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 library_run.py &lt;span class="nt"&gt;--work-dir&lt;/span&gt; /work &lt;span class="nt"&gt;--tier&lt;/span&gt; qsv &lt;span class="nt"&gt;--force-tier&lt;/span&gt; qsv &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--qsv-quality&lt;/span&gt; Q &lt;span class="nt"&gt;--qsv-preset&lt;/span&gt; P &lt;span class="nt"&gt;--path&lt;/span&gt; &amp;lt;real &lt;span class="nb"&gt;source&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--keep-local&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--keep-local&lt;/code&gt; means encode, verify, run the gate, and then &lt;em&gt;do not&lt;/em&gt; land&lt;br&gt;
the file — the verdict is what I am collecting, not a new library. Sources&lt;br&gt;
came in read-only over an existing SMB mount, and I hashed each source&lt;br&gt;
before and after: unchanged.&lt;/p&gt;

&lt;p&gt;The image is &lt;code&gt;linuxserver/ffmpeg&lt;/code&gt; (ffmpeg 9.0.2, iHD 26.3.5, oneVPL 2.17)&lt;br&gt;
plus &lt;code&gt;python3&lt;/code&gt; — plus &lt;strong&gt;numpy&lt;/strong&gt;, which is not optional. Without it the gate&lt;br&gt;
prints &lt;code&gt;flat-frame scan skipped: No module named 'numpy'&lt;/code&gt; and quietly&lt;br&gt;
changes how near-flat frames are judged, which changes verdicts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-global_quality&lt;/code&gt; maps to &lt;code&gt;RateControlMethod: ICQ&lt;/code&gt; on &lt;code&gt;hevc_qsv&lt;/code&gt;, which is&lt;br&gt;
the knob being calibrated.&lt;/p&gt;
&lt;h2&gt;
  
  
  The ladder: on a thin source, low ICQ makes the file &lt;em&gt;bigger&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;First run, on a real 1080p25 source at 951 kbps (0.0184 bits per pixel — a&lt;br&gt;
"thin" file, the kind that is already squeezed):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ICQ&lt;/th&gt;
&lt;th&gt;engine verdict&lt;/th&gt;
&lt;th&gt;mean SSIM&lt;/th&gt;
&lt;th&gt;video = source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;FAIL size&lt;/strong&gt; — 176.8%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;FAIL size&lt;/strong&gt; — 141.8%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;FAIL size&lt;/strong&gt; — 107.6%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;0.9967&lt;/td&gt;
&lt;td&gt;81.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;0.9959&lt;/td&gt;
&lt;td&gt;61.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;0.9951&lt;/td&gt;
&lt;td&gt;46.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL quality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9942&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL quality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9942&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things fall out of that table.&lt;/p&gt;

&lt;p&gt;The counterintuitive one: &lt;strong&gt;low ICQ does not mean safe.&lt;/strong&gt; ICQ 18–22 produce&lt;br&gt;
files &lt;em&gt;larger&lt;/em&gt; than the source, so the engine's size ceiling (100% of the&lt;br&gt;
source's video bitrate) rejects them. QSV is not alone in this — NVENC at a&lt;br&gt;
low &lt;code&gt;cq&lt;/code&gt; does the same thing on thin inputs — but the boundary sits higher&lt;br&gt;
here: the usable range starts at 24, not 18.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;there is a cliff between 28 and 30.&lt;/strong&gt; 30 and 32 fail on quality,&lt;br&gt;
and they fail on the &lt;em&gt;first attempt&lt;/em&gt;. That phrase matters, because the&lt;br&gt;
engine retries a failed quality attempt at &lt;code&gt;global_quality - 2&lt;/code&gt; — lower&lt;br&gt;
ICQ, higher quality — and logs the retry. A line reading "ok at quality+2"&lt;br&gt;
is a &lt;strong&gt;first-attempt failure&lt;/strong&gt;, not a pass. I read that line backwards in my&lt;br&gt;
own notes the first time; the rule is to read the first attempt, not the&lt;br&gt;
recovery.&lt;/p&gt;

&lt;p&gt;Third, the floor is where the choice gets made: with a &lt;strong&gt;0.995 mean-SSIM&lt;br&gt;
floor&lt;/strong&gt;, q28 passes with 0.0001 of margin and q26 with 0.0010.&lt;/p&gt;
&lt;h2&gt;
  
  
  Full files confirm the ladder — and one shortcut survives
&lt;/h2&gt;

&lt;p&gt;Excerpts (180-second lossless slices) are how you rank ten candidates&lt;br&gt;
without ten full encodes. They are only legitimate if they agree with&lt;br&gt;
full-file runs, so I ran the real files end to end:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;ICQ&lt;/th&gt;
&lt;th&gt;preset&lt;/th&gt;
&lt;th&gt;video = source&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;SSIM min / mean&lt;/th&gt;
&lt;th&gt;encode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;254 MB, 2023 s, 872 kbps&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;168,435,266 B (66%)&lt;/td&gt;
&lt;td&gt;0.9938 / &lt;strong&gt;0.9960&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;360 s ≈ 5.6× realtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same file&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;46.3%&lt;/td&gt;
&lt;td&gt;54%&lt;/td&gt;
&lt;td&gt;0.9927 / 0.9952&lt;/td&gt;
&lt;td&gt;330 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;315 MB, 3280 s, 546 kbps&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;50.4%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;0.9965 / &lt;strong&gt;0.9977&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;600 s ≈ 5.5× realtime&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 180-second excerpt of the first file measured mean 0.9959; the full file&lt;br&gt;
measured 0.9960. That is close enough to trust the shortcut &lt;strong&gt;for quality&lt;br&gt;
ranking&lt;/strong&gt; — and I now have the evidence to say so rather than hope so.&lt;/p&gt;
&lt;h2&gt;
  
  
  The preset that does nothing
&lt;/h2&gt;

&lt;p&gt;The excerpt runs said preset only changes speed: veryfast 30 s / 61.6%, fast&lt;br&gt;
30 s / 61.0%, medium 60 s / 61.0%, slow and veryslow 60 s. Clean story: pick&lt;br&gt;
veryfast, save 30 seconds per file.&lt;/p&gt;

&lt;p&gt;Then the full files disagreed with it. The same source at ICQ 26, one run&lt;br&gt;
with &lt;code&gt;fast&lt;/code&gt; and one with &lt;code&gt;medium&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;fast:    168,435,266 B   enc 360 s   mean SSIM 0.9960
medium:  168,435,266 B   enc 360 s   mean SSIM 0.9960
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Byte-for-byte identical, same encode time, same quality. The 30-vs-60-second&lt;br&gt;
difference in the excerpt layer was per-run fixed overhead, not encoding&lt;br&gt;
speed — and it is the kind of number that would have gone into a config file&lt;br&gt;
and a blog post if I had stopped one step early.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;excerpts rank quality, never speed.&lt;/strong&gt; Speed has to be measured on full&lt;br&gt;
files, where the fixed overhead disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;qsv_quality = 26&lt;/code&gt;, &lt;code&gt;qsv_preset = medium&lt;/code&gt; — 61.0% of the source's video&lt;br&gt;
bitrate at mean SSIM 0.9960 (min 0.9938) at roughly 5.6× realtime, and 50.4%&lt;br&gt;
at mean 0.9977 on the thinnest file in the library. q28's margin over the&lt;br&gt;
floor is 0.0002, and the cost of a quality failure is two full encodes plus&lt;br&gt;
a park, so the extra 15 percentage points of savings are not worth the tail&lt;br&gt;
risk.&lt;/p&gt;

&lt;p&gt;Two supporting measurements worth recording, because they answer "is it QSV&lt;br&gt;
or is it the file?":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder control.&lt;/strong&gt; One 720p source with non-uniform cadence scored
QSV q24 mean 0.9154, QSV q28 mean 0.9146, and x265 crf26 mean 0.9164 — and
the engine's alignment search reported
&lt;code&gt;no alignment explains the mismatch&lt;/code&gt;. The rejection belongs to the source,
not to Quick Sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency.&lt;/strong&gt; The engine refuses a second encoder on a shared work
root:
&lt;code&gt;another transcode is already running (pid 7, /work/run-qsv.lock, 3 s old)&lt;/code&gt;.
With separate work roots, two jobs run — and take 201 s where one takes
107 s. Twice the wall clock, zero throughput gain. One QSV job at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Four traps, each of which cost a cycle
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never mount a shared library at &lt;code&gt;/lib&lt;/code&gt;.&lt;/strong&gt; I mounted the SMB share into
the container at &lt;code&gt;/lib&lt;/code&gt; — which is where the container's own dynamic
loader lives. All eight jobs died in under a second with
&lt;code&gt;exec /bin/bash: no such file or directory&lt;/code&gt;, which reads like a broken
image, not a bad mount. &lt;code&gt;/library&lt;/code&gt; works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic sources lie about ICQ.&lt;/strong&gt; &lt;code&gt;hevc_qsv&lt;/code&gt;'s rate control is
rejected by the driver for &lt;code&gt;rgb24&lt;/code&gt; input
(&lt;code&gt;Selected ratecontrol mode is unsupported&lt;/code&gt;) — so a
&lt;code&gt;testsrc&lt;/code&gt;/&lt;code&gt;mandelbrot&lt;/code&gt; self-test concludes the opposite of the truth. Use
a real file, or add &lt;code&gt;format=nv12&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already-HEVC sources cannot be calibrated at all.&lt;/strong&gt; The gate answers
&lt;code&gt;SKIP already HEVC hvc1&lt;/code&gt; and &lt;code&gt;--overwrite&lt;/code&gt; does not lift it. The fat
1080p H.264 originals in this library had all been converted long ago;
the remaining fat files are HEVC — so that tier has nothing left to
measure on. That is a missing input, not a skipped task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the first attempt, not the retry&lt;/strong&gt; (see the cliff above).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure speed on the artifact you will actually run&lt;/strong&gt;, not on a slice
of it. My one wrong number in this whole exercise came from trusting an
excerpt's wall clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the machine prove it has the hardware before you plan the test.&lt;/strong&gt;
Six minutes of &lt;code&gt;lspci&lt;/code&gt; and &lt;code&gt;virsh dumpxml&lt;/code&gt; retired the "why does QSV fail
in the VM" question that had been open for weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the gate in the loop.&lt;/strong&gt; Because the engine's own verification
judged every run, the tables above are decisions, not opinions — the same
gate that will reject a bad file in production rejected my ICQ 30.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the honest scope line into the notes.&lt;/strong&gt; These numbers are Linux
iHD 26.3.5 + oneVPL 2.17 on a UHD 770; the production target is the
Windows MSDK path on the same silicon, and I cannot claim they agree.
What this establishes is that the command runs on real hardware, where
the quality↔size curve sits, and how the lock behaves — leaving a
re-measurement on Windows+Intel as one table, instead of another
afternoon.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;QSV went from "present in the code and never selectable" to a calibrated&lt;br&gt;
tier with published numbers, and the safety rule changed with it: the test&lt;br&gt;
that used to pin &lt;em&gt;shipping defaults must mean uncalibrated&lt;/em&gt; now pins&lt;br&gt;
&lt;em&gt;shipping defaults must be measured values&lt;/em&gt;, while keeping every assertion&lt;br&gt;
that an empty value is a hard exclusion. The doctor output on a machine with&lt;br&gt;
no Intel GPU moved from an informational "waiting for hardware" line to a&lt;br&gt;
warning — which is its own small lesson about what a calibrated encoder&lt;br&gt;
failing self-check should look like, and why hardware the machine does not&lt;br&gt;
have belongs in a summary row rather than a warning.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need Resilient Hardware Video Pipelines &amp;amp; Automated Quality Gates?
&lt;/h2&gt;

&lt;p&gt;If you manage GPU/iGPU transcoding fleets (NVENC, Quick Sync, VAAPI) or need strict frame-level quality assertions (SSIM/VMAF) to keep defective encodes out of production libraries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp:&lt;/strong&gt; &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; &lt;a href="mailto:me@hoelee.com?subject=Media%20pipeline%20%26%20transcoding%20consulting"&gt;me@hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;For video pipeline engineers: do you rely on hardware QSV/NVENC for bulk storage transcode or bite the CPU bullet for x265 crf?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/docker-reported-exit-0-the-kernel-had-already-oom-killed-it-2ojp"&gt;Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/one-prometheus-for-unraid-a-synology-nas-and-a-vps-what-i-got-wrong-2g4g"&gt;One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tutorial</category>
      <category>performance</category>
      <category>python</category>
      <category>linux</category>
    </item>
    <item>
      <title>No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Mon, 26 Oct 2026 04:26:02 +0000</pubDate>
      <link>https://dev.to/hoelee/no-api-for-browser-translation-adding-an-english-mode-to-a-chinese-only-web-app-4bd0</link>
      <guid>https://dev.to/hoelee/no-api-for-browser-translation-adding-an-english-mode-to-a-chinese-only-web-app-4bd0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/adding-english-mode-to-a-chinese-only-web-app/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Can I trigger the browser's translate feature from JavaScript? No. That answer cost me an afternoon to accept — but the more interesting discovery came first: my app was already sabotaging the browser's translation, and it did it with a single attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was working on
&lt;/h2&gt;

&lt;p&gt;I run a private, invite-only reading platform at &lt;code&gt;book.hoelee.com&lt;/code&gt;: a self-hosted reader (Kotlin/Spring backend, Vue single-page app) holding my library, with accounts for invited readers. The interface is Chinese-only. Not "Chinese-first" — &lt;em&gt;only&lt;/em&gt;. I grepped every served bundle before believing it: no i18n library, no locale files, no language switcher. Just a Simplified/Traditional character converter and a list of English TTS voice names.&lt;/p&gt;

&lt;p&gt;Then I started handing out invite cards to English-speaking readers. They could register, and then they met 书架, 浏览书仓, 确定, 设置 — every control in a language they don't read. That isn't a polish problem. For half the people I invited, the product was unusable.&lt;/p&gt;

&lt;p&gt;One constraint shaped everything that follows: &lt;strong&gt;I don't modify the app.&lt;/strong&gt; Its upstream repository is archived and the UI is a minified Vue bundle, so anything I patch inside the image dies the next time I swap the image. Whatever I build has to live one layer out — in the nginx gateway I already run in front of the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the app declares the wrong language, and that disables translation
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I looked at what the browser sees:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://book.hoelee.com/index.html | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;html[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
&amp;lt;html &lt;span class="nv"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shell says English. The interface is 100% Chinese.&lt;/p&gt;

&lt;p&gt;That matters more than it looks. Chrome, Edge and Safari decide whether to &lt;em&gt;offer&lt;/em&gt; translation largely from the page's declared language. A page that declares English while rendering Chinese text doesn't get the "Translate this page?" prompt — the single most useful accessibility feature for my new readers was silently switched off, and right-click → &lt;em&gt;Translate to English&lt;/em&gt; is a ritual most people never learn.&lt;/p&gt;

&lt;p&gt;The fix is one line, applied to the response on the way out instead of inside the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# sub_filter cannot rewrite a gzipped body -&amp;gt; ask upstream for plain HTML&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Accept-Encoding&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;sub_filter_once&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# the app declares lang="en" while its UI is Chinese -&amp;gt; browsers then never&lt;/span&gt;
    &lt;span class="c1"&gt;# offer Chinese -&amp;gt; English translation. Declare the truth.&lt;/span&gt;
    &lt;span class="kn"&gt;sub_filter&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;html&lt;/span&gt; &lt;span class="s"&gt;lang="en"&amp;gt;'&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;html&lt;/span&gt; &lt;span class="s"&gt;lang="zh-CN"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://hectorqin-reader:8080&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;Verify the same way you found it: &lt;code&gt;curl -s https://book.hoelee.com/index.html | grep -o '&amp;lt;html lang="[^"]*"'&lt;/code&gt; → &lt;code&gt;&amp;lt;html lang="zh-CN"&lt;/code&gt;. The declared language is what the translate prompt keys off, so with the page finally declaring Chinese the offer can fire correctly. (The exact prompt still depends on each browser and its language settings — what I verified on the wire is the attribute itself.) Because the rewrite happens at the proxy, it survives every app update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: there is no browser-translate API
&lt;/h2&gt;

&lt;p&gt;With the page honest again, the obvious next question was whether I could &lt;em&gt;offer&lt;/em&gt; translation myself — a button that does what the browser's menu item does. There is no such API. Browser translation is a UI-level feature; a page cannot invoke it, and no web-exposed surface exists to script it.&lt;/p&gt;

&lt;p&gt;So I worked through the alternatives and rejected them one by one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Why I dropped it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rewrite the app's JS bundles to swap strings&lt;/td&gt;
&lt;td&gt;Couples me to minified internals; the next image breaks it. Also violates the no-modify rule in spirit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy the site through &lt;code&gt;translate.goog&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;It's a &lt;strong&gt;different origin&lt;/strong&gt;. The app keeps its auth token in &lt;code&gt;localStorage&lt;/code&gt;, which is per-origin — readers would land logged out, and the service worker/PWA breaks. Every API call would also round-trip through Google.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tell readers to right-click → Translate&lt;/td&gt;
&lt;td&gt;Works for &lt;em&gt;book text&lt;/em&gt;, but it's a per-visit, per-browser ritual that does nothing for someone who doesn't know it exists. Not good enough for a product where I control the whole stack.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fork the app and add i18n properly&lt;/td&gt;
&lt;td&gt;Weeks of work on an archived codebase, for an interface I'd have to re-translate on every upstream change.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What's left is the unglamorous one: translate the interface myself, in the browser, with a dictionary — and wrap it in a button that only exists inside the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a dictionary and a button, injected at the gateway
&lt;/h2&gt;

&lt;p&gt;The gateway already serves my front page, so it can serve one more static file and inject a script tag into the app shell only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/gate-en.js&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=300"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# ... the lang rewrite above ...&lt;/span&gt;
    &lt;span class="kn"&gt;sub_filter&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;/head&amp;gt;'&lt;/span&gt; &lt;span class="s"&gt;'&amp;lt;script&lt;/span&gt; &lt;span class="s"&gt;src="/gate-en.js"&lt;/span&gt; &lt;span class="s"&gt;defer&amp;gt;&amp;lt;/script&amp;gt;&amp;lt;/head&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://hectorqin-reader:8080&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;That's the whole deployment: a 40 KB script, loaded only on &lt;code&gt;/index.html&lt;/code&gt;, so the button exists inside the app and nowhere else. The script does three things.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Harvest the vocabulary from the app's own bundles
&lt;/h3&gt;

&lt;p&gt;The UI strings are already in the minified JavaScript as quoted literals. Extract every one that contains a CJK character and count them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;
&lt;span class="n"&gt;CJK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[\u4e00-\u9fff]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;bundle&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bundles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\\\n]{2,40})&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bundle&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;CJK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 840 unique strings, most frequent first
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;840 unique strings came out: menus (书架, 书源管理), confirmations (确认要删除所选择的书籍吗?), settings labels (段落行距), error messages (本地书籍源文件不存在). The dictionary ended up at &lt;strong&gt;871 entries&lt;/strong&gt; — the harvest plus everything I found by running the app in English mode and watching what stayed Chinese.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Match like the DOM actually is, not like you wish it were
&lt;/h3&gt;

&lt;p&gt;Naive substring replacement mangles Chinese compounds: 书架 sits inside 加入书架, and single characters are worse (页 inside 页面). Three rules fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whole-label match first, on whitespace-stripped text.&lt;/strong&gt; The app stores labels like &lt;code&gt;" 书架 "&lt;/code&gt; with padding, so a dictionary key of &lt;code&gt;设置&lt;/code&gt; has to match &lt;code&gt;" 设置 "&lt;/code&gt; in the DOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then longest-first substring replacement&lt;/strong&gt;, restricted to keys of two or more characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-character keys are whole-label only&lt;/strong&gt; (章, 页, 条), so they can never corrupt a longer word.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;CJK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EXACT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s\u&lt;/span&gt;&lt;span class="sr"&gt;3000&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;   &lt;span class="c1"&gt;// whole label, whitespace-normalised&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;keepWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// KEYS: longest-first, length &amp;gt;= 2&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DICT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// tidy count phrases: 共58个可用书源 -&amp;gt; "58 usable sources"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/共&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*个/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d)([&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1 $2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Keep up with the framework
&lt;/h3&gt;

&lt;p&gt;It's a Vue app, so the DOM is rewritten constantly — a translation pass that runs once is a translation pass that's half wrong a second later. A &lt;code&gt;MutationObserver&lt;/code&gt; with a 150 ms debounce re-runs the walk after any change.&lt;/p&gt;

&lt;p&gt;The pass is idempotent: once a label is English it contains no CJK, so nothing is rewritten and there is no write loop. Text nodes plus &lt;code&gt;placeholder&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;alt&lt;/code&gt; and input &lt;code&gt;value&lt;/code&gt; attributes all get translated, which is why tooltips and the search box come along too.&lt;/p&gt;

&lt;p&gt;The UI is a small pill in the bottom-right corner: &lt;code&gt;EN&lt;/code&gt; when off, &lt;code&gt;中文&lt;/code&gt; when on, remembered in &lt;code&gt;localStorage&lt;/code&gt;, with a "Hide this button" link for readers who prefer their own translator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not translate
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Book text.&lt;/strong&gt; The reader renders EPUB content inside an iframe; my walker skips iframes by design, and I would never machine-swap a novel's prose anyway.&lt;/p&gt;

&lt;p&gt;Also left alone: book titles and authors, book-source names, and the group names I created myself (梯子, 精品, 正版), plus the app's poem tagline. Those are &lt;em&gt;data&lt;/em&gt;, not interface. Translating the chrome is a courtesy; silently rewriting a user's own content is a different and much worse idea.&lt;/p&gt;

&lt;p&gt;For book text the browser's own translation is still the right tool — and it now actually gets offered, thanks to Finding 1. The button's popup says exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure it, don't eyeball it
&lt;/h2&gt;

&lt;p&gt;"Screenshots look mostly English" is not a result. I counted CJK-containing text nodes in the live DOM before and after toggling the button:&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;let&lt;/span&gt; &lt;span class="nx"&gt;cjk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&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;// walker skips the injected UI + script/style&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;walker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nextNode&lt;/span&gt;&lt;span class="p"&gt;()))&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;total&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="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;4e00-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;9fff&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;cjk&lt;/span&gt;&lt;span class="o"&gt;++&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Screen&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Translated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login / register dialog&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bookshelf + settings&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;77%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading page chrome&lt;/td&gt;
&lt;td&gt;214&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything still counted as Chinese on those screens is data — titles, authors, source names, my own group names, the tagline — which is exactly what should stay Chinese.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't rewrite an 871-entry dictionary by hand.&lt;/strong&gt; I restructured mine once and silently dropped 25 keys. The symptom looked like a logic bug: 设置 stayed Chinese while every sibling label translated correctly. The matcher was fine — the &lt;em&gt;entry&lt;/em&gt; was gone. Diff the key sets programmatically before you trust a rewrite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"print(sorted(set(old_keys) - set(new_keys)))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Keep automated UI checks short.&lt;/strong&gt; My headless runner hard-times-out at 60 seconds per call. A six-book loop came back HTTP 408 with an empty body, and my JSON parser reported a parse error rather than a timeout — I debugged the wrong thing for a while.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learn the SPA's real routes before debugging it.&lt;/strong&gt; &lt;code&gt;#/reader/1&lt;/code&gt; renders a blank shell and makes you think the app is broken; the actual route is &lt;code&gt;#/reader?bookUrl=&amp;lt;urlencoded&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mind your own bounce logic.&lt;/strong&gt; My gateway sends any reload back to the front page unless a &lt;code&gt;sessionStorage&lt;/code&gt; pass flag is set. Switching the interface &lt;em&gt;back&lt;/em&gt; to Chinese reloads the page — so the button sets that same flag first, or the reader gets thrown out of the app for changing a language setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;An English-only reader now opens the platform, taps one button, and reads the interface in English. The app itself is untouched: the whole feature is a 40 KB script served by a sidecar nginx container, so the next image update cannot break it, and the browser's own translation has a page that declares its real language to work with.&lt;/p&gt;

&lt;p&gt;The lesson I'm keeping: &lt;strong&gt;when the platform won't give you an API, look one layer out.&lt;/strong&gt; A reverse proxy is a legitimate place to add behaviour you can't add in the app — and it's usually the only place that survives upgrades.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build and self-host platforms like this one — web apps, private libraries, internal tools for small businesses in Malaysia. If you need something similar: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp +60 12-797 2969&lt;/a&gt;, &lt;a href="mailto:me@hoelee.com?subject=Self-hosted%20platform"&gt;me@hoelee.com&lt;/a&gt;, or &lt;a href="https://www.hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;When retrofitting i18n into monolithic legacy SPAs without backend API support, what architectural trade-offs do you make?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/two-ways-a-javascript-file-stops-working-and-looks-fine-16ch"&gt;Two Ways a JavaScript File Stops Working and Looks Fine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/why-your-cdp-clicks-silently-fail-smooth-scroll-minimised-windows-and-js-dispatched-events-5o"&gt;Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>i18n</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>php</category>
    </item>
    <item>
      <title>My Backup Was Crawling Because I Was Using Someone Else's API Quota</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sun, 25 Oct 2026 04:25:39 +0000</pubDate>
      <link>https://dev.to/hoelee/my-backup-was-crawling-because-i-was-using-someone-elses-api-quota-16a0</link>
      <guid>https://dev.to/hoelee/my-backup-was-crawling-because-i-was-using-someone-elses-api-quota-16a0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/my-backup-was-using-someone-elses-api-quota/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The subscription ended, and I had to move a terabyte
&lt;/h2&gt;

&lt;p&gt;The call came in the least convenient way: the OneDrive account holding my&lt;br&gt;
nightly backups had no subscription any more, and I had a 1.5-year Google&lt;br&gt;
subscription sitting unused. Move the backup, keep the history, don't lose a&lt;br&gt;
night.&lt;/p&gt;

&lt;p&gt;The first thing I hit was not a technical problem: I could not remember&lt;br&gt;
&lt;em&gt;how&lt;/em&gt; the backup was wired. rclone? Duplicati? Something Synology shipped?&lt;br&gt;
On a three-host estate with 78 compose stacks — the one in&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/running-production-infrastructure-solo/" rel="noopener noreferrer"&gt;Running Production Infrastructure Solo&lt;/a&gt;&lt;br&gt;
— "which tool writes my backups" is a question I should have been able to&lt;br&gt;
answer from memory and could not. So step one was reading the machine.&lt;/p&gt;
&lt;h2&gt;
  
  
  What was actually there
&lt;/h2&gt;

&lt;p&gt;Two rclone configs, which is the kind of detail that bites: the GUI config&lt;br&gt;
that Backrest (the restic front end) uses, plus a &lt;strong&gt;separate stale CLI&lt;br&gt;
copy&lt;/strong&gt; that a cron job or a human shell had been using at some point. Change&lt;br&gt;
one and you have changed half the system.&lt;/p&gt;

&lt;p&gt;The migration itself is boring when done in the right order, and I did it&lt;br&gt;
read-first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rclone.conf                     append [gdrive-*] + [gdrive-*-crypt]
rclone/rclone.conf (CLI copy)   same two remotes
Portainer stack 177             env → the new crypt remote; config :ro → :rw
restic init                     new repo on Google Drive
Backrest SetConfig              dsm-repo URI → the new location
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before touching the plan URIs I checked one property in the source, because&lt;br&gt;
everything depended on it: a restic repository's identity is&lt;br&gt;
&lt;code&gt;guid = sha256(id)&lt;/code&gt; — a stable value derived from the repo id, &lt;strong&gt;not&lt;/strong&gt; from&lt;br&gt;
its URL. So repointing a plan at a new backend cannot silently fork the&lt;br&gt;
history. All six plans came out byte-identical to the originals, and the&lt;br&gt;
repo &lt;code&gt;id&lt;/code&gt;/&lt;code&gt;guid&lt;/code&gt; were untouched.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then the seed stopped moving, and the log named a stranger
&lt;/h2&gt;

&lt;p&gt;The new repo seeded to 2.88 GB of roughly 1075 GB — 0.27% — and then slowed&lt;br&gt;
to a crawl. The live log had the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'Read requests per minute' ... reason: RATE_LIMIT_EXCEEDED
consumer: projects/202264815644        &amp;lt;- not my project
quota_limit_value: 840000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;202264815644&lt;/code&gt; is &lt;strong&gt;rclone's own shared OAuth project&lt;/strong&gt;. My terabyte seed&lt;br&gt;
was drawing on a per-minute read quota pooled with every rclone user on&lt;br&gt;
earth, which is why it degraded from ~750 KB/s to nothing while my NAS, my&lt;br&gt;
disk and my link were all idle. By the time I measured, the seed was fully&lt;br&gt;
stalled — &lt;strong&gt;0 bytes in 90 seconds&lt;/strong&gt;, 159 errors, still &lt;code&gt;rateLimitExceeded&lt;/code&gt; —&lt;br&gt;
and the six nightly plans had all failed with the same wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fatal: unable to save snapshot: unexpected HTTP response (500)
googleapi: Error 403: Quota exceeded for quota metric 'Queries'
  and limit 'Requests per minute' ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Slow" and "blocked" look similar in a progress bar and are completely&lt;br&gt;
different problems. Ninety seconds of sampled zeros is what made the call.&lt;/p&gt;
&lt;h2&gt;
  
  
  You need your own OAuth client — and the console is unavoidable
&lt;/h2&gt;

&lt;p&gt;There is exactly one real fix: stop using the shared client and bring your&lt;br&gt;
own. That means a Google Cloud project, an OAuth consent screen, and a&lt;br&gt;
credential. Two settings cost real time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application type = Desktop app.&lt;/strong&gt; rclone's redirect is
&lt;code&gt;http://127.0.0.1:53682&lt;/code&gt;, and that client type permits it without extra
configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the app.&lt;/strong&gt; Leave it in "Testing" and Google expires your refresh
token &lt;strong&gt;every 7 days&lt;/strong&gt; — which turns "working" into a silent weekly
outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I checked whether I could shortcut the console with the CLI that was already&lt;br&gt;
authenticated here. I could not, and the reason is worth knowing: &lt;code&gt;gcloud&lt;/code&gt;&lt;br&gt;
was signed in only as a &lt;strong&gt;service account&lt;/strong&gt; in an unrelated project, and&lt;br&gt;
service accounts cannot create consent screens or OAuth client IDs. The&lt;br&gt;
credential has to belong to a project a human owns. It was a dead end, and I&lt;br&gt;
verified that instead of asserting it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three traps in the authorization flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Publishing an unverified app can hard-block a restricted scope.&lt;/strong&gt;
&lt;code&gt;.../auth/drive&lt;/code&gt; is restricted, and an app that is published &lt;em&gt;without&lt;/em&gt;
completing Google's verification review is sometimes blocked outright
with no bypass. In my case the bypass existed — the "Google hasn't
verified this app" screen, then &lt;strong&gt;Advanced → Go to rclone-backup
(unsafe)&lt;/strong&gt; — but that is a coin flip on scope and review state, not a
guarantee, and "unsafe" is only a label meaning the app never went
through a paid audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The callback accepts exactly one request.&lt;/strong&gt; A reload, or a second tab,
hits &lt;code&gt;127.0.0.1:53682&lt;/code&gt; with no code and kills the attempt. My first try
died exactly that way (&lt;code&gt;no code returned&lt;/code&gt;), and it looks like a
credentials problem. One tab. Don't reload it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finish the consent screen on the machine that runs rclone.&lt;/strong&gt; The
redirect goes to &lt;code&gt;localhost&lt;/code&gt;, so a phone or another PC cannot complete it
— the browser has to be the one on the same host as the process waiting
on that port.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then: read the token, write &lt;code&gt;client_id&lt;/code&gt; + &lt;code&gt;client_secret&lt;/code&gt; + &lt;code&gt;token&lt;/code&gt; into&lt;br&gt;
&lt;strong&gt;both&lt;/strong&gt; configs (the GUI one and the stale CLI copy), and restore the two&lt;br&gt;
flags that matter for a long unattended upload: &lt;code&gt;use_trash = false&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;stop_on_upload_limit = true&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The old side: verify by reading, not by asking
&lt;/h2&gt;

&lt;p&gt;Purging the old remote reported an error — &lt;code&gt;itemNotFound&lt;/code&gt; — and the space&lt;br&gt;
came back anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Used:   1.012 TiB  -&amp;gt;  3.800 GiB
Free:   ~0         -&amp;gt;  1.007 TiB
DSM folder: present -&amp;gt; gone      Trashed: 0 B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error was rclone complaining &lt;em&gt;after&lt;/em&gt; the work finished. Also worth&lt;br&gt;
recording: the tool's documentation says personal accounts cannot&lt;br&gt;
&lt;code&gt;permanentDelete&lt;/code&gt;, which would have meant emptying the recycle bin by hand —&lt;br&gt;
in practice the trash was already empty and roughly 1 TB was reclaimed with&lt;br&gt;
no browser step. Documentation describes the API; the API is not always what&lt;br&gt;
you get.&lt;/p&gt;

&lt;p&gt;One more measured thing, because it changed my next decision: pausing the&lt;br&gt;
file-sync container that shared that machine halved the load average — 16.70&lt;br&gt;
→ 8.89 — which is the difference between "the network is slow" and "the box&lt;br&gt;
is busy".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Own the OAuth client from day one.&lt;/strong&gt; A shared client id is a queue you
are standing in, with no status page and no one to call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer "what writes my backups" from a document, not from memory.&lt;/strong&gt; Two
rclone configs is a normal amount of drift for a system nobody has drawn
in a year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the destination, not the exit code.&lt;/strong&gt; The purge "failed" and freed
a terabyte; a successful-looking rename can land nothing. Same lesson as
&lt;a href="https://blog.hoelee.com/posts/it-reported-success-nothing-had-changed/" rel="noopener noreferrer"&gt;It Reported Success. Nothing Had Changed.&lt;/a&gt;
— this estate keeps teaching it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never start a multi-day upload while the last few percent of your
attention is elsewhere.&lt;/strong&gt; Checking for &lt;code&gt;RATE_LIMIT_EXCEEDED&lt;/code&gt; at 0.27%
costs minutes; discovering it after two days costs the window.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result, honestly stated
&lt;/h2&gt;

&lt;p&gt;The plumbing works: the plans live on Google Drive, the repo identity is&lt;br&gt;
unchanged, the seed is now spending &lt;strong&gt;my&lt;/strong&gt; quota instead of a shared one,&lt;br&gt;
and the old side gave back about a terabyte. What it is not, yet, is a&lt;br&gt;
backup: at the time I wrote this, the first seed had uploaded 2.88 GB of&lt;br&gt;
~1075 GB and the new repo had &lt;strong&gt;zero snapshots&lt;/strong&gt; — nothing is restorable&lt;br&gt;
until the first snapshot lands. That is the number I watch, because "the&lt;br&gt;
transfer is running" and "you can restore" are different claims.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How do you monitor and isolate third-party rate limits across background backup jobs to prevent silent starvation?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/one-prometheus-for-unraid-a-synology-nas-and-a-vps-what-i-got-wrong-2g4g"&gt;One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/docker-reported-exit-0-the-kernel-had-already-oom-killed-it-2ojp"&gt;Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>backup</category>
      <category>python</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sat, 24 Oct 2026 04:25:54 +0000</pubDate>
      <link>https://dev.to/hoelee/docker-reported-exit-0-the-kernel-had-already-oom-killed-it-2ojp</link>
      <guid>https://dev.to/hoelee/docker-reported-exit-0-the-kernel-had-already-oom-killed-it-2ojp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/docker-reported-exit-0-the-oom-killer-disagreed/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The notification I expected to be a config mistake
&lt;/h2&gt;

&lt;p&gt;My NAS sent the notification every Docker user learns to skim: &lt;strong&gt;container&lt;br&gt;
stopped unexpectedly&lt;/strong&gt;. This one is the edge proxy in front of my&lt;br&gt;
self-hosted wiki, and it had been up for two weeks. Nobody had deployed&lt;br&gt;
anything that morning and nothing in the compose file had been touched, so&lt;br&gt;
the two available stories were "it crashed" and "something restarted it".&lt;/p&gt;

&lt;p&gt;Both were wrong, and the fact that I could not tell from Docker's own report&lt;br&gt;
is the part worth writing down.&lt;/p&gt;
&lt;h2&gt;
  
  
  Docker's report says nothing happened
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State:
  Status:      running
  Running:     true
  ExitCode:    0            &amp;lt;- a clean exit
  OOMKilled:   false        &amp;lt;- not killed by the kernel
  FinishedAt:  2026-10-15T19:39:33.63057879Z
  StartedAt:   2026-10-15T19:39:38.769326672Z   &amp;lt;- restarted 5 s later
RestartCount: 6
RestartPolicy: always
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Read that as written and the conclusion is: the process exited cleanly, six&lt;br&gt;
times, and the restart policy gave it a new life each time. A long-running&lt;br&gt;
proxy does not exit with status 0 on its own — and a clean exit that repeats&lt;br&gt;
six times is a contradiction, not an answer. &lt;code&gt;ExitCode&lt;/code&gt; and &lt;code&gt;OOMKilled&lt;/code&gt; are&lt;br&gt;
exactly the two fields I would have trusted here. On this host, both were&lt;br&gt;
wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The kernel keeps its own record
&lt;/h2&gt;

&lt;p&gt;On Linux, a cgroup memory kill is logged by the kernel even when the&lt;br&gt;
container runtime's bookkeeping does not carry it forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1737797.044856] traefik invoked oom-killer: gfp_mask=0x2400040, order=0, oom_score_adj=0
[1737797.123736] Memory cgroup out of memory: Kill process 615 (traefik) score 970 or sacrifice child
[1737797.132800] Killed process 615 (traefik) total-vm:2525800kB, anon-rss:478480kB, file-rss:0kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The uptime stamp &lt;code&gt;1737797&lt;/code&gt; corresponds to 19:39:33 UTC — to the second, the&lt;br&gt;
container's &lt;code&gt;FinishedAt&lt;/code&gt;. That is the whole diagnosis, and it took one&lt;br&gt;
&lt;code&gt;dmesg&lt;/code&gt; to get it. The container did not exit; it was killed inside its own&lt;br&gt;
memory limit and restarted 5 seconds later.&lt;/p&gt;

&lt;p&gt;dmesg holds &lt;strong&gt;six&lt;/strong&gt; such events, and &lt;code&gt;RestartCount&lt;/code&gt; was &lt;strong&gt;6&lt;/strong&gt;. Every other&lt;br&gt;
container in that stack has a restart count of zero and a start time from&lt;br&gt;
weeks ago, so the six kills pin to this one container:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dmesg uptime&lt;/th&gt;
&lt;th&gt;local time&lt;/th&gt;
&lt;th&gt;anon-rss at kill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1406539&lt;/td&gt;
&lt;td&gt;Oct 12, ~07:37&lt;/td&gt;
&lt;td&gt;491 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1407759&lt;/td&gt;
&lt;td&gt;Oct 12, ~07:57&lt;/td&gt;
&lt;td&gt;494 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1432574&lt;/td&gt;
&lt;td&gt;Oct 12, ~14:49&lt;/td&gt;
&lt;td&gt;486 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1734793&lt;/td&gt;
&lt;td&gt;Oct 16, ~02:45&lt;/td&gt;
&lt;td&gt;487 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1735495&lt;/td&gt;
&lt;td&gt;Oct 16, ~02:58&lt;/td&gt;
&lt;td&gt;485 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1737797&lt;/td&gt;
&lt;td&gt;Oct 16, 03:39 ← the notification&lt;/td&gt;
&lt;td&gt;478 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every kill happens at 478–494 MB against a limit of 512 MiB (&lt;code&gt;536,870,912&lt;/code&gt;&lt;br&gt;
bytes). That is a ceiling being touched, not a runaway.&lt;/p&gt;

&lt;p&gt;Two more readings made the mechanism clear. The cgroup's recorded peak — the&lt;br&gt;
counter that survives the restart — was already back at exactly&lt;br&gt;
&lt;code&gt;536,870,912&lt;/code&gt; &lt;strong&gt;18 minutes&lt;/strong&gt; after the restart, i.e. the container touched&lt;br&gt;
its ceiling again in the same cycle &lt;em&gt;without&lt;/em&gt; being killed. And the usage&lt;br&gt;
curve oscillated (176 → 308 → 361 MB) instead of climbing monotonically, so&lt;br&gt;
this is allocation churn under load hitting a small cap, not a provable slow&lt;br&gt;
leak. The prime suspect is a geoblock plugin doing an external HTTPS lookup&lt;br&gt;
for every client IP it has not seen before.&lt;/p&gt;

&lt;p&gt;A seventh kill landed at 05:33 that morning, before the fix went in.&lt;/p&gt;

&lt;p&gt;On this DSM build the truth was only in &lt;code&gt;dmesg&lt;/code&gt; plus the cgroup's&lt;br&gt;
&lt;code&gt;memory.max_usage_in_bytes&lt;/code&gt;. If you are chasing the same shape on a normal&lt;br&gt;
host, &lt;code&gt;docker inspect -f '{{.State.OOMKilled}}'&lt;/code&gt; is the field to read first&lt;br&gt;
— it is supposed to be authoritative. Here it read &lt;code&gt;false&lt;/code&gt; five seconds&lt;br&gt;
after the kernel had logged the kill, which is the reason this post exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  What was actually breaking: a crawler flood
&lt;/h2&gt;

&lt;p&gt;Host memory was never the problem — 64 GB installed, ~39 GB available. One&lt;br&gt;
container's 512 MiB ceiling was.&lt;/p&gt;

&lt;p&gt;The driver was traffic, and the numbers came out of the container's own&lt;br&gt;
access log. In the last 50,000 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distinct client IPs      25,976
403 responses            33,636   (geoblock / deny-list doing its job)
429 responses            16,082   (inflight and rate limiting doing their job)
container CPU            118–230%
host load average        26–32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each restart refilled the heap in 12 to 40 minutes. Nothing in the&lt;br&gt;
configuration was wrong, and neither were the 403s and 429s — they are the&lt;br&gt;
filter rejecting crawlers exactly as designed. The problem is who is&lt;br&gt;
knocking: this container is the public edge for a multi-terabyte Wikipedia&lt;br&gt;
corpus served from a home NAS, behind a country allowlist and a bot filter.&lt;br&gt;
That is an extremely attractive target for a residential-proxy crawler&lt;br&gt;
cloud, and the responses telling it to go away are still work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the sentence that has to come with it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt; &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
   &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
     &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt;      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
&lt;span class="na"&gt;+      memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2g&lt;/span&gt;
 &lt;span class="s"&gt;restart&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="na"&gt;+memswap_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification, because "I raised the limit and it feels better" is not a&lt;br&gt;
verification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only that container was recreated.&lt;/strong&gt; The wiki, the proxy core and the
robots container still reported two weeks of uptime — the gap users saw
was seconds, not a stack restart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HostConfig.Memory = 2147483648&lt;/code&gt;, &lt;code&gt;MemorySwap = 2147483648&lt;/code&gt;, and the
cgroup's &lt;code&gt;memory.limit_in_bytes&lt;/code&gt; agreed with both; &lt;code&gt;RestartCount&lt;/code&gt; went
back to 0.&lt;/li&gt;
&lt;li&gt;Peak usage after 18 minutes: &lt;strong&gt;210 MB&lt;/strong&gt;, against a ceiling it had been
pinning at 512 MB. By midday it sat at 94 MiB.&lt;/li&gt;
&lt;li&gt;The mount list still matched the file, including the plugin directory
mount — losing that one had once disabled every router in this stack.&lt;/li&gt;
&lt;li&gt;Both plugins were loaded and active (country allowlist, bot filter), and
the routes returned 200 once the filter's score window expired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;And what it is not:&lt;/strong&gt; this is a stopgap. The flood is the driver, so an&lt;br&gt;
order-of-magnitude bigger flood reaches 2 GB too. The real fix is shrinking&lt;br&gt;
the public entry surface, not enlarging the cage around it — and a limit&lt;br&gt;
bump that is not labelled as a stopgap becomes a permanent explanation.&lt;/p&gt;

&lt;p&gt;I chose to disable swap for this container rather than let it swap:&lt;br&gt;
&lt;code&gt;memswap_limit&lt;/code&gt; equal to the memory limit does exactly that. The host&lt;br&gt;
already had 16 GB of its 40 GB swap in use with committed memory at 92 GB,&lt;br&gt;
and an edge proxy that starts swapping slows the entire NAS — the disk array&lt;br&gt;
is the bottleneck here, so swapping buys a slower failure rather than a&lt;br&gt;
longer life.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that nearly made me report a self-inflicted outage
&lt;/h2&gt;

&lt;p&gt;During verification, my first bare &lt;code&gt;curl&lt;/code&gt; probes against the site returned&lt;br&gt;
&lt;strong&gt;403&lt;/strong&gt;, and it looked exactly like a regression I had just caused by&lt;br&gt;
restarting the proxy. It was not: the bot filter had scored &lt;strong&gt;my own client&lt;br&gt;
IP&lt;/strong&gt; (&lt;code&gt;blocked user-agent&lt;/code&gt;, score 80) and placed it in a 15-minute score&lt;br&gt;
window, because I had been hammering the host.&lt;/p&gt;

&lt;p&gt;The tell was in the same test run — &lt;code&gt;/robots.txt&lt;/code&gt; returned 200 and the&lt;br&gt;
internal &lt;code&gt;/cdn&lt;/code&gt; path returned 400 at the same moment, i.e. the router was&lt;br&gt;
healthy and only my own requests were being filtered. On a host where you&lt;br&gt;
are also a client, never diagnose with your own bare curl: probe a path the&lt;br&gt;
filter ignores, or read a 200 out of the access log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat "stopped unexpectedly" plus &lt;code&gt;ExitCode 0&lt;/code&gt; as an instruction to
read &lt;code&gt;dmesg&lt;/code&gt;&lt;/strong&gt;, not as evidence of a clean exit. A restart count above
zero is a memory investigation until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on the cgroup peak, not only on restarts.&lt;/strong&gt; The post-restart
reading that was already pinned at the ceiling told me a seventh kill was
coming about eighteen minutes before the notification arrived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get the "who is hitting me" number early.&lt;/strong&gt; 25,976 distinct IPs in one
log sample is the entire story, and I found it after spending time on the
memory curve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Question the container, not the server.&lt;/strong&gt; "The server is out of memory"
and "one container has a 512 MiB ceiling on a 64 GB machine" need
completely different responses, and the second one is invisible unless
you look at the limit.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;No OOM kill since the change, with a peak at 210 MB after 18 minutes against&lt;br&gt;
a ceiling that used to be touched in every single cycle — and the seven&lt;br&gt;
kills that had been silently restarting the public edge for four days&lt;br&gt;
stopped appearing in dmesg. The crawler flood did not stop, which is why I&lt;br&gt;
recorded the failure mode in my ops notes next to the stack: "container&lt;br&gt;
stopped unexpectedly" plus &lt;code&gt;exit 0&lt;/code&gt; / &lt;code&gt;OOMKilled=false&lt;/code&gt; is a misleading&lt;br&gt;
signature, and on this platform the honest evidence lives in the kernel log&lt;br&gt;
and the cgroup's high-water mark.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How do you detect silent container crash loops when uptime health checks report green between restarts?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/one-prometheus-for-unraid-a-synology-nas-and-a-vps-what-i-got-wrong-2g4g"&gt;One Prometheus for unRaid, a Synology NAS, and a VPS: What I Got Wrong&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/my-backup-was-crawling-because-i-was-using-someone-elses-api-quota-16a0"&gt;My Backup Was Crawling Because I Was Using Someone Else's API Quota&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>linux</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:25:11 +0000</pubDate>
      <link>https://dev.to/hoelee/why-your-cdp-clicks-silently-fail-smooth-scroll-minimised-windows-and-js-dispatched-events-5o</link>
      <guid>https://dev.to/hoelee/why-your-cdp-clicks-silently-fail-smooth-scroll-minimised-windows-and-js-dispatched-events-5o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/why-your-cdp-clicks-silently-fail/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you drive a web app with the Chrome DevTools Protocol, you have met this failure: your script reports &lt;code&gt;clicked @591,361&lt;/code&gt;, the element is there, the click landed — and nothing happens. No exception, no console error, no change on the page.&lt;/p&gt;

&lt;p&gt;I spent an evening on exactly this while automating a marketplace's seller admin panel, and the three causes I found are worth writing down, because they are all invisible to the code that reports the click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: a stale rect, because the page scrolls smoothly
&lt;/h2&gt;

&lt;p&gt;The obvious click helper looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(()=&amp;gt;{const e=%s; e.scrollIntoView({block:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;});
  const b=e.getBoundingClientRect();
  return JSON.stringify({x:b.left+b.width/2, y:b.top+b.height/2});})()&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click_xy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scroll into view, read the element's rectangle, click its centre. Correct — unless the page has &lt;code&gt;scroll-behavior: smooth&lt;/code&gt; (very common in modern UI kits). Then &lt;code&gt;scrollIntoView()&lt;/code&gt; starts an &lt;strong&gt;animation&lt;/strong&gt;, and &lt;code&gt;getBoundingClientRect()&lt;/code&gt; in the same tick returns the position the element is &lt;em&gt;about to leave&lt;/em&gt;. Your click goes to where the element was, lands on whatever is there, and the log tells you it clicked the coordinates you asked for.&lt;/p&gt;

&lt;p&gt;The fix is two lines — turn smooth scrolling off, then re-read the rect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(()=&amp;gt;{document.documentElement.style.scrollBehavior=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;
  if(document.body) document.body.style.scrollBehavior=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;; return &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;})()&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;moved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(()=&amp;gt;{const e=%s; if(!e) return &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;
  e.scrollIntoView({block:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, behavior:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;instant&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;}); return &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;})()&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# let layout settle
# then read the rect (and retry a couple of times if the element is not there yet)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. &lt;code&gt;behavior:'instant'&lt;/code&gt; on &lt;code&gt;scrollIntoView&lt;/code&gt; overrides the CSS; and the small wait before reading is what makes the rect trustworthy. Without the retry loop the helper still fails intermittently on elements that render a frame late — intermittent failures are the worst kind, because they make you doubt your own diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: the window is minimised, and coordinate clicks are dropped
&lt;/h2&gt;

&lt;p&gt;After the fix, my click helper worked in a one-off probe and then never again inside the real script. Same selector, same coordinates, same page. The difference turned out to be visibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;document.visibilityState → "hidden"
document.hasFocus()      → true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CDP-synthesised mouse events at coordinates are unreliable when the target tab is not the active tab of a visible window — the browser may accept them, throttle them, or route them nowhere. Note that &lt;code&gt;hasFocus()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; even when the document is hidden, so it is not a useful check; &lt;code&gt;document.hidden&lt;/code&gt;/&lt;code&gt;visibilityState&lt;/code&gt; is.&lt;/p&gt;

&lt;p&gt;Two fixes, and I use both:&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;def&lt;/span&gt; &lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                 &lt;span class="c1"&gt;# bring the tab to the front of its window
&lt;/span&gt;    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cdp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Page.bringToFront&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;click_js&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;            &lt;span class="c1"&gt;# click by dispatching events from inside the page
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(()=&amp;gt;{const e=%s; if(!e) return &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;
      [&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mousedown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mouseup&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;click&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;].forEach(k=&amp;gt;
        e.dispatchEvent(new MouseEvent(k,{bubbles:true,cancelable:true,view:window})));
      return &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;js-clicked&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;;})()&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Page.bringToFront&lt;/code&gt; fixes the common case. But the JS-dispatched click is the one that never fails: it does not depend on the window being visible, and React's root-level event listener receives it exactly as it receives a real click, because the events bubble to the same root. A date-picker input that opened maybe one time in five with coordinate clicks opens &lt;strong&gt;every&lt;/strong&gt; time this way.&lt;/p&gt;

&lt;p&gt;The trade-off to know: dispatching events skips hit-testing, so it will also “click” an element a user could not reach (covered, scrolled off, zero-opacity). That is a feature here — and a trap if you use it to paper over a layout bug — so I keep coordinate clicking for anything visual and reach for &lt;code&gt;click_js&lt;/code&gt; when the widget is a custom control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the page received the click and rejected it
&lt;/h2&gt;

&lt;p&gt;This is the one that wasted the most time, because the symptom is identical to causes 1 and 2: click fires, nothing changes.&lt;/p&gt;

&lt;p&gt;Distinguishing them takes one injected listener:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__ev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mousedown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mouseup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Events recorded → the input path works. The problem is in the app (cause 3), or in how you read the result (see below).&lt;/li&gt;
&lt;li&gt;Nothing recorded → causes 1 or 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the listener armed I clicked &lt;code&gt;Confirm&lt;/code&gt; and the buffer filled up — so the click was fine, and the app was saying no. The reason was in text I had not thought to look at:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Please create a new shop welcome voucher after the existing one is expired.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The form refuses a second voucher of that type. No field-level red text, no toast in the first 500 ms, the URL unchanged. &lt;strong&gt;The rejection was real and the click was innocent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two more tools for this class of bug:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;elementFromPoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// what is actually on top at that coordinate?&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;   &lt;span class="c1"&gt;// how many match your selector?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one caught a subtle one: the portal leaves a &lt;strong&gt;hidden&lt;/strong&gt; &lt;code&gt;Confirm&lt;/code&gt; button in the DOM from the collapsed date-panel, so &lt;code&gt;.pop()&lt;/code&gt; returned the invisible duplicate and the click went nowhere visible. Filter by visibility &lt;em&gt;and&lt;/em&gt; size:&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[...document.querySelectorAll(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)].filter(b=&amp;gt;/^Confirm$/.test(b.innerText.trim())&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &amp;amp;&amp;amp; b.offsetParent!==null &amp;amp;&amp;amp; b.getBoundingClientRect().width&amp;gt;0).pop()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  And the fourth failure I only found by re-reading
&lt;/h2&gt;

&lt;p&gt;There is a class of “failure” that is not one: &lt;strong&gt;the write succeeded and your read is stale.&lt;/strong&gt; I concluded a create had been silently refused because the list page did not show it immediately, clicked again, and later discovered the voucher had existed the whole time — the list just lagged about ten minutes. The lesson generalises: after a write, the only trustworthy verdict is a read-back &lt;em&gt;after&lt;/em&gt; a real waiting period, and “no visible change” means &lt;em&gt;unknown&lt;/em&gt;, not &lt;em&gt;failed&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Turn off smooth scrolling; scroll with &lt;code&gt;behavior:'instant'&lt;/code&gt;; wait, then read the rect.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;Page.bringToFront&lt;/code&gt; before clicking; treat &lt;code&gt;document.hidden&lt;/code&gt; as the signal, not &lt;code&gt;hasFocus()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For custom widgets, dispatch &lt;code&gt;mousedown&lt;/code&gt;/&lt;code&gt;mouseup&lt;/code&gt;/&lt;code&gt;click&lt;/code&gt; from JS rather than clicking coordinates.&lt;/li&gt;
&lt;li&gt;Arm an event listener to prove whether input arrived before you blame the app.&lt;/li&gt;
&lt;li&gt;Count your selectors — hidden duplicates are common in component libraries.&lt;/li&gt;
&lt;li&gt;After a write, wait and re-read; never re-click a submit you have not verified.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic, but every item is invisible in a log line that says &lt;code&gt;clicked @591,361&lt;/code&gt;. If you are building automation against an admin panel you do not control, the click is the least reliable part of the pipeline — and the one most likely to be blamed last.&lt;/p&gt;

&lt;p&gt;I build and debug this kind of browser automation for a living — self-hosted services, Docker and Traefik stacks, and the odd scraped-but-bot-walled marketplace. If you have a workflow that keeps breaking because a UI has other ideas, tell me about it: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp&lt;/a&gt; · &lt;a href="mailto:me@hoelee.com?subject=Browser%20automation%20debugging"&gt;me@hoelee.com&lt;/a&gt; · &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;When driving headless browsers in production, how do you verify click events actually triggered application side-effects rather than just passing the automation driver's checks?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/why-chrome-forgets-its-tabs-in-a-container-and-how-i-fixed-it-23am"&gt;Why Chrome Forgets Its Tabs in a Container — And How I Fixed It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/two-ways-a-javascript-file-stops-working-and-looks-fine-16ch"&gt;Two Ways a JavaScript File Stops Working and Looks Fine&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>browser</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
    <item>
      <title>Two Ways a JavaScript File Stops Working and Looks Fine</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Thu, 22 Oct 2026 04:25:08 +0000</pubDate>
      <link>https://dev.to/hoelee/two-ways-a-javascript-file-stops-working-and-looks-fine-16ch</link>
      <guid>https://dev.to/hoelee/two-ways-a-javascript-file-stops-working-and-looks-fine-16ch</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/two-ways-a-javascript-file-stops-working-and-looks-fine/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The change that did nothing at all
&lt;/h2&gt;

&lt;p&gt;I added a language switcher to a dashboard I run for a media-conversion&lt;br&gt;
queue. Three language packs, a &lt;code&gt;t(key, vars)&lt;/code&gt; function, &lt;code&gt;data-i18n&lt;/code&gt;&lt;br&gt;
attributes on the chrome, a &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; in the top bar.&lt;/p&gt;

&lt;p&gt;After deploying it, the page looked exactly as it had before. Not broken —&lt;br&gt;
&lt;em&gt;unchanged&lt;/em&gt;. Same layout, same Chinese labels, no missing panels, no error&lt;br&gt;
card, no console banner. The switcher was simply not there, and neither was&lt;br&gt;
anything else the new code was supposed to do.&lt;/p&gt;

&lt;p&gt;That is the shape of bug I want to write down, because the page was not&lt;br&gt;
"mostly working". &lt;strong&gt;The script had stopped executing partway through —&lt;br&gt;
twice, for two unrelated one-line reasons — and the page rendered perfectly&lt;br&gt;
anyway.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why nothing looked broken
&lt;/h2&gt;

&lt;p&gt;Two design decisions I would make again, and both of them hid the failure.&lt;/p&gt;

&lt;p&gt;The first is that the default language lives in the markup. The Chinese text&lt;br&gt;
is written inside the elements; &lt;code&gt;data-i18n&lt;/code&gt; is only a hook that &lt;em&gt;overrides&lt;/em&gt;&lt;br&gt;
it when you switch. That is deliberate — the page needs no fetch to render,&lt;br&gt;
and if a language pack goes missing you get Chinese instead of a row of&lt;br&gt;
&lt;code&gt;top.theme_to_dark&lt;/code&gt; keys. It also means a completely dead i18n layer&lt;br&gt;
produces a page that is, visually, correct.&lt;/p&gt;

&lt;p&gt;The second is that the buttons are wired with inline handlers&lt;br&gt;
(&lt;code&gt;onclick="rtFoo()"&lt;/code&gt;), which after a script abort point at functions that&lt;br&gt;
were never defined. Nothing warns you. The page only reveals the truth when&lt;br&gt;
a human clicks something.&lt;/p&gt;

&lt;p&gt;So the symptom was "my change had no effect", which is the least informative&lt;br&gt;
symptom a web page can give you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Failure one: a parameter that swallowed a function
&lt;/h2&gt;

&lt;p&gt;The file has a theme block and an i18n block. The theme block had this&lt;br&gt;
signature:&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;function&lt;/span&gt; &lt;span class="nf"&gt;applyTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                       &lt;span class="c1"&gt;// t = the theme&lt;/span&gt;
  &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top.theme_to_dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// TypeError: t is not a 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;code&gt;applyTheme&lt;/code&gt; was written before the i18n layer existed, and &lt;code&gt;t&lt;/code&gt; was a&lt;br&gt;
perfectly reasonable name for "theme". When the i18n layer arrived, &lt;code&gt;t&lt;/code&gt; in&lt;br&gt;
that scope stopped meaning the theme and started meaning &lt;em&gt;the function I&lt;br&gt;
wanted to call&lt;/em&gt; — except the parameter shadows it, so the call became&lt;br&gt;
"invoke a string".&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;TypeError&lt;/code&gt; at the top level of a classic script does not just fail one&lt;br&gt;
line. Everything after it never runs: the language-pack loader, the&lt;br&gt;
&lt;code&gt;data-i18n&lt;/code&gt; sweep, the switcher rendering. One wrong letter removed an&lt;br&gt;
entire feature, silently.&lt;/p&gt;

&lt;p&gt;The fix is one character — rename the parameter to &lt;code&gt;th&lt;/code&gt; — plus a comment&lt;br&gt;
where the next person will look:&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;function&lt;/span&gt; &lt;span class="nf"&gt;applyTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;th&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// NOT `t`: t() is the i18n function in this file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Failure two: a declaration that arrived too late
&lt;/h2&gt;

&lt;p&gt;The second cause was in the same file, and it is a JavaScript rule people&lt;br&gt;
know and still trip over. The theme block runs at load and calls &lt;code&gt;t('…')&lt;/code&gt;.&lt;br&gt;
The i18n block, further down the file, began with:&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;let&lt;/span&gt; &lt;span class="nx"&gt;LANG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rt_lang&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zh-CN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;let&lt;/code&gt; and &lt;code&gt;const&lt;/code&gt; are hoisted but &lt;strong&gt;not initialised&lt;/strong&gt;: between the start of&lt;br&gt;
the scope and that line they live in the temporal dead zone, and any access&lt;br&gt;
throws &lt;code&gt;Cannot access 'LANG' before initialization&lt;/code&gt;. Because the theme block&lt;br&gt;
sits &lt;em&gt;above&lt;/em&gt; the i18n block, the failure was not a race — it was guaranteed&lt;br&gt;
on every single load.&lt;/p&gt;

&lt;p&gt;Note the distinction from &lt;code&gt;var&lt;/code&gt;, which would have quietly given me&lt;br&gt;
&lt;code&gt;undefined&lt;/code&gt; and a working page. The TDZ is the language protecting me from&lt;br&gt;
exactly the bug I was about to write; the only reason it looked like&lt;br&gt;
"nothing happened" is the markup fallback again.&lt;/p&gt;

&lt;p&gt;The fix is ordering, and ordering here is a &lt;em&gt;dependency&lt;/em&gt;, not a style&lt;br&gt;
choice: if a block calls &lt;code&gt;t()&lt;/code&gt;, the i18n declarations have to be above it.&lt;br&gt;
The file had been organised by topic ("theme, then i18n") because that is&lt;br&gt;
how it grew.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why my checks did not catch either one
&lt;/h2&gt;

&lt;p&gt;I had tests. They checked intent, not effect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the three language packs exist and parse;&lt;/li&gt;
&lt;li&gt;every key used in the markup exists in every pack;&lt;/li&gt;
&lt;li&gt;no pack contains a dead key (a typo detector);&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;{var}&lt;/code&gt; placeholder sets in the three packs match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All true. All green. None of them executes the page, so none of them can see&lt;br&gt;
a script that aborts on line 12 of 900. Static checks are structurally&lt;br&gt;
unable to catch "the file is valid and the program is dead".&lt;/p&gt;
&lt;h2&gt;
  
  
  Assert on effects, in a browser
&lt;/h2&gt;

&lt;p&gt;What found both bugs was asking the live page questions that only a running&lt;br&gt;
program can answer:&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;          &lt;span class="c1"&gt;// is the entry point still callable?&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-i18n]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;   &lt;span class="c1"&gt;// hooks present&lt;/span&gt;
&lt;span class="nf"&gt;rtCheckHandlers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                &lt;span class="c1"&gt;// do the inline handlers still resolve?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is worth more than it looks. The page carries &lt;strong&gt;219 inline&lt;br&gt;
&lt;code&gt;onclick&lt;/code&gt; attributes naming 217 distinct functions&lt;/strong&gt;; the check enumerates&lt;br&gt;
them and asserts each name is defined on &lt;code&gt;window&lt;/code&gt;. It turns a hand-clicked&lt;br&gt;
smoke test into a two-second automated one, and it is the check that turns&lt;br&gt;
"the page looks right" into a measurable property.&lt;/p&gt;

&lt;p&gt;The second tool is cheaper and nastier: a &lt;strong&gt;pseudo-language&lt;/strong&gt;.&lt;br&gt;
&lt;code&gt;?lang=pseudo&lt;/code&gt; takes every string in the pack, prefixes it with &lt;code&gt;[~~&lt;/code&gt; and&lt;br&gt;
stretches it 1.6×. Then assert two things — no horizontal overflow at&lt;br&gt;
1440px, and no element in the nav still lacks the &lt;code&gt;[~~&lt;/code&gt; prefix. The first&lt;br&gt;
catches layout that only breaks in a longer language; the second catches&lt;br&gt;
nodes that never got a translation hook at all. It found a real one in the&lt;br&gt;
same run.&lt;/p&gt;

&lt;p&gt;Honest scope note: a pseudo-locale does not prove your Malay translation is&lt;br&gt;
&lt;em&gt;good&lt;/em&gt;. It proves the &lt;em&gt;plumbing&lt;/em&gt; is complete — every string flows through&lt;br&gt;
the mechanism, and the layout survives length. Judgement about the words&lt;br&gt;
stays with a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Load the page in a real browser and assert &lt;code&gt;typeof&lt;/code&gt; on the entry points
before believing a client-side refactor worked.&lt;/strong&gt; A dead script is
invisible to every static check you own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let a parameter name shadow a function the same file calls.&lt;/strong&gt; One
letter bought me an entire afternoon; the comment is the part that stops
it recurring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat declaration order as a dependency, not aesthetics.&lt;/strong&gt; If a block
calls a function or reads a constant, that declaration goes above it —
especially in one long script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate the language packs from one table.&lt;/strong&gt; Then "the keys match" is
a test rather than a hope, and drift cannot be merged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a change does nothing, suspect the script before the change.&lt;/strong&gt;
"Nothing happened" is the default failure mode of a rendered page, not
evidence that your code was inert.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Two one-line causes, both in the same file, both found by browser-side&lt;br&gt;
assertions rather than by reading code again. The dashboard switches&lt;br&gt;
language at runtime now, the default render is byte-for-byte what it was&lt;br&gt;
before the change, and the three packs come from a single generated table —&lt;br&gt;
so a missing key shows up as a red test instead of as a word that quietly&lt;br&gt;
stays Chinese.&lt;/p&gt;

&lt;p&gt;The generalisation is the part I keep: &lt;strong&gt;a crash tells you where to look. A&lt;br&gt;
silent stop tells you nothing, and looks like success.&lt;/strong&gt; If a feature can&lt;br&gt;
fail without moving a single pixel, then "the page still looks right" is not&lt;br&gt;
a check.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What is the most deceptive 'silent failure' bug you have tracked down where HTTP returned 200 and console errors stayed quiet?&lt;/strong&gt; Drop your setup or thoughts in the comments below — I read and reply to every discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related notes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/why-your-cdp-clicks-silently-fail-smooth-scroll-minimised-windows-and-js-dispatched-events-5o"&gt;Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/hoelee/why-chrome-forgets-its-tabs-in-a-container-and-how-i-fixed-it-23am"&gt;Why Chrome Forgets Its Tabs in a Container — And How I Fixed It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
