<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://blog.oyatrino.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.oyatrino.com/" rel="alternate" type="text/html" /><updated>2026-07-13T18:40:17-04:00</updated><id>https://blog.oyatrino.com/feed.xml</id><title type="html">dailycmd</title><subtitle>One command, one war story at a time — notes from the trenches of Kubernetes, observability and infrastructure automation.</subtitle><author><name>Corentin</name></author><entry><title type="html">kubectl &amp;amp; k9s cheatsheet</title><link href="https://blog.oyatrino.com/2026/07/kubectl-k9s-cheatsheet.html" rel="alternate" type="text/html" title="kubectl &amp;amp; k9s cheatsheet" /><published>2026-07-11T12:30:00-04:00</published><updated>2026-07-11T12:30:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/kubectl-k9s-cheatsheet</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/kubectl-k9s-cheatsheet.html"><![CDATA[<p>The commands I actually use. <code class="language-plaintext highlighter-rouge">kubectl</code> for scripting and precision, <a href="https://k9scli.io/">k9s</a>
for interactive triage.</p>

<h2 id="kubectl--context--namespace">kubectl — context &amp; namespace</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Command</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>List contexts</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl config get-contexts</code></td>
    </tr>
    <tr>
      <td>Switch context</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl config use-context NAME</code></td>
    </tr>
    <tr>
      <td>Current context</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl config current-context</code></td>
    </tr>
    <tr>
      <td>Set default namespace</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl config set-context --current --namespace=NS</code></td>
    </tr>
  </tbody>
</table>

<h2 id="kubectl--inspect">kubectl — inspect</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Command</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pods with node + IP</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get pods -o wide</code></td>
    </tr>
    <tr>
      <td>Everything in a namespace</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get all -n NS</code></td>
    </tr>
    <tr>
      <td>Watch pods change</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get pods -w</code></td>
    </tr>
    <tr>
      <td>Describe (events at the bottom)</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl describe pod POD</code></td>
    </tr>
    <tr>
      <td>Recent cluster events</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get events --sort-by=.lastTimestamp</code></td>
    </tr>
    <tr>
      <td>Resource usage</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl top pods</code> / <code class="language-plaintext highlighter-rouge">kubectl top nodes</code></td>
    </tr>
    <tr>
      <td>Full YAML of a live object</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get deploy NAME -o yaml</code></td>
    </tr>
    <tr>
      <td>One field via jsonpath</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get pod POD -o jsonpath='{.status.podIP}'</code></td>
    </tr>
    <tr>
      <td>Find pods by label</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get pods -l app=NAME -A</code></td>
    </tr>
  </tbody>
</table>

<h2 id="kubectl--logs">kubectl — logs</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Command</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Follow logs</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl logs -f POD</code></td>
    </tr>
    <tr>
      <td>Specific container</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl logs POD -c CONTAINER</code></td>
    </tr>
    <tr>
      <td>Previous crashed container</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl logs POD --previous</code></td>
    </tr>
    <tr>
      <td>All pods of a deployment</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl logs -f deploy/NAME</code></td>
    </tr>
    <tr>
      <td>Last hour only</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl logs POD --since=1h</code></td>
    </tr>
  </tbody>
</table>

<h2 id="kubectl--act">kubectl — act</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Command</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Shell into a pod</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl exec -it POD -- sh</code></td>
    </tr>
    <tr>
      <td>Restart a deployment</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl rollout restart deploy/NAME</code></td>
    </tr>
    <tr>
      <td>Watch a rollout</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl rollout status deploy/NAME</code></td>
    </tr>
    <tr>
      <td>Undo a rollout</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl rollout undo deploy/NAME</code></td>
    </tr>
    <tr>
      <td>Scale</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl scale deploy/NAME --replicas=3</code></td>
    </tr>
    <tr>
      <td>Port-forward</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl port-forward svc/NAME 8080:80</code></td>
    </tr>
    <tr>
      <td>Copy file out of a pod</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl cp NS/POD:/path/file ./file</code></td>
    </tr>
    <tr>
      <td>Apply / diff first</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl apply -f f.yaml</code> / <code class="language-plaintext highlighter-rouge">kubectl diff -f f.yaml</code></td>
    </tr>
    <tr>
      <td>Throwaway debug pod</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl run tmp --rm -it --image=busybox -- sh</code></td>
    </tr>
    <tr>
      <td>Cordon + drain a node</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl cordon NODE &amp;&amp; kubectl drain NODE --ignore-daemonsets</code></td>
    </tr>
  </tbody>
</table>

<p>Watch out for short-name collisions: on clusters with extra CRDs, <code class="language-plaintext highlighter-rouge">kubectl get
backup</code> may resolve to a different resource than you expect — prefer the
fully-qualified form (<code class="language-plaintext highlighter-rouge">backups.velero.io</code>) in scripts.</p>

<h2 id="k9s--navigation">k9s — navigation</h2>

<p>Everything starts with <code class="language-plaintext highlighter-rouge">:</code> (command mode) or <code class="language-plaintext highlighter-rouge">/</code> (filter).</p>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Open resource view</td>
      <td><code class="language-plaintext highlighter-rouge">:pods</code>, <code class="language-plaintext highlighter-rouge">:deploy</code>, <code class="language-plaintext highlighter-rouge">:svc</code>, <code class="language-plaintext highlighter-rouge">:nodes</code>, …</td>
    </tr>
    <tr>
      <td>Any CRD too</td>
      <td><code class="language-plaintext highlighter-rouge">:applications</code>, <code class="language-plaintext highlighter-rouge">:certificates</code>, …</td>
    </tr>
    <tr>
      <td>Filter rows</td>
      <td><code class="language-plaintext highlighter-rouge">/pattern</code> (regex), <code class="language-plaintext highlighter-rouge">Esc</code> to clear</td>
    </tr>
    <tr>
      <td>Invert filter</td>
      <td><code class="language-plaintext highlighter-rouge">/!pattern</code></td>
    </tr>
    <tr>
      <td>Switch namespace</td>
      <td><code class="language-plaintext highlighter-rouge">:ns</code> then Enter on one, or <code class="language-plaintext highlighter-rouge">0</code> for all</td>
    </tr>
    <tr>
      <td>Switch context</td>
      <td><code class="language-plaintext highlighter-rouge">:ctx</code></td>
    </tr>
    <tr>
      <td>Go back</td>
      <td><code class="language-plaintext highlighter-rouge">Esc</code></td>
    </tr>
    <tr>
      <td>Quit</td>
      <td><code class="language-plaintext highlighter-rouge">:q</code> or <code class="language-plaintext highlighter-rouge">Ctrl-c</code></td>
    </tr>
  </tbody>
</table>

<h2 id="k9s--on-a-selected-pod">k9s — on a selected pod</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Logs</td>
      <td><code class="language-plaintext highlighter-rouge">l</code> (then <code class="language-plaintext highlighter-rouge">f</code> to toggle follow, <code class="language-plaintext highlighter-rouge">p</code> for previous container)</td>
    </tr>
    <tr>
      <td>Shell</td>
      <td><code class="language-plaintext highlighter-rouge">s</code></td>
    </tr>
    <tr>
      <td>Describe</td>
      <td><code class="language-plaintext highlighter-rouge">d</code></td>
    </tr>
    <tr>
      <td>YAML</td>
      <td><code class="language-plaintext highlighter-rouge">y</code></td>
    </tr>
    <tr>
      <td>Delete</td>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-d</code></td>
    </tr>
    <tr>
      <td>Kill (no grace)</td>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-k</code></td>
    </tr>
    <tr>
      <td>Port-forward</td>
      <td><code class="language-plaintext highlighter-rouge">Shift-f</code></td>
    </tr>
    <tr>
      <td>Sort by CPU / memory</td>
      <td><code class="language-plaintext highlighter-rouge">Shift-c</code> / <code class="language-plaintext highlighter-rouge">Shift-m</code></td>
    </tr>
    <tr>
      <td>Mark pod (multi-select)</td>
      <td><code class="language-plaintext highlighter-rouge">Space</code></td>
    </tr>
  </tbody>
</table>

<h2 id="k9s--wider-views">k9s — wider views</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Events for the cluster</td>
      <td><code class="language-plaintext highlighter-rouge">:events</code></td>
    </tr>
    <tr>
      <td>Pulses (cluster overview)</td>
      <td><code class="language-plaintext highlighter-rouge">:pulses</code></td>
    </tr>
    <tr>
      <td>xray (resource tree)</td>
      <td><code class="language-plaintext highlighter-rouge">:xray deploy NS</code></td>
    </tr>
    <tr>
      <td>Popeye (lint the cluster)</td>
      <td><code class="language-plaintext highlighter-rouge">:popeye</code></td>
    </tr>
    <tr>
      <td>Toggle wide columns</td>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-w</code></td>
    </tr>
    <tr>
      <td>Toggle header</td>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-e</code></td>
    </tr>
  </tbody>
</table>

<h2 id="the-triage-loop">The triage loop</h2>

<p>My default incident sequence, entirely in k9s:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">:pods</code> then <code class="language-plaintext highlighter-rouge">/</code> on the app name — anything not <code class="language-plaintext highlighter-rouge">Running</code>?</li>
  <li><code class="language-plaintext highlighter-rouge">d</code> on the suspect — read the Events section first, not the spec.</li>
  <li><code class="language-plaintext highlighter-rouge">l</code> then <code class="language-plaintext highlighter-rouge">p</code> — the <em>previous</em> container’s logs hold the crash reason;
the current one may be too young to have logged anything.</li>
  <li><code class="language-plaintext highlighter-rouge">:events</code> sorted by time — anything cluster-wide (evictions, image pulls,
OOM kills) that the pod view doesn’t show.</li>
</ol>]]></content><author><name>Corentin</name></author><category term="kubernetes" /><category term="tools" /><category term="kubernetes" /><category term="kubectl" /><category term="k9s" /><category term="cli" /><category term="cheatsheet" /><summary type="html"><![CDATA[The commands I actually use. kubectl for scripting and precision, k9s for interactive triage. kubectl — context &amp; namespace Action Command List contexts kubectl config get-contexts Switch context kubectl config use-context NAME Current context kubectl config current-context Set default namespace kubectl config set-context --current --namespace=NS kubectl — inspect Action Command Pods with node + IP kubectl get pods -o wide Everything in a namespace kubectl get all -n NS Watch pods change kubectl get pods -w Describe (events at the bottom) kubectl describe pod POD Recent cluster events kubectl get events --sort-by=.lastTimestamp Resource usage kubectl top pods / kubectl top nodes Full YAML of a live object kubectl get deploy NAME -o yaml One field via jsonpath kubectl get pod POD -o jsonpath='{.status.podIP}' Find pods by label kubectl get pods -l app=NAME -A kubectl — logs Action Command Follow logs kubectl logs -f POD Specific container kubectl logs POD -c CONTAINER Previous crashed container kubectl logs POD --previous All pods of a deployment kubectl logs -f deploy/NAME Last hour only kubectl logs POD --since=1h kubectl — act Action Command Shell into a pod kubectl exec -it POD -- sh Restart a deployment kubectl rollout restart deploy/NAME Watch a rollout kubectl rollout status deploy/NAME Undo a rollout kubectl rollout undo deploy/NAME Scale kubectl scale deploy/NAME --replicas=3 Port-forward kubectl port-forward svc/NAME 8080:80 Copy file out of a pod kubectl cp NS/POD:/path/file ./file Apply / diff first kubectl apply -f f.yaml / kubectl diff -f f.yaml Throwaway debug pod kubectl run tmp --rm -it --image=busybox -- sh Cordon + drain a node kubectl cordon NODE &amp;&amp; kubectl drain NODE --ignore-daemonsets Watch out for short-name collisions: on clusters with extra CRDs, kubectl get backup may resolve to a different resource than you expect — prefer the fully-qualified form (backups.velero.io) in scripts. k9s — navigation Everything starts with : (command mode) or / (filter). Action Keys Open resource view :pods, :deploy, :svc, :nodes, … Any CRD too :applications, :certificates, … Filter rows /pattern (regex), Esc to clear Invert filter /!pattern Switch namespace :ns then Enter on one, or 0 for all Switch context :ctx Go back Esc Quit :q or Ctrl-c k9s — on a selected pod Action Keys Logs l (then f to toggle follow, p for previous container) Shell s Describe d YAML y Delete Ctrl-d Kill (no grace) Ctrl-k Port-forward Shift-f Sort by CPU / memory Shift-c / Shift-m Mark pod (multi-select) Space k9s — wider views Action Keys Events for the cluster :events Pulses (cluster overview) :pulses xray (resource tree) :xray deploy NS Popeye (lint the cluster) :popeye Toggle wide columns Ctrl-w Toggle header Ctrl-e The triage loop My default incident sequence, entirely in k9s: :pods then / on the app name — anything not Running? d on the suspect — read the Events section first, not the spec. l then p — the previous container’s logs hold the crash reason; the current one may be too young to have logged anything. :events sorted by time — anything cluster-wide (evictions, image pulls, OOM kills) that the pod view doesn’t show.]]></summary></entry><entry><title type="html">tmux cheatsheet — surviving dropped SSH connections</title><link href="https://blog.oyatrino.com/2026/07/tmux-cheatsheet.html" rel="alternate" type="text/html" title="tmux cheatsheet — surviving dropped SSH connections" /><published>2026-07-11T12:00:00-04:00</published><updated>2026-07-11T12:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/tmux-cheatsheet</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/tmux-cheatsheet.html"><![CDATA[<p>The commands I actually use. The main use case: a long-running command on a
remote host that must survive the SSH connection dying.</p>

<p><code class="language-plaintext highlighter-rouge">Ctrl-b</code> is the <strong>prefix</strong>. Notation <code class="language-plaintext highlighter-rouge">C-b d</code> = press <code class="language-plaintext highlighter-rouge">Ctrl-b</code>, release, then press <code class="language-plaintext highlighter-rouge">d</code>.</p>

<h2 id="survive-a-dropped-ssh-connection">Survive a dropped SSH connection</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tmux new <span class="nt">-s</span> work          <span class="c"># start a named session</span>
<span class="c"># ... launch your long-running command inside ...</span>
<span class="c"># C-b d                   # detach — command keeps running on the server</span>
<span class="c"># (connection can now die safely)</span>

tmux attach <span class="nt">-t</span> work       <span class="c"># reattach later</span>
tmux attach <span class="nt">-d</span> <span class="nt">-t</span> work    <span class="c"># force-attach, detaching any stale client</span>
</code></pre></div></div>

<h2 id="sessions">Sessions</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Command / keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>New named session</td>
      <td><code class="language-plaintext highlighter-rouge">tmux new -s NAME</code></td>
    </tr>
    <tr>
      <td>List sessions</td>
      <td><code class="language-plaintext highlighter-rouge">tmux ls</code></td>
    </tr>
    <tr>
      <td>Attach to one</td>
      <td><code class="language-plaintext highlighter-rouge">tmux attach -t NAME</code></td>
    </tr>
    <tr>
      <td>Force attach (kick others)</td>
      <td><code class="language-plaintext highlighter-rouge">tmux attach -d -t NAME</code></td>
    </tr>
    <tr>
      <td>Detach (from inside)</td>
      <td><code class="language-plaintext highlighter-rouge">C-b d</code></td>
    </tr>
    <tr>
      <td>Rename session</td>
      <td><code class="language-plaintext highlighter-rouge">C-b $</code></td>
    </tr>
    <tr>
      <td>Kill a session</td>
      <td><code class="language-plaintext highlighter-rouge">tmux kill-session -t NAME</code></td>
    </tr>
    <tr>
      <td>Kill the server (all)</td>
      <td><code class="language-plaintext highlighter-rouge">tmux kill-server</code></td>
    </tr>
    <tr>
      <td>Switch session (inside)</td>
      <td><code class="language-plaintext highlighter-rouge">C-b s</code> then arrows</td>
    </tr>
  </tbody>
</table>

<h2 id="windows-like-tabs">Windows (like tabs)</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>New window</td>
      <td><code class="language-plaintext highlighter-rouge">C-b c</code></td>
    </tr>
    <tr>
      <td>Next / previous</td>
      <td><code class="language-plaintext highlighter-rouge">C-b n</code> / <code class="language-plaintext highlighter-rouge">C-b p</code></td>
    </tr>
    <tr>
      <td>Go to window N</td>
      <td><code class="language-plaintext highlighter-rouge">C-b 0</code>…<code class="language-plaintext highlighter-rouge">9</code></td>
    </tr>
    <tr>
      <td>Rename window</td>
      <td><code class="language-plaintext highlighter-rouge">C-b ,</code></td>
    </tr>
    <tr>
      <td>Close window</td>
      <td><code class="language-plaintext highlighter-rouge">C-b &amp;</code> (or just <code class="language-plaintext highlighter-rouge">exit</code>)</td>
    </tr>
    <tr>
      <td>List / pick window</td>
      <td><code class="language-plaintext highlighter-rouge">C-b w</code></td>
    </tr>
  </tbody>
</table>

<h2 id="panes-splits">Panes (splits)</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Split vertical (left/right)</td>
      <td><code class="language-plaintext highlighter-rouge">C-b %</code></td>
    </tr>
    <tr>
      <td>Split horizontal (top/bottom)</td>
      <td><code class="language-plaintext highlighter-rouge">C-b "</code></td>
    </tr>
    <tr>
      <td>Move between panes</td>
      <td><code class="language-plaintext highlighter-rouge">C-b</code> + arrow keys</td>
    </tr>
    <tr>
      <td>Cycle panes</td>
      <td><code class="language-plaintext highlighter-rouge">C-b o</code></td>
    </tr>
    <tr>
      <td>Toggle zoom (fullscreen pane)</td>
      <td><code class="language-plaintext highlighter-rouge">C-b z</code></td>
    </tr>
    <tr>
      <td>Close pane</td>
      <td><code class="language-plaintext highlighter-rouge">C-b x</code> (or <code class="language-plaintext highlighter-rouge">exit</code>)</td>
    </tr>
    <tr>
      <td>Convert pane to window</td>
      <td><code class="language-plaintext highlighter-rouge">C-b !</code></td>
    </tr>
  </tbody>
</table>

<h2 id="scrolling--copy-mode">Scrolling &amp; copy mode</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Enter scroll/copy mode</td>
      <td><code class="language-plaintext highlighter-rouge">C-b [</code></td>
    </tr>
    <tr>
      <td>Scroll</td>
      <td>arrows / PgUp / PgDn</td>
    </tr>
    <tr>
      <td>Search up / down</td>
      <td><code class="language-plaintext highlighter-rouge">?</code> / <code class="language-plaintext highlighter-rouge">/</code> (in copy mode)</td>
    </tr>
    <tr>
      <td>Quit copy mode</td>
      <td><code class="language-plaintext highlighter-rouge">q</code></td>
    </tr>
  </tbody>
</table>

<h2 id="handy">Handy</h2>

<table>
  <thead>
    <tr>
      <th>Action</th>
      <th>Keys</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Command prompt</td>
      <td><code class="language-plaintext highlighter-rouge">C-b :</code></td>
    </tr>
    <tr>
      <td>List all key bindings</td>
      <td><code class="language-plaintext highlighter-rouge">C-b ?</code></td>
    </tr>
    <tr>
      <td>Reload config</td>
      <td><code class="language-plaintext highlighter-rouge">C-b :</code> then <code class="language-plaintext highlighter-rouge">source-file ~/.tmux.conf</code></td>
    </tr>
  </tbody>
</table>

<h2 id="watch-a-long-job-after-reattaching">Watch a long job after reattaching</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">tail</span> <span class="nt">-f</span> my-long-job-<span class="k">*</span>.log     <span class="c"># Ctrl-c to stop tailing (job keeps running)</span>
</code></pre></div></div>

<h2 id="minimal-tmuxconf-niceties-optional">Minimal <code class="language-plaintext highlighter-rouge">~/.tmux.conf</code> niceties (optional)</h2>

<pre><code class="language-tmux">set -g history-limit 100000      # bigger scrollback
set -g mouse on                  # mouse scroll/select panes
setw -g mode-keys vi             # vi keys in copy mode
</code></pre>]]></content><author><name>Corentin</name></author><category term="shell" /><category term="tools" /><category term="tmux" /><category term="ssh" /><category term="terminal" /><category term="cli" /><category term="cheatsheet" /><summary type="html"><![CDATA[The commands I actually use. The main use case: a long-running command on a remote host that must survive the SSH connection dying. Ctrl-b is the prefix. Notation C-b d = press Ctrl-b, release, then press d. Survive a dropped SSH connection tmux new -s work # start a named session # ... launch your long-running command inside ... # C-b d # detach — command keeps running on the server # (connection can now die safely) tmux attach -t work # reattach later tmux attach -d -t work # force-attach, detaching any stale client Sessions Action Command / keys New named session tmux new -s NAME List sessions tmux ls Attach to one tmux attach -t NAME Force attach (kick others) tmux attach -d -t NAME Detach (from inside) C-b d Rename session C-b $ Kill a session tmux kill-session -t NAME Kill the server (all) tmux kill-server Switch session (inside) C-b s then arrows Windows (like tabs) Action Keys New window C-b c Next / previous C-b n / C-b p Go to window N C-b 0…9 Rename window C-b , Close window C-b &amp; (or just exit) List / pick window C-b w Panes (splits) Action Keys Split vertical (left/right) C-b % Split horizontal (top/bottom) C-b " Move between panes C-b + arrow keys Cycle panes C-b o Toggle zoom (fullscreen pane) C-b z Close pane C-b x (or exit) Convert pane to window C-b ! Scrolling &amp; copy mode Action Keys Enter scroll/copy mode C-b [ Scroll arrows / PgUp / PgDn Search up / down ? / / (in copy mode) Quit copy mode q Handy Action Keys Command prompt C-b : List all key bindings C-b ? Reload config C-b : then source-file ~/.tmux.conf Watch a long job after reattaching tail -f my-long-job-*.log # Ctrl-c to stop tailing (job keeps running) Minimal ~/.tmux.conf niceties (optional) set -g history-limit 100000 # bigger scrollback set -g mouse on # mouse scroll/select panes setw -g mode-keys vi # vi keys in copy mode]]></summary></entry><entry><title type="html">Materializing an upstream API into git with scheduled CI</title><link href="https://blog.oyatrino.com/2026/07/syncing-a-committed-config-file-from-an-upstream-api-with-scheduled-ci.html" rel="alternate" type="text/html" title="Materializing an upstream API into git with scheduled CI" /><published>2026-07-11T11:00:00-04:00</published><updated>2026-07-11T11:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/syncing-a-committed-config-file-from-an-upstream-api-with-scheduled-ci</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/syncing-a-committed-config-file-from-an-upstream-api-with-scheduled-ci.html"><![CDATA[<p>Some <strong>upstream API is the source of truth</strong> for a list your infrastructure depends on — live networks, regions, tenants, feature flags — and you need that list inside your repo so your tooling (rendering, pinning, review, dependency bots) can act on it.</p>

<p>Two options: read the upstream live at deploy time, or <strong>materialize it into git</strong> — fetch it on a schedule, write it to a committed file, let a bot commit the diff. This post is about the second one: when it’s the right call, and a reusable way to build it in GitLab CI. The concrete example is the public Tezos test-network registry at <a href="https://teztnets.com/teztnets.json"><code class="language-plaintext highlighter-rouge">teztnets.com/teztnets.json</code></a>, but nothing here is specific to it.</p>

<h2 id="why-commit-it-instead-of-reading-it-live">Why commit it instead of reading it live</h2>

<p>Reading upstream live is simpler and always current. But materializing into git buys you things a live read can’t:</p>

<ul>
  <li><strong>Auditable history.</strong> Every change to the list is a commit with a diff and a timestamp. “When did <code class="language-plaintext highlighter-rouge">foonet</code> appear?” is <code class="language-plaintext highlighter-rouge">git log</code>, not a guess.</li>
  <li><strong>Review and pinning.</strong> The committed file can carry <em>your</em> metadata per row — a pinned image tag, a release channel, a rollout policy — that upstream doesn’t know about. A live read would flatten all of that away.</li>
  <li><strong>A stable interface for other automation.</strong> A dependency bot (Renovate/Dependabot) can bump the pinned versions in the file; renderers can read it deterministically; nothing at deploy time depends on the upstream being reachable.</li>
  <li><strong>Decoupled failure.</strong> If upstream has a bad day, your last-known-good file is still in git. A live read fails your reconcile.</li>
</ul>

<p>The cost is a moving part — a scheduled job — and a few footguns. Here’s the whole thing.</p>

<h2 id="a-reusable-parameterized-job">A reusable, parameterized job</h2>

<p>The core insight that keeps this from sprawling: make the job take the <em>list of files to regenerate</em> as an <strong>input</strong>, so one job template covers every such file in the repo. GitLab’s <code class="language-plaintext highlighter-rouge">spec.inputs</code> does this — a typed array parameter:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .gitlab/generate-networks.yml</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">files</span><span class="pi">:</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">array</span>
      <span class="na">description</span><span class="pi">:</span> <span class="pi">&gt;-</span>
        <span class="s">Repo-root paths to regenerate from the upstream registry.</span>
        <span class="s">Add a path here to bring another file under scheduled regeneration.</span>
<span class="s">---</span>
<span class="na">generate-networks</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">generate</span>
  <span class="na">image</span><span class="pi">:</span> <span class="s">alpine:3.22.1</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$CI_PIPELINE_SOURCE</span><span class="nv"> </span><span class="s">==</span><span class="nv"> </span><span class="s">"schedule"'</span>   <span class="c1"># only on a schedule</span>
    <span class="pi">-</span> <span class="na">when</span><span class="pi">:</span> <span class="s">never</span>
  <span class="na">before_script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">apk add --no-cache curl jq git bash</span>
    <span class="pi">-</span> <span class="s">git config --global user.email "ci-bot@example.com"</span>
    <span class="pi">-</span> <span class="s">git config --global user.name  "CI Bot"</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s"># inputs.files renders as a JSON array; regenerate each, stage all,</span>
      <span class="s"># commit once so concurrent pushes never race.</span>
      <span class="s">echo '$[[ inputs.files ]]' | jq -r '.[]' | while IFS= read -r target; do</span>
        <span class="s">echo "=== Regenerating $target ==="</span>
        <span class="s">bash .gitlab/generate-networks.sh "$target"</span>
        <span class="s">git add "$target"</span>
      <span class="s">done</span>
      <span class="s">if git diff --cached --quiet; then</span>
        <span class="s">echo "No changes"</span>
      <span class="s">else</span>
        <span class="s">git commit -m "chore: update networks files [ci skip]"</span>
        <span class="s">git push "https://ci-bot:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" \</span>
          <span class="s">HEAD:${CI_COMMIT_REF_NAME}</span>
      <span class="s">fi</span>
</code></pre></div></div>

<p>and you include it, passing the files, from your main pipeline:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .gitlab-ci.yml</span>
<span class="na">include</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">local</span><span class="pi">:</span> <span class="s">.gitlab/generate-networks.yml</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">files</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">networks-teztale.json</span>
        <span class="pi">-</span> <span class="s">networks-test.json</span>      <span class="c1"># add a path -&gt; it's now on the schedule</span>
</code></pre></div></div>

<p>Three details in that job matter:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">rules</code> pins it to <code class="language-plaintext highlighter-rouge">schedule</code>.</strong> It never runs on push or MR — only from a GitLab <em>scheduled pipeline</em> (say, nightly). Everything else is <code class="language-plaintext highlighter-rouge">when: never</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">[ci skip]</code> on the commit</strong> stops the bot’s own push from triggering another pipeline — no loops.</li>
  <li><strong>One commit for all files.</strong> Regenerating each and committing together means a second scheduled run (or a human push) can’t interleave with a half-written batch.</li>
</ul>

<h2 id="the-regeneration-script">The regeneration script</h2>

<p>The job delegates per-file logic to a script. The non-obvious requirements: <strong>don’t clobber the metadata you keep locally</strong>, give <strong>new</strong> rows a sensible default, and <strong>drop</strong> rows that vanished upstream.</p>

<p>Say each row in the file carries an “upgrade-stream” schema you maintain — a pinned tag, a release channel, a policy — that upstream doesn’t provide:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpha"</span><span class="p">,</span><span class="w"> </span><span class="nl">"octezTag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"octez-v25.0"</span><span class="p">,</span><span class="w"> </span><span class="nl">"octezChannel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stable"</span><span class="p">,</span><span class="w"> </span><span class="nl">"octezPolicy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"manual"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>The script fetches upstream, then merges: keep existing rows verbatim (preserving your pins), add newcomers inheriting defaults from the first existing row, and let disappearances fall off:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail
<span class="nv">target_path</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">1</span>:?usage:<span class="p"> </span><span class="nv">$0</span><span class="p"> &lt;target_path&gt;</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">url</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">TEZTNETS_URL</span><span class="k">:-</span><span class="nv">https</span>://teztnets.com/teztnets.json<span class="k">}</span><span class="s2">"</span>

<span class="c"># Derive defaults for new rows from the first existing entry's schema.</span>
<span class="nv">default_tag</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.[0].octezTag // empty'</span>     <span class="s2">"</span><span class="nv">$target_path</span><span class="s2">"</span><span class="si">)</span>
<span class="nv">default_channel</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.[0].octezChannel // empty'</span> <span class="s2">"</span><span class="nv">$target_path</span><span class="s2">"</span><span class="si">)</span>
<span class="nv">default_policy</span><span class="o">=</span><span class="si">$(</span>jq <span class="nt">-r</span> <span class="s1">'.[0].octezPolicy // empty'</span>   <span class="s2">"</span><span class="nv">$target_path</span><span class="s2">"</span><span class="si">)</span>

<span class="c"># Build into a temp file and mv into place: the target is BOTH read (for the</span>
<span class="c"># existing schema) and written, and a `&gt; "$target"` redirect would truncate it</span>
<span class="c"># before jq could slurp it. This is the footgun.</span>
<span class="nv">tmp</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span><span class="s2">"</span><span class="p">;</span> <span class="nb">trap</span> <span class="s1">'rm -f "$tmp"'</span> EXIT

curl <span class="nt">-fsS</span> <span class="s2">"</span><span class="nv">$url</span><span class="s2">"</span> <span class="se">\</span>
  | jq <span class="nt">--slurpfile</span> existing <span class="s2">"</span><span class="nv">$target_path</span><span class="s2">"</span> <span class="se">\</span>
       <span class="nt">--arg</span> dt <span class="s2">"</span><span class="nv">$default_tag</span><span class="s2">"</span> <span class="nt">--arg</span> dc <span class="s2">"</span><span class="nv">$default_channel</span><span class="s2">"</span> <span class="nt">--arg</span> dp <span class="s2">"</span><span class="nv">$default_policy</span><span class="s2">"</span> <span class="se">\</span>
    <span class="s1">'($existing[0] | map({(.name): .}) | add) as $by_name
     | to_entries
     | map(select(.value.aliasOf == null) | .key)
     | map(. as $n | $by_name[$n]
           // {name: $n, octezTag: $dt, octezChannel: $dc, octezPolicy: $dp})'</span> <span class="se">\</span>
  <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span>

<span class="nb">mv</span> <span class="s2">"</span><span class="nv">$tmp</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$target_path</span><span class="s2">"</span><span class="p">;</span> <span class="nb">trap</span> - EXIT
</code></pre></div></div>

<p>Reading it top to bottom: build a name→row lookup of what’s already committed; take upstream’s canonical entries (<code class="language-plaintext highlighter-rouge">aliasOf == null</code>); for each, <strong>reuse the committed row if it exists</strong> (keeping your pins untouched), otherwise <strong>synthesize a new row</strong> on the fleet’s default tag/channel/policy. Rows not in upstream simply aren’t emitted — they drop out, and that deletion shows up as a reviewable diff on the next run.</p>

<p>That “reuse if present, default if new” merge is why a dependency bot can bump <code class="language-plaintext highlighter-rouge">octezTag</code> on one row and the next regeneration won’t stomp it — the row already exists, so it’s preserved verbatim.</p>

<h2 id="the-two-footguns">The two footguns</h2>

<ol>
  <li><strong>Truncation.</strong> The file is read and written in the same step. <code class="language-plaintext highlighter-rouge">jq … &gt; "$target"</code> truncates it to empty <em>before</em> <code class="language-plaintext highlighter-rouge">jq</code> opens it via <code class="language-plaintext highlighter-rouge">--slurpfile</code>, so you lose your existing schema and every new row gets <code class="language-plaintext highlighter-rouge">null</code> defaults. Build in a temp file, <code class="language-plaintext highlighter-rouge">mv</code> into place.</li>
  <li><strong>New-row defaults must come from somewhere.</strong> If the file is empty, there’s no first row to inherit from, and a new network lands with <code class="language-plaintext highlighter-rouge">null</code> tag/channel/policy. Guard for it: refuse to run (or seed one canonical row by hand) rather than emit half-populated entries.</li>
</ol>

<h2 id="when-to-use-which">When to use which</h2>

<p>Materialize-into-git and read-live are two ends of a spectrum:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Scheduled regenerate-and-commit</th>
      <th>Live read at deploy time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>History / audit</td>
      <td>Yes — every change is a commit</td>
      <td>No</td>
    </tr>
    <tr>
      <td>Per-row local metadata (pins, policy)</td>
      <td>Preserved through merge</td>
      <td>Lost</td>
    </tr>
    <tr>
      <td>Works with dependency bots</td>
      <td>Yes</td>
      <td>No</td>
    </tr>
    <tr>
      <td>Upstream outage at deploy</td>
      <td>Uses last-known-good</td>
      <td>Fails</td>
    </tr>
    <tr>
      <td>Freshness</td>
      <td>As fresh as the schedule</td>
      <td>Instant</td>
    </tr>
    <tr>
      <td>Moving parts</td>
      <td>A scheduled job to own</td>
      <td>None</td>
    </tr>
  </tbody>
</table>

<p>If the list is disposable and you <em>want</em> to track upstream instantly, read it live. If rows carry state you care about — pins you review, policies you enforce, a history you audit — regenerate on a schedule and let the diff speak. The pattern above is about forty lines of YAML and jq, reusable across every such file in the repo.</p>]]></content><author><name>Corentin</name></author><category term="gitops" /><category term="ci" /><category term="gitlab-ci" /><category term="gitops" /><category term="jq" /><category term="automation" /><category term="scheduled-pipelines" /><category term="ci-components" /><summary type="html"><![CDATA[Some upstream API is the source of truth for a list your infrastructure depends on — live networks, regions, tenants, feature flags — and you need that list inside your repo so your tooling (rendering, pinning, review, dependency bots) can act on it. Two options: read the upstream live at deploy time, or materialize it into git — fetch it on a schedule, write it to a committed file, let a bot commit the diff. This post is about the second one: when it’s the right call, and a reusable way to build it in GitLab CI. The concrete example is the public Tezos test-network registry at teztnets.com/teztnets.json, but nothing here is specific to it. Why commit it instead of reading it live Reading upstream live is simpler and always current. But materializing into git buys you things a live read can’t: Auditable history. Every change to the list is a commit with a diff and a timestamp. “When did foonet appear?” is git log, not a guess. Review and pinning. The committed file can carry your metadata per row — a pinned image tag, a release channel, a rollout policy — that upstream doesn’t know about. A live read would flatten all of that away. A stable interface for other automation. A dependency bot (Renovate/Dependabot) can bump the pinned versions in the file; renderers can read it deterministically; nothing at deploy time depends on the upstream being reachable. Decoupled failure. If upstream has a bad day, your last-known-good file is still in git. A live read fails your reconcile. The cost is a moving part — a scheduled job — and a few footguns. Here’s the whole thing. A reusable, parameterized job The core insight that keeps this from sprawling: make the job take the list of files to regenerate as an input, so one job template covers every such file in the repo. GitLab’s spec.inputs does this — a typed array parameter: # .gitlab/generate-networks.yml spec: inputs: files: type: array description: &gt;- Repo-root paths to regenerate from the upstream registry. Add a path here to bring another file under scheduled regeneration. --- generate-networks: stage: generate image: alpine:3.22.1 rules: - if: '$CI_PIPELINE_SOURCE == "schedule"' # only on a schedule - when: never before_script: - apk add --no-cache curl jq git bash - git config --global user.email "ci-bot@example.com" - git config --global user.name "CI Bot" script: - | set -euo pipefail # inputs.files renders as a JSON array; regenerate each, stage all, # commit once so concurrent pushes never race. echo '$[[ inputs.files ]]' | jq -r '.[]' | while IFS= read -r target; do echo "=== Regenerating $target ===" bash .gitlab/generate-networks.sh "$target" git add "$target" done if git diff --cached --quiet; then echo "No changes" else git commit -m "chore: update networks files [ci skip]" git push "https://ci-bot:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" \ HEAD:${CI_COMMIT_REF_NAME} fi and you include it, passing the files, from your main pipeline: # .gitlab-ci.yml include: - local: .gitlab/generate-networks.yml inputs: files: - networks-teztale.json - networks-test.json # add a path -&gt; it's now on the schedule Three details in that job matter: rules pins it to schedule. It never runs on push or MR — only from a GitLab scheduled pipeline (say, nightly). Everything else is when: never. [ci skip] on the commit stops the bot’s own push from triggering another pipeline — no loops. One commit for all files. Regenerating each and committing together means a second scheduled run (or a human push) can’t interleave with a half-written batch. The regeneration script The job delegates per-file logic to a script. The non-obvious requirements: don’t clobber the metadata you keep locally, give new rows a sensible default, and drop rows that vanished upstream. Say each row in the file carries an “upgrade-stream” schema you maintain — a pinned tag, a release channel, a policy — that upstream doesn’t provide: [ { "name": "alpha", "octezTag": "octez-v25.0", "octezChannel": "stable", "octezPolicy": "manual" } ] The script fetches upstream, then merges: keep existing rows verbatim (preserving your pins), add newcomers inheriting defaults from the first existing row, and let disappearances fall off: #!/usr/bin/env bash set -euo pipefail target_path="${1:?usage: $0 &lt;target_path&gt;}" url="${TEZTNETS_URL:-https://teztnets.com/teztnets.json}" # Derive defaults for new rows from the first existing entry's schema. default_tag=$(jq -r '.[0].octezTag // empty' "$target_path") default_channel=$(jq -r '.[0].octezChannel // empty' "$target_path") default_policy=$(jq -r '.[0].octezPolicy // empty' "$target_path") # Build into a temp file and mv into place: the target is BOTH read (for the # existing schema) and written, and a `&gt; "$target"` redirect would truncate it # before jq could slurp it. This is the footgun. tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT curl -fsS "$url" \ | jq --slurpfile existing "$target_path" \ --arg dt "$default_tag" --arg dc "$default_channel" --arg dp "$default_policy" \ '($existing[0] | map({(.name): .}) | add) as $by_name | to_entries | map(select(.value.aliasOf == null) | .key) | map(. as $n | $by_name[$n] // {name: $n, octezTag: $dt, octezChannel: $dc, octezPolicy: $dp})' \ &gt; "$tmp" mv "$tmp" "$target_path"; trap - EXIT Reading it top to bottom: build a name→row lookup of what’s already committed; take upstream’s canonical entries (aliasOf == null); for each, reuse the committed row if it exists (keeping your pins untouched), otherwise synthesize a new row on the fleet’s default tag/channel/policy. Rows not in upstream simply aren’t emitted — they drop out, and that deletion shows up as a reviewable diff on the next run. That “reuse if present, default if new” merge is why a dependency bot can bump octezTag on one row and the next regeneration won’t stomp it — the row already exists, so it’s preserved verbatim. The two footguns Truncation. The file is read and written in the same step. jq … &gt; "$target" truncates it to empty before jq opens it via --slurpfile, so you lose your existing schema and every new row gets null defaults. Build in a temp file, mv into place. New-row defaults must come from somewhere. If the file is empty, there’s no first row to inherit from, and a new network lands with null tag/channel/policy. Guard for it: refuse to run (or seed one canonical row by hand) rather than emit half-populated entries. When to use which Materialize-into-git and read-live are two ends of a spectrum:   Scheduled regenerate-and-commit Live read at deploy time History / audit Yes — every change is a commit No Per-row local metadata (pins, policy) Preserved through merge Lost Works with dependency bots Yes No Upstream outage at deploy Uses last-known-good Fails Freshness As fresh as the schedule Instant Moving parts A scheduled job to own None If the list is disposable and you want to track upstream instantly, read it live. If rows carry state you care about — pins you review, policies you enforce, a history you audit — regenerate on a schedule and let the diff speak. The pattern above is about forty lines of YAML and jq, reusable across every such file in the repo.]]></summary></entry><entry><title type="html">Auto-onboarding Tezos test networks with teztnets.json</title><link href="https://blog.oyatrino.com/2026/07/auto-onboarding-tezos-testnets-with-teztnets-json.html" rel="alternate" type="text/html" title="Auto-onboarding Tezos test networks with teztnets.json" /><published>2026-07-11T08:00:00-04:00</published><updated>2026-07-11T08:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/auto-onboarding-tezos-testnets-with-teztnets-json</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/auto-onboarding-tezos-testnets-with-teztnets-json.html"><![CDATA[<p>I run a small fleet of <a href="https://tezos.gitlab.io/"><code class="language-plaintext highlighter-rouge">octez-node</code></a> instances — one Tezos node per active test network — on Kubernetes, deployed by ArgoCD. For a long time the list of “which networks exist” lived in a JSON file I maintained by hand in the GitOps repo: a network spins up upstream, I open an MR to add it, wait for review, merge; it gets decommissioned, I open another MR to remove it.</p>

<p>The annoying part is that the list already exists, upstream, and is already kept current. Tezos publishes the canonical registry of live test networks at <strong><a href="https://teztnets.com/teztnets.json">https://teztnets.com/teztnets.json</a></strong> — every network, its category, and the exact <code class="language-plaintext highlighter-rouge">octez</code> image it expects. Copying that into my repo by hand is busywork. I wanted the <code class="language-plaintext highlighter-rouge">ApplicationSet</code> to read it directly.</p>

<p>This is a concrete application of the <a href="/2026/07/driving-argocd-applicationsets-from-any-json-api.html">ApplicationSet plugin generator</a> from the previous post — read that first for how the plugin works and how to deploy it (raw manifests, in the ArgoCD controller’s namespace). Here I’ll just wire it to <code class="language-plaintext highlighter-rouge">teztnets.json</code>.</p>

<h2 id="what-teztnetsjson-looks-like">What teztnets.json looks like</h2>

<p>It’s an object keyed by network name. Trimmed:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"ghostnet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"aliasOf"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
    </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Long-running Teztnets"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"docker_build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tezos/tezos:octez-v23.1"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"ushuaianet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"aliasOf"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
    </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Protocol Teztnets"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"docker_build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tezos/tezos:octez-v25.0"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"ushuaianet-20260701"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"aliasOf"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ushuaianet"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Protocol Teztnets"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Three things matter for my use case:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">aliasOf</code></strong> — dated snapshots alias the rolling name; I only want the canonical entries (<code class="language-plaintext highlighter-rouge">aliasOf == null</code>).</li>
  <li><strong><code class="language-plaintext highlighter-rouge">category</code></strong> — I only want the short-lived <em>protocol-test</em> networks, not the long-running public ones (which I run differently).</li>
  <li><strong><code class="language-plaintext highlighter-rouge">docker_build</code></strong> — <code class="language-plaintext highlighter-rouge">tezos/tezos:octez-v25.0</code>; I need the tag part, <code class="language-plaintext highlighter-rouge">octez-v25.0</code>, to pin the node image per network.</li>
</ul>

<h2 id="the-filter">The filter</h2>

<p>The plugin’s job is to turn that object into a flat list of <code class="language-plaintext highlighter-rouge">{name, octezTag}</code>. As a <code class="language-plaintext highlighter-rouge">jq</code> filter (<code class="language-plaintext highlighter-rouge">JSON_FILTER</code>):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>to_entries
| map(select(.value.aliasOf == null
             and (.value.category | test("Protocol"))))
| map({
    name: .key,
    octezTag: (.value.docker_build | split(":")[1])
  })
</code></pre></div></div>

<p>Fed the live payload, today that returns exactly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ushuaianet"</span><span class="p">,</span><span class="w"> </span><span class="nl">"octezTag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"octez-v25.0"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>One network now; more when upstream announces the next protocol test net. <code class="language-plaintext highlighter-rouge">JSON_URL</code> is <code class="language-plaintext highlighter-rouge">https://teztnets.com/teztnets.json</code>, and that filter is the only network-specific configuration the plugin needs.</p>

<h2 id="the-applicationset">The ApplicationSet</h2>

<p>The generator is a <code class="language-plaintext highlighter-rouge">matrix</code> of the plugin (which networks?) against a single-element <code class="language-plaintext highlighter-rouge">list</code> (which cluster do the nodes run on?). The template stamps one <code class="language-plaintext highlighter-rouge">octez-node</code> per network, using the network name and the image tag the plugin extracted:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">argoproj.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ApplicationSet</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">octez-test</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">goTemplate</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">goTemplateOptions</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">missingkey=error"</span><span class="pi">]</span>
  <span class="na">generators</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">matrix</span><span class="pi">:</span>
        <span class="na">generators</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">plugin</span><span class="pi">:</span>
              <span class="na">configMapRef</span><span class="pi">:</span>
                <span class="na">name</span><span class="pi">:</span> <span class="s">json-plugin</span>      <span class="c1"># the teztnets-configured plugin</span>
              <span class="na">input</span><span class="pi">:</span>
                <span class="na">parameters</span><span class="pi">:</span> <span class="pi">{}</span>
              <span class="na">requeueAfterSeconds</span><span class="pi">:</span> <span class="m">300</span>
          <span class="pi">-</span> <span class="na">list</span><span class="pi">:</span>
              <span class="na">elements</span><span class="pi">:</span>
                <span class="pi">-</span> <span class="na">cluster</span><span class="pi">:</span> <span class="s">my-workload-cluster</span>
                  <span class="na">server</span><span class="pi">:</span> <span class="s">https://my-workload-cluster.example</span>   <span class="c1"># where nodes run</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">octez--test"</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">network</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">project</span><span class="pi">:</span> <span class="s">default</span>
      <span class="na">destination</span><span class="pi">:</span>
        <span class="na">server</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
        <span class="na">namespace</span><span class="pi">:</span> <span class="s2">"</span><span class="s">octez--test"</span>
      <span class="na">source</span><span class="pi">:</span>
        <span class="c1"># octez-node packaged as a Helm chart — point repoURL/chart at wherever</span>
        <span class="c1"># you host it. Only the values below are network-specific.</span>
        <span class="na">repoURL</span><span class="pi">:</span> <span class="s">&lt;your-octez-node-chart-repo&gt;</span>
        <span class="na">chart</span><span class="pi">:</span> <span class="s">octez-node</span>
        <span class="na">targetRevision</span><span class="pi">:</span> <span class="s">0.9.1</span>
        <span class="na">helm</span><span class="pi">:</span>
          <span class="na">releaseName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">octez--test"</span>
          <span class="na">values</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">network: ""</span>
            <span class="s">history: "rolling"</span>
            <span class="s">image:</span>
              <span class="s">repository: tezos/tezos</span>
              <span class="s">tag: ""</span>
            <span class="s">persistence:</span>
              <span class="s">enabled: true</span>
              <span class="s">size: 250Gi</span>
      <span class="na">syncPolicy</span><span class="pi">:</span>
        <span class="na">automated</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">prune</span><span class="pi">:</span> <span class="nv">true</span><span class="pi">,</span> <span class="nv">selfHeal</span><span class="pi">:</span> <span class="nv">true</span> <span class="pi">}</span>
        <span class="na">syncOptions</span><span class="pi">:</span> <span class="pi">[</span> <span class="nv">CreateNamespace=true</span> <span class="pi">]</span>
</code></pre></div></div>

<p>Note the split of responsibilities that makes this robust: the <strong>plugin</strong> (running next to the ArgoCD controller) answers <em>which networks exist and on what image</em>; the <strong>template’s <code class="language-plaintext highlighter-rouge">destination</code></strong> decides <em>where the nodes actually run</em>. The two are independent — the plugin can be on your control-plane cluster while every node lands on a separate workload cluster.</p>

<h2 id="the-payoff">The payoff</h2>

<p>With <code class="language-plaintext highlighter-rouge">prune: true</code>, the loop is fully self-maintaining on a five-minute requeue:</p>

<ul>
  <li>a new protocol-test network announced on <code class="language-plaintext highlighter-rouge">teztnets.com</code> becomes a running <code class="language-plaintext highlighter-rouge">octez-{name}-test</code> node with <strong>no MR</strong>;</li>
  <li>a decommissioned network drops out of <code class="language-plaintext highlighter-rouge">teztnets.json</code> and its node is pruned the same way;</li>
  <li>the image tag always matches what upstream says the network expects, because it comes straight from <code class="language-plaintext highlighter-rouge">docker_build</code>.</li>
</ul>

<p>The hand-maintained network file is out of the critical path entirely.</p>

<h2 id="the-trade-off">The trade-off</h2>

<p>Reading the registry live means you follow upstream’s image tag <em>exactly and immediately</em> — which is the point, but also means you’ve handed your rollout timing to <code class="language-plaintext highlighter-rouge">docker_build</code>. If you need to <strong>pin</strong> each network to a reviewed image, gate upgrades behind a policy, or keep an auditable git diff of “what changed and when,” you want a committed file you regenerate on a schedule instead — see the <a href="/2026/07/syncing-a-committed-config-file-from-an-upstream-api-with-scheduled-ci.html">companion post</a>.</p>

<p>For a disposable, follow-upstream test fleet, live is exactly right: nobody has to touch it when the network list changes.</p>]]></content><author><name>Corentin</name></author><category term="kubernetes" /><category term="gitops" /><category term="argocd" /><category term="applicationset" /><category term="tezos" /><category term="octez" /><category term="teztnets" /><category term="jq" /><category term="gitops" /><category term="kubernetes" /><summary type="html"><![CDATA[I run a small fleet of octez-node instances — one Tezos node per active test network — on Kubernetes, deployed by ArgoCD. For a long time the list of “which networks exist” lived in a JSON file I maintained by hand in the GitOps repo: a network spins up upstream, I open an MR to add it, wait for review, merge; it gets decommissioned, I open another MR to remove it. The annoying part is that the list already exists, upstream, and is already kept current. Tezos publishes the canonical registry of live test networks at https://teztnets.com/teztnets.json — every network, its category, and the exact octez image it expects. Copying that into my repo by hand is busywork. I wanted the ApplicationSet to read it directly. This is a concrete application of the ApplicationSet plugin generator from the previous post — read that first for how the plugin works and how to deploy it (raw manifests, in the ArgoCD controller’s namespace). Here I’ll just wire it to teztnets.json. What teztnets.json looks like It’s an object keyed by network name. Trimmed: { "ghostnet": { "aliasOf": null, "category": "Long-running Teztnets", "docker_build": "tezos/tezos:octez-v23.1" }, "ushuaianet": { "aliasOf": null, "category": "Protocol Teztnets", "docker_build": "tezos/tezos:octez-v25.0" }, "ushuaianet-20260701": { "aliasOf": "ushuaianet", "category": "Protocol Teztnets" } } Three things matter for my use case: aliasOf — dated snapshots alias the rolling name; I only want the canonical entries (aliasOf == null). category — I only want the short-lived protocol-test networks, not the long-running public ones (which I run differently). docker_build — tezos/tezos:octez-v25.0; I need the tag part, octez-v25.0, to pin the node image per network. The filter The plugin’s job is to turn that object into a flat list of {name, octezTag}. As a jq filter (JSON_FILTER): to_entries | map(select(.value.aliasOf == null and (.value.category | test("Protocol")))) | map({ name: .key, octezTag: (.value.docker_build | split(":")[1]) }) Fed the live payload, today that returns exactly: [ { "name": "ushuaianet", "octezTag": "octez-v25.0" } ] One network now; more when upstream announces the next protocol test net. JSON_URL is https://teztnets.com/teztnets.json, and that filter is the only network-specific configuration the plugin needs. The ApplicationSet The generator is a matrix of the plugin (which networks?) against a single-element list (which cluster do the nodes run on?). The template stamps one octez-node per network, using the network name and the image tag the plugin extracted: apiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: name: octez-test namespace: argocd spec: goTemplate: true goTemplateOptions: ["missingkey=error"] generators: - matrix: generators: - plugin: configMapRef: name: json-plugin # the teztnets-configured plugin input: parameters: {} requeueAfterSeconds: 300 - list: elements: - cluster: my-workload-cluster server: https://my-workload-cluster.example # where nodes run template: metadata: name: "octez--test" labels: network: "" spec: project: default destination: server: "" namespace: "octez--test" source: # octez-node packaged as a Helm chart — point repoURL/chart at wherever # you host it. Only the values below are network-specific. repoURL: &lt;your-octez-node-chart-repo&gt; chart: octez-node targetRevision: 0.9.1 helm: releaseName: "octez--test" values: | network: "" history: "rolling" image: repository: tezos/tezos tag: "" persistence: enabled: true size: 250Gi syncPolicy: automated: { prune: true, selfHeal: true } syncOptions: [ CreateNamespace=true ] Note the split of responsibilities that makes this robust: the plugin (running next to the ArgoCD controller) answers which networks exist and on what image; the template’s destination decides where the nodes actually run. The two are independent — the plugin can be on your control-plane cluster while every node lands on a separate workload cluster. The payoff With prune: true, the loop is fully self-maintaining on a five-minute requeue: a new protocol-test network announced on teztnets.com becomes a running octez-{name}-test node with no MR; a decommissioned network drops out of teztnets.json and its node is pruned the same way; the image tag always matches what upstream says the network expects, because it comes straight from docker_build. The hand-maintained network file is out of the critical path entirely. The trade-off Reading the registry live means you follow upstream’s image tag exactly and immediately — which is the point, but also means you’ve handed your rollout timing to docker_build. If you need to pin each network to a reviewed image, gate upgrades behind a policy, or keep an auditable git diff of “what changed and when,” you want a committed file you regenerate on a schedule instead — see the companion post. For a disposable, follow-upstream test fleet, live is exactly right: nobody has to touch it when the network list changes.]]></summary></entry><entry><title type="html">Driving ArgoCD ApplicationSets from any JSON API</title><link href="https://blog.oyatrino.com/2026/07/driving-argocd-applicationsets-from-any-json-api.html" rel="alternate" type="text/html" title="Driving ArgoCD ApplicationSets from any JSON API" /><published>2026-07-11T00:00:00-04:00</published><updated>2026-07-11T00:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/driving-argocd-applicationsets-from-any-json-api</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/driving-argocd-applicationsets-from-any-json-api.html"><![CDATA[<p>ArgoCD’s <code class="language-plaintext highlighter-rouge">ApplicationSet</code> ships with generators for the common cases — a static <code class="language-plaintext highlighter-rouge">list</code>, files in <code class="language-plaintext highlighter-rouge">git</code>, registered <code class="language-plaintext highlighter-rouge">clusters</code>, pull requests, SCM org scans. The <strong>plugin generator</strong> covers everything else: any REST API, internal service catalogue, or external JSON feed that already exists and is already kept current by someone else. It’s the least-documented generator of the bunch.</p>

<p>This post is a from-scratch, no-Helm walkthrough of standing one up: the protocol, the raw manifests, how to write the filter, and a few production pitfalls. I’ll use a small open-source plugin — <a href="https://github.com/cmehat/argocd-applicationset-json-plugin"><code class="language-plaintext highlighter-rouge">argocd-applicationset-json-plugin</code></a> — that does one generic job: fetch JSON from a URL, filter it with <code class="language-plaintext highlighter-rouge">jq</code> or JSONPath, and hand the result back to ArgoCD as parameters. The protocol described here applies to any plugin, though.</p>

<h2 id="what-a-plugin-generator-actually-is">What a plugin generator actually is</h2>

<p><strong>A plugin generator does not run your code inside the controller.</strong> It makes an authenticated HTTP call to a service <em>you</em> deploy, and turns the JSON that comes back into generator parameters. The <code class="language-plaintext highlighter-rouge">argocd-applicationset-controller</code> is the client; your plugin is a tiny HTTP server.</p>

<p>The contract is a single route:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /api/v1/getparams.execute
Authorization: Bearer &lt;token&gt;
Content-Type: application/json

{ "applicationSetName": "my-appset", "input": { "parameters": {} } }
</code></pre></div></div>

<p>and the response is a list of parameter objects:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"output"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpha"</span><span class="p">,</span><span class="w"> </span><span class="nl">"region"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eu"</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"beta"</span><span class="p">,</span><span class="w">  </span><span class="nl">"region"</span><span class="p">:</span><span class="w"> </span><span class="s2">"us"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Each object becomes one set of template variables.</p>

<div class="mermaid">
flowchart LR
  G["ApplicationSet<br />(plugin generator)"] --&gt;|reads| CM["ConfigMap<br />baseUrl + token ref"]
  AC["applicationset-controller"] --&gt;|"POST getparams.execute"| SVC["your plugin Service"]
  SVC --&gt; APP["JSON source (any URL)"]
  AC --&gt;|"one Application per parameter"| OUT["Applications"]
</div>

<h2 id="deploying-the-plugin-backend-raw-manifests">Deploying the plugin backend (raw manifests)</h2>

<p>Four objects, all in the <strong>same namespace as the ApplicationSet controller</strong> (usually <code class="language-plaintext highlighter-rouge">argocd</code>). We’ll come back to <em>why</em> that matters.</p>

<p><strong>Secret</strong> — the shared token the controller authenticates with:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Secret</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">json-plugin</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
<span class="na">stringData</span><span class="pi">:</span>
  <span class="na">token</span><span class="pi">:</span> <span class="s2">"</span><span class="s">replace-me-with-a-real-secret"</span>
</code></pre></div></div>

<p><strong>Deployment</strong> — the plugin server. It reads its config from environment variables and the token from a mounted file:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">json-plugin</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">app</span><span class="pi">:</span> <span class="nv">json-plugin</span> <span class="pi">}</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">app</span><span class="pi">:</span> <span class="nv">json-plugin</span> <span class="pi">}</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">plugin</span>
          <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/cmehat/argocd-applicationset-json-plugin:jq-latest</span>
          <span class="na">env</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">JSON_URL</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://api.github.com/users/kubernetes/repos?per_page=100"</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">JSON_FILTER</span>          <span class="c1"># jq expression</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s1">'</span><span class="s">map(select(.archived</span><span class="nv"> </span><span class="s">==</span><span class="nv"> </span><span class="s">false)</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">{name:</span><span class="nv"> </span><span class="s">.name,</span><span class="nv"> </span><span class="s">stars:</span><span class="nv"> </span><span class="s">(.stargazers_count|tostring)})'</span>
          <span class="na">ports</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">http</span><span class="pi">,</span> <span class="nv">containerPort</span><span class="pi">:</span> <span class="nv">4355</span> <span class="pi">}</span>
          <span class="na">volumeMounts</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">token</span><span class="pi">,</span> <span class="nv">mountPath</span><span class="pi">:</span> <span class="nv">/var/run/argo</span><span class="pi">,</span> <span class="nv">readOnly</span><span class="pi">:</span> <span class="nv">true</span> <span class="pi">}</span>
          <span class="c1"># The only route is POST-only; a GET returns 501. Probe the socket,</span>
          <span class="c1"># not the endpoint. (More on this below.)</span>
          <span class="na">readinessProbe</span><span class="pi">:</span>
            <span class="na">tcpSocket</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">port</span><span class="pi">:</span> <span class="nv">4355</span> <span class="pi">}</span>
          <span class="na">livenessProbe</span><span class="pi">:</span>
            <span class="na">tcpSocket</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">port</span><span class="pi">:</span> <span class="nv">4355</span> <span class="pi">}</span>
      <span class="na">volumes</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">token</span>
          <span class="na">secret</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">secretName</span><span class="pi">:</span> <span class="nv">json-plugin</span> <span class="pi">}</span>
</code></pre></div></div>

<p><strong>Service</strong> — how the controller reaches it, in-cluster:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">json-plugin</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">selector</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">app</span><span class="pi">:</span> <span class="nv">json-plugin</span> <span class="pi">}</span>
  <span class="na">ports</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="pi">{</span> <span class="nv">port</span><span class="pi">:</span> <span class="nv">4355</span><span class="pi">,</span> <span class="nv">targetPort</span><span class="pi">:</span> <span class="nv">4355</span> <span class="pi">}</span>
</code></pre></div></div>

<p><strong>ConfigMap</strong> — this is what the <code class="language-plaintext highlighter-rouge">ApplicationSet</code> references. ArgoCD discovers it by the <code class="language-plaintext highlighter-rouge">app.kubernetes.io/part-of: argocd</code> label, and reads the plugin’s URL and token from it:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ConfigMap</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">json-plugin</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app.kubernetes.io/part-of</span><span class="pi">:</span> <span class="s">argocd</span>   <span class="c1"># required for discovery</span>
<span class="na">data</span><span class="pi">:</span>
  <span class="na">baseUrl</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://json-plugin.argocd.svc.cluster.local:4355"</span>   <span class="c1"># include the port!</span>
  <span class="na">token</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$json-plugin:token"</span>           <span class="c1"># $&lt;secret-name&gt;:&lt;key&gt;</span>
</code></pre></div></div>

<h2 id="wiring-the-applicationset">Wiring the ApplicationSet</h2>

<p>Now the generator just points at that ConfigMap by name:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">argoproj.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ApplicationSet</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">repos</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">goTemplate</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">generators</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">plugin</span><span class="pi">:</span>
        <span class="na">configMapRef</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">json-plugin</span>
        <span class="na">input</span><span class="pi">:</span>
          <span class="na">parameters</span><span class="pi">:</span> <span class="pi">{}</span>
        <span class="na">requeueAfterSeconds</span><span class="pi">:</span> <span class="m">300</span>   <span class="c1"># re-fetch the source every 5 min</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">repo-"</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">project</span><span class="pi">:</span> <span class="s">default</span>
      <span class="na">source</span><span class="pi">:</span>
        <span class="na">repoURL</span><span class="pi">:</span> <span class="s">https://github.com/my-org/app-of-apps.git</span>
        <span class="na">targetRevision</span><span class="pi">:</span> <span class="s">main</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">charts/generic"</span>
        <span class="na">helm</span><span class="pi">:</span>
          <span class="na">values</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">displayName: ""</span>
            <span class="s">stars: ""</span>
      <span class="na">destination</span><span class="pi">:</span>
        <span class="na">server</span><span class="pi">:</span> <span class="s">https://kubernetes.default.svc</span>
        <span class="na">namespace</span><span class="pi">:</span> <span class="s2">"</span><span class="s">repo-"</span>
      <span class="na">syncPolicy</span><span class="pi">:</span>
        <span class="na">automated</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">prune</span><span class="pi">:</span> <span class="nv">true</span><span class="pi">,</span> <span class="nv">selfHeal</span><span class="pi">:</span> <span class="nv">true</span> <span class="pi">}</span>
        <span class="na">syncOptions</span><span class="pi">:</span> <span class="pi">[</span> <span class="nv">CreateNamespace=true</span> <span class="pi">]</span>
</code></pre></div></div>

<p>Every object the plugin returns becomes one Application, refreshed on the requeue interval. New entries in the upstream JSON appear on their own; entries that disappear are pruned.</p>

<h2 id="writing-the-filter-jq-or-jsonpath">Writing the filter: jq or JSONPath</h2>

<p>The plugin ships two flavours — a <code class="language-plaintext highlighter-rouge">jq</code> image (<code class="language-plaintext highlighter-rouge">jq-*</code> tags) and a JSONPath image (<code class="language-plaintext highlighter-rouge">jsonpath-*</code> tags, the default). Both take the fetched JSON and must emit a <strong>flat array of objects with string values</strong> (ArgoCD parameters are strings).</p>

<p>A few generic shapes:</p>

<p><strong>Array of objects → pick fields</strong> (e.g. the GitHub repos list above):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>map({name: .name, url: .html_url})
</code></pre></div></div>

<p><strong>Object-of-objects → keys as items</strong>, dropping entries that are aliases of another (a common pattern in registry-style files):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>to_entries
| map(select(.value.aliasOf == null))
| map({name: .key})
</code></pre></div></div>

<p><strong>Filter by a property</strong> — only public, only in a region, only “active”:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>map(select(.status == "active") | {name: .id, region: .region})
</code></pre></div></div>

<p>The JSONPath variant covers the simple cases without a <code class="language-plaintext highlighter-rouge">jq</code> binary — <code class="language-plaintext highlighter-rouge">JSON_PATH=$.*</code>, <code class="language-plaintext highlighter-rouge">JSON_PATH_KEYS_ONLY=true</code>, <code class="language-plaintext highlighter-rouge">JSON_PATH_EXCLUDE_IF_EXISTS=aliasOf</code> gives you the “keys, minus aliases” shape declaratively. Reach for <code class="language-plaintext highlighter-rouge">jq</code> when you need to reshape or compute fields.</p>

<p>Whichever you use, <strong>test the filter against the real payload before you deploy it</strong> — pipe the live URL through <code class="language-plaintext highlighter-rouge">jq</code> locally. Most plugin-generator “it produces nothing” incidents are actually a filter that returns <code class="language-plaintext highlighter-rouge">[]</code> or the wrong shape.</p>

<h2 id="deployment-notes">Deployment notes</h2>

<p>Three lessons from running this for real:</p>

<p><strong>1. The plugin must live where the <em>controller</em> runs — not where the Applications deploy.</strong>
This is the counterintuitive one. The controller resolves <code class="language-plaintext highlighter-rouge">configMapRef</code> in <em>its own</em> namespace and dials <code class="language-plaintext highlighter-rouge">baseUrl</code> over <em>its own</em> in-cluster network. If your ArgoCD control plane and your workloads are on different clusters (a very common topology), the plugin belongs on the <strong>control-plane</strong> cluster, next to the controller. Put it on the workload cluster and the controller just reports:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>error getting plugin from generator: error fetching ConfigMap "json-plugin" not found
</code></pre></div></div>

<p>Where the generated Applications ultimately deploy is decided entirely by the template’s <code class="language-plaintext highlighter-rouge">destination</code> — independently of where the plugin lives.</p>

<p><strong>2. Health-check the socket, not the endpoint.</strong>
The plugin’s only route is <code class="language-plaintext highlighter-rouge">POST /api/v1/getparams.execute</code>, and it’s token-authenticated — a plain <code class="language-plaintext highlighter-rouge">GET</code> returns <code class="language-plaintext highlighter-rouge">501</code>. An <code class="language-plaintext highlighter-rouge">httpGet</code> probe against that path therefore <em>always fails</em>: liveness kills the container in a crash loop (<code class="language-plaintext highlighter-rouge">exitCode 137</code>), and readiness keeps the pod out of the Service’s endpoints, so even a healthy process is unreachable. Use <code class="language-plaintext highlighter-rouge">tcpSocket</code> probes (as above). For a stateless request/response server, “is the port accepting connections?” is the right liveness signal.</p>

<p><strong>3. Two small ones.</strong> Pin an image tag your registry actually has (an unbuilt tag gives <code class="language-plaintext highlighter-rouge">ImagePullBackOff</code> with a misleading <code class="language-plaintext highlighter-rouge">not found</code>), and make sure the ConfigMap’s <code class="language-plaintext highlighter-rouge">baseUrl</code> carries the <strong>service port</strong> — no port means port 80, and the Service listens on 4355.</p>

<h2 id="when-to-use-it">When to use it</h2>

<p>Use a plugin generator when the source of truth is <em>external</em> JSON you don’t want to copy into git and you want Applications to track it live. It’s a running service you have to operate, so it’s overkill for a static list. For “template one Application per row of this API,” it turns an MR-per-change chore into a five-minute reconcile loop.</p>

<p>The plugin used here is open source: <a href="https://github.com/cmehat/argocd-applicationset-json-plugin">argocd-applicationset-json-plugin</a>. In the <a href="/2026/07/auto-onboarding-tezos-testnets-with-teztnets-json.html">next post</a> I put it to work against a real, public feed — the Tezos test-network registry.</p>]]></content><author><name>Corentin</name></author><category term="kubernetes" /><category term="gitops" /><category term="argocd" /><category term="applicationset" /><category term="plugin-generator" /><category term="kubernetes" /><category term="gitops" /><category term="jq" /><category term="jsonpath" /><category term="sre" /><summary type="html"><![CDATA[ArgoCD’s ApplicationSet ships with generators for the common cases — a static list, files in git, registered clusters, pull requests, SCM org scans. The plugin generator covers everything else: any REST API, internal service catalogue, or external JSON feed that already exists and is already kept current by someone else. It’s the least-documented generator of the bunch. This post is a from-scratch, no-Helm walkthrough of standing one up: the protocol, the raw manifests, how to write the filter, and a few production pitfalls. I’ll use a small open-source plugin — argocd-applicationset-json-plugin — that does one generic job: fetch JSON from a URL, filter it with jq or JSONPath, and hand the result back to ArgoCD as parameters. The protocol described here applies to any plugin, though. What a plugin generator actually is A plugin generator does not run your code inside the controller. It makes an authenticated HTTP call to a service you deploy, and turns the JSON that comes back into generator parameters. The argocd-applicationset-controller is the client; your plugin is a tiny HTTP server. The contract is a single route: POST /api/v1/getparams.execute Authorization: Bearer &lt;token&gt; Content-Type: application/json { "applicationSetName": "my-appset", "input": { "parameters": {} } } and the response is a list of parameter objects: { "output": { "parameters": [ { "name": "alpha", "region": "eu" }, { "name": "beta", "region": "us" } ] } } Each object becomes one set of template variables. flowchart LR G["ApplicationSet(plugin generator)"] --&gt;|reads| CM["ConfigMapbaseUrl + token ref"] AC["applicationset-controller"] --&gt;|"POST getparams.execute"| SVC["your plugin Service"] SVC --&gt; APP["JSON source (any URL)"] AC --&gt;|"one Application per parameter"| OUT["Applications"] Deploying the plugin backend (raw manifests) Four objects, all in the same namespace as the ApplicationSet controller (usually argocd). We’ll come back to why that matters. Secret — the shared token the controller authenticates with: apiVersion: v1 kind: Secret metadata: name: json-plugin namespace: argocd stringData: token: "replace-me-with-a-real-secret" Deployment — the plugin server. It reads its config from environment variables and the token from a mounted file: apiVersion: apps/v1 kind: Deployment metadata: name: json-plugin namespace: argocd spec: replicas: 1 selector: matchLabels: { app: json-plugin } template: metadata: labels: { app: json-plugin } spec: containers: - name: plugin image: ghcr.io/cmehat/argocd-applicationset-json-plugin:jq-latest env: - name: JSON_URL value: "https://api.github.com/users/kubernetes/repos?per_page=100" - name: JSON_FILTER # jq expression value: 'map(select(.archived == false) | {name: .name, stars: (.stargazers_count|tostring)})' ports: - { name: http, containerPort: 4355 } volumeMounts: - { name: token, mountPath: /var/run/argo, readOnly: true } # The only route is POST-only; a GET returns 501. Probe the socket, # not the endpoint. (More on this below.) readinessProbe: tcpSocket: { port: 4355 } livenessProbe: tcpSocket: { port: 4355 } volumes: - name: token secret: { secretName: json-plugin } Service — how the controller reaches it, in-cluster: apiVersion: v1 kind: Service metadata: name: json-plugin namespace: argocd spec: selector: { app: json-plugin } ports: - { port: 4355, targetPort: 4355 } ConfigMap — this is what the ApplicationSet references. ArgoCD discovers it by the app.kubernetes.io/part-of: argocd label, and reads the plugin’s URL and token from it: apiVersion: v1 kind: ConfigMap metadata: name: json-plugin namespace: argocd labels: app.kubernetes.io/part-of: argocd # required for discovery data: baseUrl: "http://json-plugin.argocd.svc.cluster.local:4355" # include the port! token: "$json-plugin:token" # $&lt;secret-name&gt;:&lt;key&gt; Wiring the ApplicationSet Now the generator just points at that ConfigMap by name: apiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: name: repos namespace: argocd spec: goTemplate: true generators: - plugin: configMapRef: name: json-plugin input: parameters: {} requeueAfterSeconds: 300 # re-fetch the source every 5 min template: metadata: name: "repo-" spec: project: default source: repoURL: https://github.com/my-org/app-of-apps.git targetRevision: main path: "charts/generic" helm: values: | displayName: "" stars: "" destination: server: https://kubernetes.default.svc namespace: "repo-" syncPolicy: automated: { prune: true, selfHeal: true } syncOptions: [ CreateNamespace=true ] Every object the plugin returns becomes one Application, refreshed on the requeue interval. New entries in the upstream JSON appear on their own; entries that disappear are pruned. Writing the filter: jq or JSONPath The plugin ships two flavours — a jq image (jq-* tags) and a JSONPath image (jsonpath-* tags, the default). Both take the fetched JSON and must emit a flat array of objects with string values (ArgoCD parameters are strings). A few generic shapes: Array of objects → pick fields (e.g. the GitHub repos list above): map({name: .name, url: .html_url}) Object-of-objects → keys as items, dropping entries that are aliases of another (a common pattern in registry-style files): to_entries | map(select(.value.aliasOf == null)) | map({name: .key}) Filter by a property — only public, only in a region, only “active”: map(select(.status == "active") | {name: .id, region: .region}) The JSONPath variant covers the simple cases without a jq binary — JSON_PATH=$.*, JSON_PATH_KEYS_ONLY=true, JSON_PATH_EXCLUDE_IF_EXISTS=aliasOf gives you the “keys, minus aliases” shape declaratively. Reach for jq when you need to reshape or compute fields. Whichever you use, test the filter against the real payload before you deploy it — pipe the live URL through jq locally. Most plugin-generator “it produces nothing” incidents are actually a filter that returns [] or the wrong shape. Deployment notes Three lessons from running this for real: 1. The plugin must live where the controller runs — not where the Applications deploy. This is the counterintuitive one. The controller resolves configMapRef in its own namespace and dials baseUrl over its own in-cluster network. If your ArgoCD control plane and your workloads are on different clusters (a very common topology), the plugin belongs on the control-plane cluster, next to the controller. Put it on the workload cluster and the controller just reports: error getting plugin from generator: error fetching ConfigMap "json-plugin" not found Where the generated Applications ultimately deploy is decided entirely by the template’s destination — independently of where the plugin lives. 2. Health-check the socket, not the endpoint. The plugin’s only route is POST /api/v1/getparams.execute, and it’s token-authenticated — a plain GET returns 501. An httpGet probe against that path therefore always fails: liveness kills the container in a crash loop (exitCode 137), and readiness keeps the pod out of the Service’s endpoints, so even a healthy process is unreachable. Use tcpSocket probes (as above). For a stateless request/response server, “is the port accepting connections?” is the right liveness signal. 3. Two small ones. Pin an image tag your registry actually has (an unbuilt tag gives ImagePullBackOff with a misleading not found), and make sure the ConfigMap’s baseUrl carries the service port — no port means port 80, and the Service listens on 4355. When to use it Use a plugin generator when the source of truth is external JSON you don’t want to copy into git and you want Applications to track it live. It’s a running service you have to operate, so it’s overkill for a static list. For “template one Application per row of this API,” it turns an MR-per-change chore into a five-minute reconcile loop. The plugin used here is open source: argocd-applicationset-json-plugin. In the next post I put it to work against a real, public feed — the Tezos test-network registry.]]></summary></entry><entry><title type="html">Tini: the missing PID 1 for your containers</title><link href="https://blog.oyatrino.com/2026/07/tini-pid-1-in-containers.html" rel="alternate" type="text/html" title="Tini: the missing PID 1 for your containers" /><published>2026-07-11T00:00:00-04:00</published><updated>2026-07-11T00:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/tini-pid-1-in-containers</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/tini-pid-1-in-containers.html"><![CDATA[<p>When <code class="language-plaintext highlighter-rouge">docker stop</code> takes ten seconds instead of milliseconds, or when a container quietly accumulates zombie processes over time, the cause is usually the same: PID 1 is special, and your application was never written to handle it.</p>

<h2 id="what-makes-pid-1-special">What makes PID 1 special</h2>

<p>On Linux, the kernel assigns PID 1 to the first process in a new PID namespace. Two things make it different from every other process:</p>

<p><strong>Signal handling.</strong> The kernel <em>never</em> delivers <code class="language-plaintext highlighter-rouge">SIGTERM</code> to PID 1 unless it has explicitly installed a signal handler for it. Most applications don’t — they rely on the default handler the C runtime sets up, which the kernel bypasses for PID 1. The practical result: <code class="language-plaintext highlighter-rouge">docker stop</code> sends <code class="language-plaintext highlighter-rouge">SIGTERM</code> to your container’s PID 1, nothing happens, and after the timeout (default 10 seconds) Docker sends <code class="language-plaintext highlighter-rouge">SIGKILL</code>. Every graceful shutdown attempt silently fails.</p>

<p><strong>Zombie reaping.</strong> When a process exits, it stays in the process table as a zombie until its parent calls <code class="language-plaintext highlighter-rouge">wait()</code> on it. If the parent exits first, the orphan is reparented to PID 1, which becomes responsible for reaping it. The operating system init daemon knows this and handles it. Your application almost certainly doesn’t.</p>

<h2 id="what-tini-does">What Tini does</h2>

<p><a href="https://github.com/krallin/tini">Tini</a> (Tiny Init) is a minimal init process designed to solve exactly these two problems. It sits as PID 1, registers signal handlers that forward signals to its child process, and reaps zombie processes. Nothing else.</p>

<p>You launch your application as a child of Tini rather than as PID 1 directly:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span>apk add <span class="nt">--no-cache</span> tini
<span class="k">ENTRYPOINT</span><span class="s"> ["/sbin/tini", "--"]</span>
<span class="k">CMD</span><span class="s"> ["your-app"]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">docker stop</code> now sends <code class="language-plaintext highlighter-rouge">SIGTERM</code> to Tini, which forwards it to your application. Your application’s default <code class="language-plaintext highlighter-rouge">SIGTERM</code> handler runs, it shuts down cleanly, and the container exits in milliseconds rather than ten seconds.</p>

<h2 id="the-docker-compose-shortcut">The Docker Compose shortcut</h2>

<p>If you control the Compose file and don’t need the init embedded in the image, there’s a one-liner:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">web</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">your-image</span>
    <span class="na">init</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">init: true</code> injects the Tini binary that ships with Docker Engine itself as PID 1. No separate download, no image change, no supply chain surface beyond what you’ve already trusted by running Docker. This is the lowest-effort correct solution for most Compose-based workloads.</p>

<h2 id="why-not-just-handle-signals-in-the-application">Why not just handle signals in the application?</h2>

<p>You can. But it doesn’t solve zombie reaping unless you also implement <code class="language-plaintext highlighter-rouge">waitpid()</code> loops, every language runtime handles signals slightly differently, and getting all shutdown paths right (graceful, panicking, subprocess spawning) is non-trivial. Tini is 200 lines of C that has been doing exactly this since 2015.</p>

<h2 id="checking-whether-you-need-it">Checking whether you need it</h2>

<p>A quick test: run your container and check what’s at PID 1.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec</span> &lt;container&gt; <span class="nb">cat</span> /proc/1/cmdline | <span class="nb">tr</span> <span class="s1">'\0'</span> <span class="s1">' '</span>
</code></pre></div></div>

<p>If it prints your application binary directly, you’re exposed to both problems. If it prints <code class="language-plaintext highlighter-rouge">tini</code> or <code class="language-plaintext highlighter-rouge">dumb-init</code>, you’re covered.</p>

<h2 id="the-alternative-dumb-init">The alternative: dumb-init</h2>

<p><a href="https://github.com/Yelp/dumb-init">dumb-init</a> (from Yelp) solves the same problem with a similar approach. It’s more actively maintained than Tini and also available in Alpine and Debian package repos:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span>apk add <span class="nt">--no-cache</span> dumb-init
<span class="k">ENTRYPOINT</span><span class="s"> ["dumb-init", "--"]</span>
<span class="k">CMD</span><span class="s"> ["your-app"]</span>
</code></pre></div></div>

<p>The functional difference between the two is small enough not to matter for most workloads. If you’re starting from scratch: <code class="language-plaintext highlighter-rouge">dumb-init</code> from the package manager, or <code class="language-plaintext highlighter-rouge">init: true</code> in Compose if that fits your deployment model. If you’re already using Tini: stay on it, there’s no reason to switch.</p>

<p>If you’re starting from scratch: <code class="language-plaintext highlighter-rouge">dumb-init</code> from the package manager, or <code class="language-plaintext highlighter-rouge">init: true</code> in Compose. If you’re already using Tini: no reason to switch.</p>]]></content><author><name>Corentin</name></author><category term="docker" /><category term="containers" /><category term="docker" /><category term="docker-compose" /><category term="tini" /><category term="dumb-init" /><category term="pid1" /><category term="signals" /><category term="devops" /><summary type="html"><![CDATA[When docker stop takes ten seconds instead of milliseconds, or when a container quietly accumulates zombie processes over time, the cause is usually the same: PID 1 is special, and your application was never written to handle it. What makes PID 1 special On Linux, the kernel assigns PID 1 to the first process in a new PID namespace. Two things make it different from every other process: Signal handling. The kernel never delivers SIGTERM to PID 1 unless it has explicitly installed a signal handler for it. Most applications don’t — they rely on the default handler the C runtime sets up, which the kernel bypasses for PID 1. The practical result: docker stop sends SIGTERM to your container’s PID 1, nothing happens, and after the timeout (default 10 seconds) Docker sends SIGKILL. Every graceful shutdown attempt silently fails. Zombie reaping. When a process exits, it stays in the process table as a zombie until its parent calls wait() on it. If the parent exits first, the orphan is reparented to PID 1, which becomes responsible for reaping it. The operating system init daemon knows this and handles it. Your application almost certainly doesn’t. What Tini does Tini (Tiny Init) is a minimal init process designed to solve exactly these two problems. It sits as PID 1, registers signal handlers that forward signals to its child process, and reaps zombie processes. Nothing else. You launch your application as a child of Tini rather than as PID 1 directly: RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--"] CMD ["your-app"] docker stop now sends SIGTERM to Tini, which forwards it to your application. Your application’s default SIGTERM handler runs, it shuts down cleanly, and the container exits in milliseconds rather than ten seconds. The Docker Compose shortcut If you control the Compose file and don’t need the init embedded in the image, there’s a one-liner: services: web: image: your-image init: true init: true injects the Tini binary that ships with Docker Engine itself as PID 1. No separate download, no image change, no supply chain surface beyond what you’ve already trusted by running Docker. This is the lowest-effort correct solution for most Compose-based workloads. Why not just handle signals in the application? You can. But it doesn’t solve zombie reaping unless you also implement waitpid() loops, every language runtime handles signals slightly differently, and getting all shutdown paths right (graceful, panicking, subprocess spawning) is non-trivial. Tini is 200 lines of C that has been doing exactly this since 2015. Checking whether you need it A quick test: run your container and check what’s at PID 1. docker exec &lt;container&gt; cat /proc/1/cmdline | tr '\0' ' ' If it prints your application binary directly, you’re exposed to both problems. If it prints tini or dumb-init, you’re covered. The alternative: dumb-init dumb-init (from Yelp) solves the same problem with a similar approach. It’s more actively maintained than Tini and also available in Alpine and Debian package repos: RUN apk add --no-cache dumb-init ENTRYPOINT ["dumb-init", "--"] CMD ["your-app"] The functional difference between the two is small enough not to matter for most workloads. If you’re starting from scratch: dumb-init from the package manager, or init: true in Compose if that fits your deployment model. If you’re already using Tini: stay on it, there’s no reason to switch. If you’re starting from scratch: dumb-init from the package manager, or init: true in Compose. If you’re already using Tini: no reason to switch.]]></summary></entry><entry><title type="html">From Pull to Push: What Agent-Based Monitoring Actually Changes</title><link href="https://blog.oyatrino.com/2026/07/from-pull-to-push-what-agent-based-monitoring-changes.html" rel="alternate" type="text/html" title="From Pull to Push: What Agent-Based Monitoring Actually Changes" /><published>2026-07-07T00:00:00-04:00</published><updated>2026-07-07T00:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/07/from-pull-to-push-what-agent-based-monitoring-changes</id><content type="html" xml:base="https://blog.oyatrino.com/2026/07/from-pull-to-push-what-agent-based-monitoring-changes.html"><![CDATA[<p>I’ve spent the last few months migrating a standalone monitoring stack — Prometheus, Alertmanager, Loki and Grafana running on cloud VMs, provisioned with Terraform and configured with Ansible — into an in-cluster, GitOps-managed kube-prometheus-stack. Along the way, one architectural question kept resurfacing in design discussions: should we keep the classic Prometheus <strong>pull</strong> model, or move to an <strong>agent push</strong> model, where lightweight collectors (Grafana Alloy, the OpenTelemetry Collector, Prometheus in agent mode) scrape locally and <code class="language-plaintext highlighter-rouge">remote_write</code> everything to a central store?</p>

<p>Grafana Cloud, Mimir, Thanos Receive, Amazon Managed Prometheus — they all ingest via <code class="language-plaintext highlighter-rouge">remote_write</code>. The transport difference is real, but it comes with a less-obvious consequence: <strong>the pull model isn’t just a transport choice, it’s a health-checking model.</strong> When you switch to push, some of your monitoring assumptions silently invert, and your alerting rules need to change with them.</p>

<h2 id="what-the-pull-model-gives-you-for-free">What the pull model gives you for free</h2>

<p>In classic Prometheus, the server initiates every scrape. That single design decision buys you three things:</p>

<p><strong>1. <code class="language-plaintext highlighter-rouge">up</code> is a real health check.</strong> Every scrape attempt produces an <code class="language-plaintext highlighter-rouge">up{job, instance}</code> sample: <code class="language-plaintext highlighter-rouge">1</code> if the target answered, <code class="language-plaintext highlighter-rouge">0</code> if it didn’t. This is <em>active</em> verification, performed by the same component that evaluates your alerts. <code class="language-plaintext highlighter-rouge">up == 0</code> means “I tried to reach this thing just now and it did not respond.” There is no ambiguity about whose fault it is.</p>

<p><strong>2. Absence is loud.</strong> A dead target doesn’t disappear from your metrics — it shows up as <code class="language-plaintext highlighter-rouge">up == 0</code>. The failure mode is a <em>signal</em>, not a <em>gap</em>.</p>

<p><strong>3. Service discovery and scrape config live in one place.</strong> The Prometheus server knows the full inventory of what it’s supposed to be monitoring. If a target exists in SD but stops answering, that’s detectable by construction.</p>

<p>The canonical alert in this world is trivially simple:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">alert</span><span class="pi">:</span> <span class="s">TargetDown</span>
  <span class="na">expr</span><span class="pi">:</span> <span class="s">up == </span><span class="m">0</span>
  <span class="na">for</span><span class="pi">:</span> <span class="s">5m</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">summary</span><span class="pi">:</span> <span class="s2">"</span><span class="s">{{</span><span class="nv"> </span><span class="s">$labels.job</span><span class="nv"> </span><span class="s">}}</span><span class="nv"> </span><span class="s">on</span><span class="nv"> </span><span class="s">{{</span><span class="nv"> </span><span class="s">$labels.instance</span><span class="nv"> </span><span class="s">}}</span><span class="nv"> </span><span class="s">is</span><span class="nv"> </span><span class="s">unreachable"</span>
</code></pre></div></div>

<h2 id="what-the-push-model-takes-away">What the push model takes away</h2>

<p>In an agent-based architecture, the topology inverts. An agent sits next to (or inside) each node or cluster, scrapes its local targets, and ships samples over <code class="language-plaintext highlighter-rouge">remote_write</code> to a central store — Mimir, Thanos Receive, a managed service. Rules are evaluated centrally, against whatever data <em>arrived</em>.</p>

<p><strong>The central store never tries to reach your targets.</strong> It only knows what it receives. So the question your alerting can answer changes from <em>“did the target respond when I probed it?”</em> to <em>“has data about this target shown up recently?”</em> — two questions that sound similar but have very different failure modes.</p>

<p>Consider the failure modes:</p>

<table>
  <thead>
    <tr>
      <th>Failure</th>
      <th>Pull model</th>
      <th>Push model</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Target process dies</td>
      <td><code class="language-plaintext highlighter-rouge">up == 0</code> from the server</td>
      <td>Agent reports <code class="language-plaintext highlighter-rouge">up == 0</code> — <strong>detected</strong>, if the agent still runs</td>
    </tr>
    <tr>
      <td>Agent dies</td>
      <td>n/a (no agent)</td>
      <td>Silence. No <code class="language-plaintext highlighter-rouge">up</code> metric at all.</td>
    </tr>
    <tr>
      <td>Network partition agent → store</td>
      <td>Scrape fails, <code class="language-plaintext highlighter-rouge">up == 0</code></td>
      <td>Silence (agent buffers in WAL, then drops)</td>
    </tr>
    <tr>
      <td>Whole node/VM disappears</td>
      <td><code class="language-plaintext highlighter-rouge">up == 0</code></td>
      <td>Silence</td>
    </tr>
    <tr>
      <td>Misconfigured relabeling drops a job</td>
      <td>Visible in targets page</td>
      <td>Silence</td>
    </tr>
  </tbody>
</table>

<p>Four out of five failure modes in the push column produce the same symptom: <strong>nothing</strong>. And “nothing” is the one thing a naïve <code class="language-plaintext highlighter-rouge">up == 0</code> alert cannot fire on, because rule evaluation needs samples to evaluate. Your loudest failure signal just became your quietest.</p>

<h2 id="rewriting-the-alerting-layer">Rewriting the alerting layer</h2>

<p>The migration is therefore not “port the alert rules and change the datasource.” Three categories of rules need rethinking.</p>

<h3 id="1-presence-alerts-replace-liveness-alerts">1. Presence alerts replace liveness alerts</h3>

<p><code class="language-plaintext highlighter-rouge">up == 0</code> still works for the case where the agent is healthy but its local target is down — keep those rules. But every one of them needs a companion <strong>absence</strong> rule that fires when the time series stops arriving entirely:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">alert</span><span class="pi">:</span> <span class="s">MetricsAbsent</span>
  <span class="na">expr</span><span class="pi">:</span> <span class="s">absent_over_time(up{job="octez-node"}[10m])</span>
  <span class="na">for</span><span class="pi">:</span> <span class="s">5m</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">severity</span><span class="pi">:</span> <span class="s">p1</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">summary</span><span class="pi">:</span> <span class="s2">"</span><span class="s">No</span><span class="nv"> </span><span class="s">samples</span><span class="nv"> </span><span class="s">received</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">job</span><span class="nv"> </span><span class="s">octez-node</span><span class="nv"> </span><span class="s">in</span><span class="nv"> </span><span class="s">10m</span><span class="nv"> </span><span class="s">—</span><span class="nv"> </span><span class="s">target,</span><span class="nv"> </span><span class="s">agent,</span><span class="nv"> </span><span class="s">or</span><span class="nv"> </span><span class="s">pipeline</span><span class="nv"> </span><span class="s">is</span><span class="nv"> </span><span class="s">down"</span>
</code></pre></div></div>

<p>Two practical constraints. First, <code class="language-plaintext highlighter-rouge">absent_over_time()</code> can only take a fixed selector — it can’t enumerate instances it has never seen, so you lose the per-instance granularity <code class="language-plaintext highlighter-rouge">up</code> gave you. You either maintain one absence rule per critical job (my choice), or you generate them from your inventory (we generate ours with Jsonnet, which keeps the rule set in lockstep with the deployment config). Second, absence alerts fire during <em>intentional</em> removals too — decommission a node and forget to remove the rule, and you page yourself at 3 a.m. for a machine that no longer exists. The inventory and the rules must come from the same source of truth. GitOps helps enormously here: the same ArgoCD application that removes the scrape config removes the absence rule.</p>

<h3 id="2-the-pipeline-itself-becomes-a-monitored-system">2. The pipeline itself becomes a monitored system</h3>

<p>With pull, the path from target to alert evaluation was one hop. With push, it’s target → agent scrape → WAL → <code class="language-plaintext highlighter-rouge">remote_write</code> queue → network → ingester → store → ruler. Each hop can back up, and a backed-up pipeline delays <em>every</em> alert downstream — your <code class="language-plaintext highlighter-rouge">for: 5m</code> is now <code class="language-plaintext highlighter-rouge">for: 5m + ingestion lag</code>.</p>

<p>The agent exposes everything you need; you just have to actually alert on it:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">prometheus_remote_storage_highest_timestamp_in_seconds</code> minus <code class="language-plaintext highlighter-rouge">prometheus_remote_storage_queue_highest_sent_timestamp_seconds</code> — your end-to-end write lag. Alert when it exceeds, say, 2 minutes.</li>
  <li><code class="language-plaintext highlighter-rouge">prometheus_remote_storage_samples_failed_total</code> and <code class="language-plaintext highlighter-rouge">samples_dropped_total</code> — non-zero rates here mean you are losing data, not just delaying it.</li>
  <li><code class="language-plaintext highlighter-rouge">prometheus_remote_storage_shards</code> pinned at <code class="language-plaintext highlighter-rouge">max_shards</code> — the queue can’t keep up.</li>
  <li>WAL disk usage on the agent — a long partition fills the buffer, and recovery replays a flood of out-of-order-adjacent samples at the store.</li>
</ul>

<p>None of these alerts existed in the old stack because none of these components existed. Budget for it: in our migration, roughly a third of the “new” alert rules monitor the monitoring pipeline itself.</p>

<h3 id="3-the-dead-mans-switch-is-no-longer-optional">3. The dead man’s switch is no longer optional</h3>

<p>In the pull world, a fully dead Prometheus was already a known blind spot, and the standard mitigation was a <code class="language-plaintext highlighter-rouge">Watchdog</code>/<code class="language-plaintext highlighter-rouge">DeadMansSwitch</code> alert — a rule that always fires, routed to an external service that pages you when the <em>heartbeat stops</em>. In the push world this pattern graduates from “nice to have” to <strong>structural requirement</strong>, because entire-pipeline death is now indistinguishable from “everything is fine” at every layer you control.</p>

<p>The heartbeat must terminate <em>outside</em> the failure domain of the stack it monitors: healthchecks.io, Dead Man’s Snitch, PagerDuty’s dead-man integration, or a second minimal Prometheus in another provider doing nothing but probing the first. If your monitoring runs in-cluster — as ours now does — this external witness is also your answer to the awkward question “what tells us the cluster hosting the monitoring is down?” Whatever you pick, the property that matters is independence: different network path, different provider, different credentials.</p>

<h2 id="what-you-get-in-exchange">What you get in exchange</h2>

<p>What you get in exchange:</p>

<ul>
  <li><strong>Topology fit.</strong> No inbound connectivity to edge networks, NATed VMs, or short-lived workloads. The agent only needs one outbound HTTPS path. This is the original motivation and it’s real — our old stack needed carefully curated firewall rules and a VPN mesh for the central server to reach every target.</li>
  <li><strong>One query surface.</strong> All regions and clusters land in one store with consistent <code class="language-plaintext highlighter-rouge">external_labels</code>. Cross-fleet queries and global dashboards stop being a federation science project.</li>
  <li><strong>HA without gaps.</strong> Run two agents per site with identical configs and distinct replica labels; the store (Mimir, or Grafana Cloud’s Adaptive dedup) deduplicates. Compare that to HA pull-Prometheus pairs, where deduplication is the <em>query layer’s</em> problem forever.</li>
  <li><strong>Centralized rule management.</strong> Rules live in the ruler, deployed via GitOps, versioned with everything else — no more Ansible-templated <code class="language-plaintext highlighter-rouge">rules.d</code> drift across regional servers.</li>
</ul>

<h2 id="migration-checklist">Migration checklist</h2>

<ol>
  <li><strong>Inventory every <code class="language-plaintext highlighter-rouge">up</code>-based alert</strong> and write its <code class="language-plaintext highlighter-rouge">absent_over_time()</code> companion. Generate them from the same source of truth as the scrape configs.</li>
  <li><strong>Add pipeline alerts</strong> on <code class="language-plaintext highlighter-rouge">remote_write</code> lag, failed/dropped samples, shard saturation and agent WAL disk <em>before</em> cutover, not after the first silent outage.</li>
  <li><strong>Stand up the external dead man’s switch first.</strong> It’s the only component that can tell you the cutover itself went wrong.</li>
  <li><strong>Re-tune <code class="language-plaintext highlighter-rouge">for:</code> durations.</strong> Add expected ingestion lag headroom to time-critical rules, or alerts that fired reliably at <code class="language-plaintext highlighter-rouge">for: 2m</code> under pull will flap under push.</li>
  <li><strong>Set <code class="language-plaintext highlighter-rouge">external_labels</code> deliberately</strong> (cluster, region, replica) before the first sample ships — relabeling history after the fact is miserable.</li>
  <li><strong>Run both models in parallel</strong> for at least one full incident cycle. The gaps you find will be in the absence-detection layer, and you want to find them while the old <code class="language-plaintext highlighter-rouge">up == 0</code> safety net still exists.</li>
</ol>

<p>The one-line summary: <strong>pull-based monitoring fails loud, push-based monitoring fails silent</strong> — and the entire migration effort, beyond plumbing, is about re-introducing loudness on purpose. If you only carry your alert rules across unchanged, you haven’t migrated your monitoring; you’ve migrated your dashboards and quietly deleted your smoke detector.</p>]]></content><author><name>Corentin</name></author><category term="observability" /><category term="prometheus" /><category term="prometheus" /><category term="grafana-alloy" /><category term="remote-write" /><category term="alerting" /><category term="kube-prometheus-stack" /><category term="mimir" /><summary type="html"><![CDATA[I’ve spent the last few months migrating a standalone monitoring stack — Prometheus, Alertmanager, Loki and Grafana running on cloud VMs, provisioned with Terraform and configured with Ansible — into an in-cluster, GitOps-managed kube-prometheus-stack. Along the way, one architectural question kept resurfacing in design discussions: should we keep the classic Prometheus pull model, or move to an agent push model, where lightweight collectors (Grafana Alloy, the OpenTelemetry Collector, Prometheus in agent mode) scrape locally and remote_write everything to a central store? Grafana Cloud, Mimir, Thanos Receive, Amazon Managed Prometheus — they all ingest via remote_write. The transport difference is real, but it comes with a less-obvious consequence: the pull model isn’t just a transport choice, it’s a health-checking model. When you switch to push, some of your monitoring assumptions silently invert, and your alerting rules need to change with them. What the pull model gives you for free In classic Prometheus, the server initiates every scrape. That single design decision buys you three things: 1. up is a real health check. Every scrape attempt produces an up{job, instance} sample: 1 if the target answered, 0 if it didn’t. This is active verification, performed by the same component that evaluates your alerts. up == 0 means “I tried to reach this thing just now and it did not respond.” There is no ambiguity about whose fault it is. 2. Absence is loud. A dead target doesn’t disappear from your metrics — it shows up as up == 0. The failure mode is a signal, not a gap. 3. Service discovery and scrape config live in one place. The Prometheus server knows the full inventory of what it’s supposed to be monitoring. If a target exists in SD but stops answering, that’s detectable by construction. The canonical alert in this world is trivially simple: - alert: TargetDown expr: up == 0 for: 5m annotations: summary: "{{ $labels.job }} on {{ $labels.instance }} is unreachable" What the push model takes away In an agent-based architecture, the topology inverts. An agent sits next to (or inside) each node or cluster, scrapes its local targets, and ships samples over remote_write to a central store — Mimir, Thanos Receive, a managed service. Rules are evaluated centrally, against whatever data arrived. The central store never tries to reach your targets. It only knows what it receives. So the question your alerting can answer changes from “did the target respond when I probed it?” to “has data about this target shown up recently?” — two questions that sound similar but have very different failure modes. Consider the failure modes: Failure Pull model Push model Target process dies up == 0 from the server Agent reports up == 0 — detected, if the agent still runs Agent dies n/a (no agent) Silence. No up metric at all. Network partition agent → store Scrape fails, up == 0 Silence (agent buffers in WAL, then drops) Whole node/VM disappears up == 0 Silence Misconfigured relabeling drops a job Visible in targets page Silence Four out of five failure modes in the push column produce the same symptom: nothing. And “nothing” is the one thing a naïve up == 0 alert cannot fire on, because rule evaluation needs samples to evaluate. Your loudest failure signal just became your quietest. Rewriting the alerting layer The migration is therefore not “port the alert rules and change the datasource.” Three categories of rules need rethinking. 1. Presence alerts replace liveness alerts up == 0 still works for the case where the agent is healthy but its local target is down — keep those rules. But every one of them needs a companion absence rule that fires when the time series stops arriving entirely: - alert: MetricsAbsent expr: absent_over_time(up{job="octez-node"}[10m]) for: 5m labels: severity: p1 annotations: summary: "No samples received for job octez-node in 10m — target, agent, or pipeline is down" Two practical constraints. First, absent_over_time() can only take a fixed selector — it can’t enumerate instances it has never seen, so you lose the per-instance granularity up gave you. You either maintain one absence rule per critical job (my choice), or you generate them from your inventory (we generate ours with Jsonnet, which keeps the rule set in lockstep with the deployment config). Second, absence alerts fire during intentional removals too — decommission a node and forget to remove the rule, and you page yourself at 3 a.m. for a machine that no longer exists. The inventory and the rules must come from the same source of truth. GitOps helps enormously here: the same ArgoCD application that removes the scrape config removes the absence rule. 2. The pipeline itself becomes a monitored system With pull, the path from target to alert evaluation was one hop. With push, it’s target → agent scrape → WAL → remote_write queue → network → ingester → store → ruler. Each hop can back up, and a backed-up pipeline delays every alert downstream — your for: 5m is now for: 5m + ingestion lag. The agent exposes everything you need; you just have to actually alert on it: prometheus_remote_storage_highest_timestamp_in_seconds minus prometheus_remote_storage_queue_highest_sent_timestamp_seconds — your end-to-end write lag. Alert when it exceeds, say, 2 minutes. prometheus_remote_storage_samples_failed_total and samples_dropped_total — non-zero rates here mean you are losing data, not just delaying it. prometheus_remote_storage_shards pinned at max_shards — the queue can’t keep up. WAL disk usage on the agent — a long partition fills the buffer, and recovery replays a flood of out-of-order-adjacent samples at the store. None of these alerts existed in the old stack because none of these components existed. Budget for it: in our migration, roughly a third of the “new” alert rules monitor the monitoring pipeline itself. 3. The dead man’s switch is no longer optional In the pull world, a fully dead Prometheus was already a known blind spot, and the standard mitigation was a Watchdog/DeadMansSwitch alert — a rule that always fires, routed to an external service that pages you when the heartbeat stops. In the push world this pattern graduates from “nice to have” to structural requirement, because entire-pipeline death is now indistinguishable from “everything is fine” at every layer you control. The heartbeat must terminate outside the failure domain of the stack it monitors: healthchecks.io, Dead Man’s Snitch, PagerDuty’s dead-man integration, or a second minimal Prometheus in another provider doing nothing but probing the first. If your monitoring runs in-cluster — as ours now does — this external witness is also your answer to the awkward question “what tells us the cluster hosting the monitoring is down?” Whatever you pick, the property that matters is independence: different network path, different provider, different credentials. What you get in exchange What you get in exchange: Topology fit. No inbound connectivity to edge networks, NATed VMs, or short-lived workloads. The agent only needs one outbound HTTPS path. This is the original motivation and it’s real — our old stack needed carefully curated firewall rules and a VPN mesh for the central server to reach every target. One query surface. All regions and clusters land in one store with consistent external_labels. Cross-fleet queries and global dashboards stop being a federation science project. HA without gaps. Run two agents per site with identical configs and distinct replica labels; the store (Mimir, or Grafana Cloud’s Adaptive dedup) deduplicates. Compare that to HA pull-Prometheus pairs, where deduplication is the query layer’s problem forever. Centralized rule management. Rules live in the ruler, deployed via GitOps, versioned with everything else — no more Ansible-templated rules.d drift across regional servers. Migration checklist Inventory every up-based alert and write its absent_over_time() companion. Generate them from the same source of truth as the scrape configs. Add pipeline alerts on remote_write lag, failed/dropped samples, shard saturation and agent WAL disk before cutover, not after the first silent outage. Stand up the external dead man’s switch first. It’s the only component that can tell you the cutover itself went wrong. Re-tune for: durations. Add expected ingestion lag headroom to time-critical rules, or alerts that fired reliably at for: 2m under pull will flap under push. Set external_labels deliberately (cluster, region, replica) before the first sample ships — relabeling history after the fact is miserable. Run both models in parallel for at least one full incident cycle. The gaps you find will be in the absence-detection layer, and you want to find them while the old up == 0 safety net still exists. The one-line summary: pull-based monitoring fails loud, push-based monitoring fails silent — and the entire migration effort, beyond plumbing, is about re-introducing loudness on purpose. If you only carry your alert rules across unchanged, you haven’t migrated your monitoring; you’ve migrated your dashboards and quietly deleted your smoke detector.]]></summary></entry><entry><title type="html">A preview page for every merge request with GitLab CI — even on the Free plan</title><link href="https://blog.oyatrino.com/2026/06/gitlab-ci-per-mr-preview-pages.html" rel="alternate" type="text/html" title="A preview page for every merge request with GitLab CI — even on the Free plan" /><published>2026-06-05T05:00:00-04:00</published><updated>2026-06-05T05:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/06/gitlab-ci-per-mr-preview-pages</id><content type="html" xml:base="https://blog.oyatrino.com/2026/06/gitlab-ci-per-mr-preview-pages.html"><![CDATA[<p>You change a CSS rule, a paragraph of copy, a generated report. The diff looks
right. But “looks right in the diff” and “looks right in a browser” are not the
same thing, and the only way to close that gap is to render the change somewhere
a reviewer can click.</p>

<p>This is a how-to for wiring that up on GitLab: <strong>every merge request gets its
own preview page, with a “View app” button right on the MR.</strong> No review server
to babysit, no extra infrastructure — and <strong>it works on the Free plan</strong>, because
it doesn’t rely on publishing multiple GitLab Pages sites at once.</p>

<p>The example is lifted from a real Terraform + Ansible infrastructure pipeline I
maintain, where each MR renders an HTML “infrastructure report” so reviewers can
<em>see</em> what a change produces instead of reading raw plan output.</p>

<h2 id="why-not-parallel-deployments">Why not parallel deployments</h2>

<p>The obvious way to do per-MR previews is GitLab Pages <strong>parallel deployments</strong>
(<code class="language-plaintext highlighter-rouge">pages.path_prefix</code>), which host each branch under its own URL prefix. It’s
clean — and on many setups it’s either gated behind a paid tier or a recent
GitLab version. If you’re on the Free plan, you typically get <strong>one</strong> Pages
deployment: your default branch.</p>

<p>So we use a different lever that exists on every plan:</p>

<blockquote>
  <p>GitLab serves a job’s <strong>artifacts</strong> over the web. If a CI job uploads an HTML
file as an artifact, GitLab gives you a URL that renders it — sandboxed on the
Pages domain. Point a merge-request <strong>environment</strong> at that URL and you have a
per-MR preview without ever publishing a second Pages site.</p>
</blockquote>

<p>The build artifact <em>is</em> the preview. No parallel deployment required.</p>

<h2 id="how-the-pieces-fit">How the pieces fit</h2>

<ol>
  <li>A <strong>build job</strong> produces the page into <code class="language-plaintext highlighter-rouge">public/</code> and uploads it as an
artifact (with a short <code class="language-plaintext highlighter-rouge">expire_in</code>, since previews are disposable).</li>
  <li>A <strong>review job</strong> declares an <code class="language-plaintext highlighter-rouge">environment</code> whose <code class="language-plaintext highlighter-rouge">url</code> points at that
artifact, served on the Pages domain so the HTML renders inline.</li>
  <li><code class="language-plaintext highlighter-rouge">auto_stop_in</code> + an <code class="language-plaintext highlighter-rouge">on_stop</code> job make the environment self-clean.</li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">environment</code> block is the magic: attaching one to a job is exactly what
makes the <strong>View app</strong> button appear on the merge request.</p>

<h2 id="step-1--build-the-page-into-an-artifact">Step 1 — build the page into an artifact</h2>

<p>Nothing special; produce your HTML into <code class="language-plaintext highlighter-rouge">public/</code> and upload it. Keep the
default-branch publish and the MR preview as two jobs sharing the same build —
here I’ll show a single build that both consume:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">build-page</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">build</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">mkdir -p public</span>
    <span class="pi">-</span> <span class="s">./generate_report.sh &gt; public/index.html</span>   <span class="c1"># whatever produces your HTML</span>
  <span class="na">artifacts</span><span class="pi">:</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">public</span>
    <span class="na">expire_in</span><span class="pi">:</span> <span class="s">1 day</span>        <span class="c1"># previews are disposable</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_MERGE_REQUEST_IID</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">expire_in: 1 day</code> matters: the preview lives only as long as the artifact does,
so you’re not accumulating stale renders.</p>

<h2 id="step-2--publish-the-real-site-on-the-default-branch">Step 2 — publish the real site on the default branch</h2>

<p>On the default branch, do a normal Pages publish. This is your “production”
page and the baseline reviewers compare previews against:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">pages</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">deploy</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">echo "Publishing to ${CI_PAGES_URL}"</span>
  <span class="na">needs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">build-page</span>
      <span class="na">optional</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">artifacts</span><span class="pi">:</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">public</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH</span>
  <span class="na">environment</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Production</span><span class="nv"> </span><span class="s">page</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">$CI_DEFAULT_BRANCH"</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$CI_PAGES_URL"</span>
</code></pre></div></div>

<p>(The <code class="language-plaintext highlighter-rouge">pages</code> job name is special — it’s what triggers GitLab’s built-in Pages
deployment. The <code class="language-plaintext highlighter-rouge">script</code> is a formality; the artifact is what gets published.)</p>

<h2 id="step-3--the-per-mr-preview-the-actual-trick">Step 3 — the per-MR preview (the actual trick)</h2>

<p>Here’s the review job. It runs only on merge requests and points the environment
URL at the build artifact, served on the Pages domain:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">pages:review:</span>
  <span class="s">stage</span><span class="err">:</span> <span class="s">deploy</span>
  <span class="s">script</span><span class="err">:</span>
    <span class="pi">-</span> <span class="s">echo "Creating review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
  <span class="na">needs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">build-page</span>
  <span class="na">artifacts</span><span class="pi">:</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">public</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_MERGE_REQUEST_IID</span>
  <span class="na">environment</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Review</span><span class="nv"> </span><span class="s">page</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${CI_PAGES_URL}/-/jobs/${CI_JOB_ID}/artifacts/public/index.html"</span>
    <span class="na">auto_stop_in</span><span class="pi">:</span> <span class="s">1 hour</span>
    <span class="na">on_stop</span><span class="pi">:</span> <span class="s">stop_review</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">url</code> line is the whole point, so it’s worth dissecting:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">${CI_PAGES_URL}</code></strong> is your project’s Pages root — e.g.
<code class="language-plaintext highlighter-rouge">https://group.gitlab.io/-/subgroup/project</code>. Using the variable instead of
hardcoding the host keeps it correct across renames and custom domains.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">/-/jobs/${CI_JOB_ID}/artifacts/public/index.html</code></strong> is GitLab’s
artifact-browsing path. <code class="language-plaintext highlighter-rouge">CI_JOB_ID</code> ties the URL to <em>this</em> job’s artifacts, so
each MR pipeline gets its own preview.</li>
  <li>Serving it <strong>through the Pages domain</strong> (the <code class="language-plaintext highlighter-rouge">group.gitlab.io</code> host) is what
makes the browser <em>render</em> the HTML. That detail is the difference between a
working preview and a downloaded file — see the gotcha below.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">auto_stop_in: 1 hour</code> tells GitLab to retire the environment automatically, and
<code class="language-plaintext highlighter-rouge">on_stop: stop_review</code> names the teardown job.</p>

<blockquote>
  <p>If you’ve ever hardcoded that artifact URL with the literal hostname and left
yourself a “replace with variables” TODO — <code class="language-plaintext highlighter-rouge">${CI_PAGES_URL}</code> is the variable.
For a project at <code class="language-plaintext highlighter-rouge">group/subgroup/project</code> it expands to exactly
<code class="language-plaintext highlighter-rouge">https://group.gitlab.io/-/subgroup/project</code>, so the composed URL matches the
hand-written one character-for-character.</p>
</blockquote>

<h2 id="step-4--the-stop-job">Step 4 — the stop job</h2>

<p><code class="language-plaintext highlighter-rouge">on_stop</code> needs a real job to point at. It does almost nothing — its existence
and <code class="language-plaintext highlighter-rouge">action: stop</code> are what let GitLab (and the manual “Stop environment”
button) close the environment:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">stop_review</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">.post</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">echo "Stopping review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_MERGE_REQUEST_IID</span>
      <span class="na">when</span><span class="pi">:</span> <span class="s">manual</span>
  <span class="na">environment</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Review</span><span class="nv"> </span><span class="s">page</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
    <span class="na">action</span><span class="pi">:</span> <span class="s">stop</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">environment.name</code> <strong>must match</strong> the review job’s exactly, or GitLab won’t
know which environment this job stops.</p>

<h2 id="the-full-thing">The full thing</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">stages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">build</span>
  <span class="pi">-</span> <span class="s">deploy</span>

<span class="na">build-page</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">build</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">mkdir -p public</span>
    <span class="pi">-</span> <span class="s">./generate_report.sh &gt; public/index.html</span>
  <span class="na">artifacts</span><span class="pi">:</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">public</span>
    <span class="na">expire_in</span><span class="pi">:</span> <span class="s">1 day</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_MERGE_REQUEST_IID</span>

<span class="c1"># Real Pages site on the default branch</span>
<span class="na">pages</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">deploy</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">echo "Publishing to ${CI_PAGES_URL}"</span>
  <span class="na">needs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">build-page</span>
      <span class="na">optional</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">artifacts</span><span class="pi">:</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">public</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH</span>
  <span class="na">environment</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Production</span><span class="nv"> </span><span class="s">page</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">$CI_DEFAULT_BRANCH"</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$CI_PAGES_URL"</span>

<span class="c1"># Per-MR preview served from job artifacts</span>
<span class="s">pages:review:</span>
  <span class="s">stage</span><span class="err">:</span> <span class="s">deploy</span>
  <span class="s">script</span><span class="err">:</span>
    <span class="pi">-</span> <span class="s">echo "Creating review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
  <span class="na">needs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">build-page</span>
  <span class="na">artifacts</span><span class="pi">:</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">public</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_MERGE_REQUEST_IID</span>
  <span class="na">environment</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Review</span><span class="nv"> </span><span class="s">page</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${CI_PAGES_URL}/-/jobs/${CI_JOB_ID}/artifacts/public/index.html"</span>
    <span class="na">auto_stop_in</span><span class="pi">:</span> <span class="s">1 hour</span>
    <span class="na">on_stop</span><span class="pi">:</span> <span class="s">stop_review</span>

<span class="na">stop_review</span><span class="pi">:</span>
  <span class="na">stage</span><span class="pi">:</span> <span class="s">.post</span>
  <span class="na">script</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">echo "Stopping review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span> <span class="s">$CI_MERGE_REQUEST_IID</span>
      <span class="na">when</span><span class="pi">:</span> <span class="s">manual</span>
  <span class="na">environment</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Review</span><span class="nv"> </span><span class="s">page</span><span class="nv"> </span><span class="s">-</span><span class="nv"> </span><span class="s">${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"</span>
    <span class="na">action</span><span class="pi">:</span> <span class="s">stop</span>
</code></pre></div></div>

<p>Open an MR and the pipeline produces a <strong>View app</strong> button linking to that MR’s
freshly built page. The default branch keeps publishing the real site at the
Pages root, so reviewers can compare the two side by side.</p>

<h2 id="gotchas">Gotchas</h2>

<ul>
  <li><strong>Pages must be enabled</strong> for the project, even though the preview rides on
artifacts rather than a published Pages site — the artifact-serving URL lives
on the Pages domain. No <code class="language-plaintext highlighter-rouge">pages</code> job has to have run yet; the domain just needs
to exist.</li>
  <li><strong>It only renders inline on the Pages domain.</strong> The same artifact reached via
<code class="language-plaintext highlighter-rouge">$CI_JOB_URL/artifacts/...</code> (the main <code class="language-plaintext highlighter-rouge">gitlab.com</code> host) is served with a
download disposition for HTML, so the browser saves the file instead of
showing it. The <code class="language-plaintext highlighter-rouge">${CI_PAGES_URL}/-/jobs/...</code> form is sandboxed on the Pages
domain and renders inline. This is <em>the</em> reason the URL is built the way it is.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">environment.name</code> must match between the review job and its <code class="language-plaintext highlighter-rouge">stop</code> job</strong>,
character for character — including the branch-name interpolation. A mismatch
silently breaks teardown.</li>
  <li><strong>Link to a file, not a directory.</strong> Point at <code class="language-plaintext highlighter-rouge">.../public/index.html</code>, not
<code class="language-plaintext highlighter-rouge">.../public/</code>; the artifact browser doesn’t do directory-index redirects.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">expire_in</code> is your cleanup.</strong> When the artifact expires the preview 404s,
which is fine — <code class="language-plaintext highlighter-rouge">auto_stop_in</code> closes the environment in parallel. Keep the
two roughly aligned so the button doesn’t outlive the page it points to.</li>
  <li><strong>Free-plan reality check.</strong> This exists precisely because you <em>can’t</em> publish
a second Pages site on Free. If you’re on a tier/version with parallel
deployments (<code class="language-plaintext highlighter-rouge">pages.path_prefix</code>), that’s the cleaner route — but this one
works everywhere and costs nothing.</li>
</ul>

<h2 id="recap">Recap</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>Key</th>
      <th>Does what</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Build</td>
      <td><code class="language-plaintext highlighter-rouge">artifacts.paths: [public]</code> + <code class="language-plaintext highlighter-rouge">expire_in</code></td>
      <td>Renders the page and ships it as a disposable artifact</td>
    </tr>
    <tr>
      <td>Real site</td>
      <td><code class="language-plaintext highlighter-rouge">pages</code> job + <code class="language-plaintext highlighter-rouge">url: $CI_PAGES_URL</code></td>
      <td>Publishes the default branch as the baseline</td>
    </tr>
    <tr>
      <td>MR preview</td>
      <td><code class="language-plaintext highlighter-rouge">environment.url: ${CI_PAGES_URL}/-/jobs/${CI_JOB_ID}/artifacts/public/index.html</code></td>
      <td>Serves <em>this MR’s</em> artifact inline; produces the <strong>View app</strong> button</td>
    </tr>
    <tr>
      <td>Lifetime</td>
      <td><code class="language-plaintext highlighter-rouge">auto_stop_in</code> + <code class="language-plaintext highlighter-rouge">on_stop</code> + <code class="language-plaintext highlighter-rouge">action: stop</code></td>
      <td>Auto-retires the environment; manual stop button too</td>
    </tr>
    <tr>
      <td>Scope</td>
      <td><code class="language-plaintext highlighter-rouge">rules: $CI_MERGE_REQUEST_IID</code></td>
      <td>Preview on MRs, real publish on the default branch</td>
    </tr>
  </tbody>
</table>]]></content><author><name>cm</name></author><category term="gitlab" /><category term="gitlab-ci" /><category term="gitlab-pages" /><category term="ci" /><category term="cd" /><category term="review-app" /><category term="preview" /><category term="merge-request" /><category term="environment" /><category term="artifacts" /><category term="devops" /><summary type="html"><![CDATA[You change a CSS rule, a paragraph of copy, a generated report. The diff looks right. But “looks right in the diff” and “looks right in a browser” are not the same thing, and the only way to close that gap is to render the change somewhere a reviewer can click. This is a how-to for wiring that up on GitLab: every merge request gets its own preview page, with a “View app” button right on the MR. No review server to babysit, no extra infrastructure — and it works on the Free plan, because it doesn’t rely on publishing multiple GitLab Pages sites at once. The example is lifted from a real Terraform + Ansible infrastructure pipeline I maintain, where each MR renders an HTML “infrastructure report” so reviewers can see what a change produces instead of reading raw plan output. Why not parallel deployments The obvious way to do per-MR previews is GitLab Pages parallel deployments (pages.path_prefix), which host each branch under its own URL prefix. It’s clean — and on many setups it’s either gated behind a paid tier or a recent GitLab version. If you’re on the Free plan, you typically get one Pages deployment: your default branch. So we use a different lever that exists on every plan: GitLab serves a job’s artifacts over the web. If a CI job uploads an HTML file as an artifact, GitLab gives you a URL that renders it — sandboxed on the Pages domain. Point a merge-request environment at that URL and you have a per-MR preview without ever publishing a second Pages site. The build artifact is the preview. No parallel deployment required. How the pieces fit A build job produces the page into public/ and uploads it as an artifact (with a short expire_in, since previews are disposable). A review job declares an environment whose url points at that artifact, served on the Pages domain so the HTML renders inline. auto_stop_in + an on_stop job make the environment self-clean. The environment block is the magic: attaching one to a job is exactly what makes the View app button appear on the merge request. Step 1 — build the page into an artifact Nothing special; produce your HTML into public/ and upload it. Keep the default-branch publish and the MR preview as two jobs sharing the same build — here I’ll show a single build that both consume: build-page: stage: build script: - mkdir -p public - ./generate_report.sh &gt; public/index.html # whatever produces your HTML artifacts: paths: - public expire_in: 1 day # previews are disposable rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_MERGE_REQUEST_IID expire_in: 1 day matters: the preview lives only as long as the artifact does, so you’re not accumulating stale renders. Step 2 — publish the real site on the default branch On the default branch, do a normal Pages publish. This is your “production” page and the baseline reviewers compare previews against: pages: stage: deploy script: - echo "Publishing to ${CI_PAGES_URL}" needs: - job: build-page optional: true artifacts: paths: - public rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH environment: name: "Production page - $CI_DEFAULT_BRANCH" url: "$CI_PAGES_URL" (The pages job name is special — it’s what triggers GitLab’s built-in Pages deployment. The script is a formality; the artifact is what gets published.) Step 3 — the per-MR preview (the actual trick) Here’s the review job. It runs only on merge requests and points the environment URL at the build artifact, served on the Pages domain: pages:review: stage: deploy script: - echo "Creating review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" needs: - build-page artifacts: paths: - public rules: - if: $CI_MERGE_REQUEST_IID environment: name: "Review page - ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" url: "${CI_PAGES_URL}/-/jobs/${CI_JOB_ID}/artifacts/public/index.html" auto_stop_in: 1 hour on_stop: stop_review The url line is the whole point, so it’s worth dissecting: ${CI_PAGES_URL} is your project’s Pages root — e.g. https://group.gitlab.io/-/subgroup/project. Using the variable instead of hardcoding the host keeps it correct across renames and custom domains. /-/jobs/${CI_JOB_ID}/artifacts/public/index.html is GitLab’s artifact-browsing path. CI_JOB_ID ties the URL to this job’s artifacts, so each MR pipeline gets its own preview. Serving it through the Pages domain (the group.gitlab.io host) is what makes the browser render the HTML. That detail is the difference between a working preview and a downloaded file — see the gotcha below. auto_stop_in: 1 hour tells GitLab to retire the environment automatically, and on_stop: stop_review names the teardown job. If you’ve ever hardcoded that artifact URL with the literal hostname and left yourself a “replace with variables” TODO — ${CI_PAGES_URL} is the variable. For a project at group/subgroup/project it expands to exactly https://group.gitlab.io/-/subgroup/project, so the composed URL matches the hand-written one character-for-character. Step 4 — the stop job on_stop needs a real job to point at. It does almost nothing — its existence and action: stop are what let GitLab (and the manual “Stop environment” button) close the environment: stop_review: stage: .post script: - echo "Stopping review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" rules: - if: $CI_MERGE_REQUEST_IID when: manual environment: name: "Review page - ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" action: stop The environment.name must match the review job’s exactly, or GitLab won’t know which environment this job stops. The full thing stages: - build - deploy build-page: stage: build script: - mkdir -p public - ./generate_report.sh &gt; public/index.html artifacts: paths: - public expire_in: 1 day rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_MERGE_REQUEST_IID # Real Pages site on the default branch pages: stage: deploy script: - echo "Publishing to ${CI_PAGES_URL}" needs: - job: build-page optional: true artifacts: paths: - public rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH environment: name: "Production page - $CI_DEFAULT_BRANCH" url: "$CI_PAGES_URL" # Per-MR preview served from job artifacts pages:review: stage: deploy script: - echo "Creating review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" needs: - build-page artifacts: paths: - public rules: - if: $CI_MERGE_REQUEST_IID environment: name: "Review page - ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" url: "${CI_PAGES_URL}/-/jobs/${CI_JOB_ID}/artifacts/public/index.html" auto_stop_in: 1 hour on_stop: stop_review stop_review: stage: .post script: - echo "Stopping review environment for ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" rules: - if: $CI_MERGE_REQUEST_IID when: manual environment: name: "Review page - ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" action: stop Open an MR and the pipeline produces a View app button linking to that MR’s freshly built page. The default branch keeps publishing the real site at the Pages root, so reviewers can compare the two side by side. Gotchas Pages must be enabled for the project, even though the preview rides on artifacts rather than a published Pages site — the artifact-serving URL lives on the Pages domain. No pages job has to have run yet; the domain just needs to exist. It only renders inline on the Pages domain. The same artifact reached via $CI_JOB_URL/artifacts/... (the main gitlab.com host) is served with a download disposition for HTML, so the browser saves the file instead of showing it. The ${CI_PAGES_URL}/-/jobs/... form is sandboxed on the Pages domain and renders inline. This is the reason the URL is built the way it is. environment.name must match between the review job and its stop job, character for character — including the branch-name interpolation. A mismatch silently breaks teardown. Link to a file, not a directory. Point at .../public/index.html, not .../public/; the artifact browser doesn’t do directory-index redirects. expire_in is your cleanup. When the artifact expires the preview 404s, which is fine — auto_stop_in closes the environment in parallel. Keep the two roughly aligned so the button doesn’t outlive the page it points to. Free-plan reality check. This exists precisely because you can’t publish a second Pages site on Free. If you’re on a tier/version with parallel deployments (pages.path_prefix), that’s the cleaner route — but this one works everywhere and costs nothing. Recap Piece Key Does what Build artifacts.paths: [public] + expire_in Renders the page and ships it as a disposable artifact Real site pages job + url: $CI_PAGES_URL Publishes the default branch as the baseline MR preview environment.url: ${CI_PAGES_URL}/-/jobs/${CI_JOB_ID}/artifacts/public/index.html Serves this MR’s artifact inline; produces the View app button Lifetime auto_stop_in + on_stop + action: stop Auto-retires the environment; manual stop button too Scope rules: $CI_MERGE_REQUEST_IID Preview on MRs, real publish on the default branch]]></summary></entry><entry><title type="html">Why your Loki alerts never fire — four layers of silent failure</title><link href="https://blog.oyatrino.com/2026/06/why-your-loki-alerts-never-fire-four-layers-of-silent-failure.html" rel="alternate" type="text/html" title="Why your Loki alerts never fire — four layers of silent failure" /><published>2026-06-04T05:00:00-04:00</published><updated>2026-06-04T05:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/06/why-your-loki-alerts-never-fire-four-layers-of-silent-failure</id><content type="html" xml:base="https://blog.oyatrino.com/2026/06/why-your-loki-alerts-never-fire-four-layers-of-silent-failure.html"><![CDATA[<p>A single alert didn’t fire for two and a half months. Four independent bugs, each masking the next, each failing silently. The metrics endpoint told the whole story; the logs told none of it.</p>

<p>The setup: self-hosted Loki receiving logs from a handful of Kubernetes clusters, rules in a ConfigMap synced by a sidecar, alertmanager from a sub-chart of the same Helm umbrella. Names (<code class="language-plaintext highlighter-rouge">my-loki</code>, <code class="language-plaintext highlighter-rouge">my-alert</code>, <code class="language-plaintext highlighter-rouge">my-channel</code>) are anonymized.</p>

<h2 id="tldr">TL;DR</h2>

<p>A LogQL-based alert sat in a ConfigMap, watched by a sidecar, mounted into the ruler container — and silently produced nothing for ten weeks. Four serial bugs, each masking the next:</p>

<ol>
  <li>The sidecar wrote the file to <code class="language-plaintext highlighter-rouge">/etc/loki/rules/</code> but the ruler expects <code class="language-plaintext highlighter-rouge">/etc/loki/rules/&lt;tenant&gt;/</code>.</li>
  <li>The line filter used <code class="language-plaintext highlighter-rouge">{label=~".*"}</code> which Loki rejects (“empty-compatible regex”), so the rule never parsed.</li>
  <li>The line filter caught one phrasing of the error log but not the other.</li>
  <li>The <code class="language-plaintext highlighter-rouge">alertmanager_url</code> resolved to a service that didn’t exist — the chart’s sub-chart had a longer name than the umbrella’s release name.</li>
</ol>

<p>Each layer failed silently. The metrics endpoint told the whole story; the logs told none of it.</p>

<h2 id="the-setup">The setup</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ConfigMap (rules)                            alertmanager service
   ↑                                              ▲
   │ (kustomize configMapGenerator)               │
   │                                              │
+-----------------------------------+   alertmanager_url  +--------------------+
| loki-backend (SimpleScalable)     | ───────────────────►| alertmanager       |
|                                   |                     | (from a sub-chart) |
|   sidecar ──writes──► emptyDir    |                     +--------------------+
|              /etc/loki/rules/...                                ▼
|   ruler ──reads──► same emptyDir                          Slack route
|              /etc/loki/rules/...
+-----------------------------------+
</code></pre></div></div>

<p>Standard “ConfigMap → emptyDir → ruler” pattern. The chart values point the ruler at the alertmanager’s in-cluster DNS name.</p>

<h2 id="the-rule">The rule</h2>

<p>A simple log-based alert: when a particular fatal log line appears, page the team.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">alert</span><span class="pi">:</span> <span class="s">my-alert</span>
  <span class="na">expr</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">sum by (instance) (rate({app=~".*"} |= "fatal: store corruption" [5m])) &gt; 0</span>
  <span class="na">for</span><span class="pi">:</span> <span class="s">0s</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">severity</span><span class="pi">:</span> <span class="s">warning</span>
    <span class="na">maturity</span><span class="pi">:</span> <span class="s">wip</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">summary</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Fatal</span><span class="nv"> </span><span class="s">store</span><span class="nv"> </span><span class="s">corruption</span><span class="nv"> </span><span class="s">on</span><span class="nv"> </span><span class="s">{{</span><span class="nv"> </span><span class="s">$labels.instance</span><span class="nv"> </span><span class="s">}}"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">for: 0s</code> means it should fire on the <em>first</em> eval after a match. Couldn’t be simpler.</p>

<h2 id="the-discovery">The discovery</h2>

<p>I was investigating an unrelated regression and noticed something off: a known-bad pod had been crash-looping for hours, log lines were piling up in Loki containing exactly the substring the alert was supposed to catch, and the team’s WIP-alerts channel was completely silent.</p>

<p>Slack search across the whole instance, bots included, for the alert name: <strong>zero hits</strong> since the rule had been merged ten weeks earlier.</p>

<p>That’s a long time for “the alert never fired”. Time to find out why.</p>

<h2 id="layer-1--the-rule-on-disk">Layer 1 — The rule on disk</h2>

<p>The first thing to check is whether the rule file actually lands where the ruler reads from. The container is distroless (no shell, no <code class="language-plaintext highlighter-rouge">ls</code>), but the sidecar is a Python container:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl <span class="nt">-n</span> monitoring <span class="nb">exec </span>my-loki-backend-0 <span class="nt">-c</span> sidecar <span class="nt">--</span> python3 <span class="nt">-c</span> <span class="se">\</span>
  <span class="s1">'import os; [print(os.path.join(r,f)) for r,_,fs in os.walk("/etc/loki/rules") for f in fs]'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/etc/loki/rules/my-alert.yaml
</code></pre></div></div>

<p>Sidecar did its job. File is there.</p>

<p>Now ask the ruler what it has loaded:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl <span class="nb">exec</span> … <span class="nt">--</span> python3 <span class="nt">-c</span> <span class="se">\</span>
  <span class="s1">'import urllib.request, json;
   r=urllib.request.Request("http://127.0.0.1:3100/prometheus/api/v1/rules",
                            headers={"X-Scope-OrgID":"anonymous"});
   print(json.dumps(json.loads(urllib.request.urlopen(r).read()), indent=2))'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{"data": {"groups": []}, "status": "success"}
</code></pre></div></div>

<p><strong>Zero groups.</strong> The ruler can see no rules at all, even though the file is right there on disk.</p>

<p>The fix: Loki’s local rule store doesn’t scan a flat directory; it scans <code class="language-plaintext highlighter-rouge">&lt;directory&gt;/&lt;tenant_id&gt;/&lt;rule_file&gt;</code>. With <code class="language-plaintext highlighter-rouge">auth_enabled: false</code>, the ruler’s implicit tenant is <code class="language-plaintext highlighter-rouge">anonymous</code>. Files placed directly under the configured directory, with no tenant subdirectory, are silently ignored. The chart’s <code class="language-plaintext highlighter-rouge">sidecar.rules.folder</code> needed to be <code class="language-plaintext highlighter-rouge">/etc/loki/rules/anonymous</code>, not <code class="language-plaintext highlighter-rouge">/etc/loki/rules</code>.</p>

<p>(Note: Loki uses <code class="language-plaintext highlighter-rouge">fake</code> as the tenant on the query path and <code class="language-plaintext highlighter-rouge">anonymous</code> on the ruler path. The two are not interchangeable.)</p>

<p>Move the file under the tenant subdir and check again.</p>

<h2 id="layer-2--the-ruler-refuses-to-parse-the-file">Layer 2 — The ruler refuses to parse the file</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{"data": {"groups": []}, "status": "success"}
</code></pre></div></div>

<p>Still empty. Now to the logs:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl logs my-loki-backend-0 <span class="nt">-c</span> loki <span class="nt">--since</span><span class="o">=</span>30m | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'rule|ruler'</span> | <span class="nb">head</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>level=error caller=ruler.go:579 msg="unable to list rules"
  err="failed to list rule groups for user anonymous:
       error parsing /etc/loki/rules/anonymous/my-alert.yaml:
       parse error : queries require at least one regexp or equality matcher
       that does not have an empty-compatible value. For instance, app=~\".*\"
       does not meet this requirement, but app=~\".+\" will"
</code></pre></div></div>

<p>Exactly the regex used in the rule: <code class="language-plaintext highlighter-rouge">{app=~".*"}</code>. Loki refuses to load any rule file with an empty-compatible regex matcher, because such a query would have to scan every stream the instance knows about — billions of bytes, with no way to fingerprint a useful index.</p>

<p>The fix is to make the selector specific. The right answer here was switching to a label that’s actually meaningful (<code class="language-plaintext highlighter-rouge">{app="the-actual-app"}</code>), which has the added benefit of dropping the candidate-stream count from ~3700 to ~30.</p>

<p>That was the bug the ruler complained about, in plain English, in the logs — but only after layer 1 was fixed. As long as the file was at the wrong path, the ruler wasn’t even attempting to parse it, so the parse-error log line never showed up.</p>

<h2 id="layer-3--the-line-filter-is-too-narrow">Layer 3 — The line filter is too narrow</h2>

<p>Now the rule loads. The ruler logs show it evaluating every 15 seconds. But the alert still doesn’t fire.</p>

<p>Sanity-check the expression directly against Loki. Pull a sample of recent matches:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">TOKEN</span><span class="o">=</span>…   <span class="c"># service account token</span>
<span class="nv">DS</span><span class="o">=</span>https://grafana.example.com/api/datasources/proxy/uid/&lt;loki-uid&gt;

curl <span class="nt">-fsS</span> <span class="nt">--get</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'query={app="my-app"} |= "fatal: store corruption"'</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"start=</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-u</span> <span class="nt">-d</span> <span class="s1">'7 days ago'</span> +%s<span class="si">)</span><span class="s2">000000000"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"end=</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-u</span> +%s<span class="si">)</span><span class="s2">000000000"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"limit=10"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$DS</span><span class="s2">/loki/api/v1/query_range"</span> | jq <span class="s1">'.data.result[]'</span>
</code></pre></div></div>

<p>Three matches in the last week. None on the day I was looking at the known-bad pod.</p>

<p>A look at the crash-looping pod’s actual log lines shows why: the application emits two different phrasings of the same logical error, only one of which contains the substring the alert filters on. The first phrasing comes from one code path (<code class="language-plaintext highlighter-rouge">fatal: store corruption</code>), the second from another (<code class="language-plaintext highlighter-rouge">Store_corruption</code> — capitalised, underscored — bubbled up as part of an exception type name).</p>

<p><code class="language-plaintext highlighter-rouge">|=</code> is a case-sensitive substring match. Of course it didn’t catch the second phrasing. Switch to a regex alternation:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>|~ "fatal: store corruption|Store_corruption"
</code></pre></div></div>

<p>…and the count over the last week jumps from “the few I found” to “thousands” — matching almost exactly the duration of the crash-loop event.</p>

<h2 id="layer-4--the-notifier-dispatches-into-the-void">Layer 4 — The notifier dispatches into the void</h2>

<p>Now the rule is loaded, the selector is correct, and the expression evaluates to a non-zero rate during the crash-loop window — verified directly:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># At the time the pod was crash-looping</span>
curl <span class="nt">--data-urlencode</span> <span class="s1">'query=&lt;expr&gt; &gt; 0'</span> <span class="nt">--data-urlencode</span> <span class="s2">"time=&lt;that timestamp&gt;"</span> …
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[{"metric":{...},"value":[&lt;ts&gt;,"0.0033333333"]}]
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">0.003 &gt; 0</code> → alert should be firing.</p>

<p>Slack: still zero. The wip channel receives plenty of other alerts; it’s not the channel.</p>

<p>The answer is in the ruler’s metrics. There’s a family of <code class="language-plaintext highlighter-rouge">loki_prometheus_notifications_*</code> counters that count attempts the ruler makes to dispatch to alertmanager:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl <span class="nb">exec</span> … <span class="nt">-c</span> sidecar <span class="nt">--</span> python3 <span class="nt">-c</span> <span class="se">\</span>
  <span class="s1">'import urllib.request;
   [print(l) for l in urllib.request.urlopen("http://127.0.0.1:3100/metrics").read().decode().split("\n")
    if "loki_prometheus_notifications_" in l and not l.startswith("#")]'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>loki_prometheus_notifications_alertmanagers_discovered{user="anonymous"}              1
loki_prometheus_notifications_sent_total{alertmanager="http://…/api/v2/alerts"}       2590
loki_prometheus_notifications_errors_total{alertmanager="http://…/api/v2/alerts"}     2590
loki_prometheus_notifications_dropped_total{user="anonymous"}                         2590
loki_prometheus_notifications_latency_seconds{quantile="0.5"}                         NaN
loki_prometheus_notifications_latency_seconds_count                                   2590
</code></pre></div></div>

<p>Every attempted dispatch — 2,590 of them — errored out, was dropped, and produced a <code class="language-plaintext highlighter-rouge">NaN</code> latency quantile because no request ever succeeded long enough to record a duration. And <code class="language-plaintext highlighter-rouge">alertmanagers_discovered=1</code> only means the URL parsed, not that it pointed at anything real.</p>

<p>Why? DNS:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl <span class="nb">exec</span> … <span class="nt">-c</span> sidecar <span class="nt">--</span> python3 <span class="nt">-c</span> <span class="se">\</span>
  <span class="s1">'import socket; print(socket.gethostbyname_ex("the-configured-alertmanager-service.monitoring.svc.cluster.local"))'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>socket.gaierror: [Errno -2] Name does not resolve
</code></pre></div></div>

<p>The chart we use packages alertmanager as a sub-chart of a larger observability umbrella. When the umbrella is installed under a given release name, the sub-chart synthesises its service name from the umbrella’s name <em>plus</em> its own component tag — not just the umbrella’s release name. So <code class="language-plaintext highlighter-rouge">kubectl get svc -n monitoring | grep alert</code> shows a service name with an extra segment in the middle, several characters longer than what the original author of the rule had hand-typed into the values file.</p>

<p>The notifier never logged the DNS failure (DNS errors are info-level in the upstream notifier code, and the bundled binary doesn’t surface them). The only place the failure appeared was in the metrics endpoint — and even there, the counters initially read as “lots of activity, all happening”, which is the wrong intuition until you look at <code class="language-plaintext highlighter-rouge">errors_total</code> and <code class="language-plaintext highlighter-rouge">dropped_total</code> side by side.</p>

<p>One-character fix to the values file (well, one segment) and we wait for the pods to roll.</p>

<h2 id="verification">Verification</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>loki_prometheus_notifications_errors_total{alertmanager="…right-name…"}  0
loki_prometheus_notifications_dropped_total                              0
loki_prometheus_notifications_sent_total                                 0  (no firings yet — the pod stopped crash-looping)
loki_prometheus_notifications_latency_seconds_count                      0
</code></pre></div></div>

<p>A finite latency quantile would have been even better evidence, but we’d need a firing to populate it. The right read on a freshly-rolled pod is: <code class="language-plaintext highlighter-rouge">errors_total</code> and <code class="language-plaintext highlighter-rouge">dropped_total</code> stop incrementing, the alertmanager target label in the metric carries the <em>new</em> URL, and <code class="language-plaintext highlighter-rouge">alertmanagers_discovered</code> is 1.</p>

<h2 id="why-all-four-hid-behind-each-other">Why all four hid behind each other</h2>

<p>Each layer fails silently in its own particular way, and each masks the next:</p>

<ol>
  <li><strong>Wrong path</strong> → the ruler can’t find the file. There’s no error log because the ruler isn’t even trying to read this file — it’s scanning a directory the file isn’t in. You can’t see the parse error from layer 2 until you fix layer 1.</li>
  <li><strong>Bad regex</strong> → the ruler tries to parse the file, fails, and logs the error. But you only notice the parse-error logs after you’ve fixed layer 1 and started looking. From the outside, layer 1 and layer 2 produce identical symptoms (“the alert isn’t loaded”).</li>
  <li><strong>Narrow line filter</strong> → the rule loads and evaluates, but the query returns zero. You can only tell whether that’s “everything is fine, no real event” or “your regex is too narrow” by running the query directly and visually inspecting actual log lines for variant phrasings.</li>
  <li><strong>Wrong alertmanager URL</strong> → the notifier dispatches, increments <code class="language-plaintext highlighter-rouge">sent_total</code> (which counts attempts, not successes), and silently fails. The DNS error is buried at info-level. Only the <code class="language-plaintext highlighter-rouge">errors_total / sent_total / dropped_total</code> triplet exposes the truth.</li>
</ol>

<p>None of the four layers emits a warning at the level the operator would see by accident. Every one needs a specific assertion when the rule is first wired up. The metrics endpoint, not the logs, is the source of truth at the dispatch layer.</p>

<h2 id="what-to-do-differently">What to do differently</h2>

<p><strong>Bench-test new rules before merging.</strong> Drop a <code class="language-plaintext highlighter-rouge">vector(1) &gt; 0</code> test rule in for ten minutes after every ruler-related change and watch it land on Slack. A passing test there proves all four layers at once. Remove it before merging the real rule.</p>

<p><strong>Monitor the notifier metrics.</strong> A Grafana panel of <code class="language-plaintext highlighter-rouge">loki_prometheus_notifications_errors_total</code> per alertmanager target is five lines of PromQL. Without it, 2,590 failed dispatches look like 2,590 successful attempts until you check <code class="language-plaintext highlighter-rouge">errors_total</code> and <code class="language-plaintext highlighter-rouge">dropped_total</code> side by side.</p>

<p>The whole chain — ConfigMap, sidecar, ruler, notifier, alertmanager, Slack — looks like a single declarative pipeline. It isn’t: each link is a separate process making independent decisions, and each fails without surfacing an error at the level an operator watches. Write the diagnostic chain down somewhere durable; you’ll need it again.</p>]]></content><author><name>cm</name></author><category term="loki" /><category term="alerting" /><category term="observability" /><category term="kubernetes" /><category term="sre" /><category term="debugging" /><category term="helm" /><category term="alertmanager" /><category term="prometheus" /><category term="gitops" /><summary type="html"><![CDATA[A single alert didn’t fire for two and a half months. Four independent bugs, each masking the next, each failing silently. The metrics endpoint told the whole story; the logs told none of it. The setup: self-hosted Loki receiving logs from a handful of Kubernetes clusters, rules in a ConfigMap synced by a sidecar, alertmanager from a sub-chart of the same Helm umbrella. Names (my-loki, my-alert, my-channel) are anonymized. TL;DR A LogQL-based alert sat in a ConfigMap, watched by a sidecar, mounted into the ruler container — and silently produced nothing for ten weeks. Four serial bugs, each masking the next: The sidecar wrote the file to /etc/loki/rules/ but the ruler expects /etc/loki/rules/&lt;tenant&gt;/. The line filter used {label=~".*"} which Loki rejects (“empty-compatible regex”), so the rule never parsed. The line filter caught one phrasing of the error log but not the other. The alertmanager_url resolved to a service that didn’t exist — the chart’s sub-chart had a longer name than the umbrella’s release name. Each layer failed silently. The metrics endpoint told the whole story; the logs told none of it. The setup ConfigMap (rules) alertmanager service ↑ ▲ │ (kustomize configMapGenerator) │ │ │ +-----------------------------------+ alertmanager_url +--------------------+ | loki-backend (SimpleScalable) | ───────────────────►| alertmanager | | | | (from a sub-chart) | | sidecar ──writes──► emptyDir | +--------------------+ | /etc/loki/rules/... ▼ | ruler ──reads──► same emptyDir Slack route | /etc/loki/rules/... +-----------------------------------+ Standard “ConfigMap → emptyDir → ruler” pattern. The chart values point the ruler at the alertmanager’s in-cluster DNS name. The rule A simple log-based alert: when a particular fatal log line appears, page the team. - alert: my-alert expr: | sum by (instance) (rate({app=~".*"} |= "fatal: store corruption" [5m])) &gt; 0 for: 0s labels: severity: warning maturity: wip annotations: summary: "Fatal store corruption on {{ $labels.instance }}" for: 0s means it should fire on the first eval after a match. Couldn’t be simpler. The discovery I was investigating an unrelated regression and noticed something off: a known-bad pod had been crash-looping for hours, log lines were piling up in Loki containing exactly the substring the alert was supposed to catch, and the team’s WIP-alerts channel was completely silent. Slack search across the whole instance, bots included, for the alert name: zero hits since the rule had been merged ten weeks earlier. That’s a long time for “the alert never fired”. Time to find out why. Layer 1 — The rule on disk The first thing to check is whether the rule file actually lands where the ruler reads from. The container is distroless (no shell, no ls), but the sidecar is a Python container: kubectl -n monitoring exec my-loki-backend-0 -c sidecar -- python3 -c \ 'import os; [print(os.path.join(r,f)) for r,_,fs in os.walk("/etc/loki/rules") for f in fs]' /etc/loki/rules/my-alert.yaml Sidecar did its job. File is there. Now ask the ruler what it has loaded: kubectl exec … -- python3 -c \ 'import urllib.request, json; r=urllib.request.Request("http://127.0.0.1:3100/prometheus/api/v1/rules", headers={"X-Scope-OrgID":"anonymous"}); print(json.dumps(json.loads(urllib.request.urlopen(r).read()), indent=2))' {"data": {"groups": []}, "status": "success"} Zero groups. The ruler can see no rules at all, even though the file is right there on disk. The fix: Loki’s local rule store doesn’t scan a flat directory; it scans &lt;directory&gt;/&lt;tenant_id&gt;/&lt;rule_file&gt;. With auth_enabled: false, the ruler’s implicit tenant is anonymous. Files placed directly under the configured directory, with no tenant subdirectory, are silently ignored. The chart’s sidecar.rules.folder needed to be /etc/loki/rules/anonymous, not /etc/loki/rules. (Note: Loki uses fake as the tenant on the query path and anonymous on the ruler path. The two are not interchangeable.) Move the file under the tenant subdir and check again. Layer 2 — The ruler refuses to parse the file {"data": {"groups": []}, "status": "success"} Still empty. Now to the logs: kubectl logs my-loki-backend-0 -c loki --since=30m | grep -E 'rule|ruler' | head level=error caller=ruler.go:579 msg="unable to list rules" err="failed to list rule groups for user anonymous: error parsing /etc/loki/rules/anonymous/my-alert.yaml: parse error : queries require at least one regexp or equality matcher that does not have an empty-compatible value. For instance, app=~\".*\" does not meet this requirement, but app=~\".+\" will" Exactly the regex used in the rule: {app=~".*"}. Loki refuses to load any rule file with an empty-compatible regex matcher, because such a query would have to scan every stream the instance knows about — billions of bytes, with no way to fingerprint a useful index. The fix is to make the selector specific. The right answer here was switching to a label that’s actually meaningful ({app="the-actual-app"}), which has the added benefit of dropping the candidate-stream count from ~3700 to ~30. That was the bug the ruler complained about, in plain English, in the logs — but only after layer 1 was fixed. As long as the file was at the wrong path, the ruler wasn’t even attempting to parse it, so the parse-error log line never showed up. Layer 3 — The line filter is too narrow Now the rule loads. The ruler logs show it evaluating every 15 seconds. But the alert still doesn’t fire. Sanity-check the expression directly against Loki. Pull a sample of recent matches: TOKEN=… # service account token DS=https://grafana.example.com/api/datasources/proxy/uid/&lt;loki-uid&gt; curl -fsS --get \ --data-urlencode 'query={app="my-app"} |= "fatal: store corruption"' \ --data-urlencode "start=$(date -u -d '7 days ago' +%s)000000000" \ --data-urlencode "end=$(date -u +%s)000000000" \ --data-urlencode "limit=10" \ -H "Authorization: Bearer $TOKEN" "$DS/loki/api/v1/query_range" | jq '.data.result[]' Three matches in the last week. None on the day I was looking at the known-bad pod. A look at the crash-looping pod’s actual log lines shows why: the application emits two different phrasings of the same logical error, only one of which contains the substring the alert filters on. The first phrasing comes from one code path (fatal: store corruption), the second from another (Store_corruption — capitalised, underscored — bubbled up as part of an exception type name). |= is a case-sensitive substring match. Of course it didn’t catch the second phrasing. Switch to a regex alternation: |~ "fatal: store corruption|Store_corruption" …and the count over the last week jumps from “the few I found” to “thousands” — matching almost exactly the duration of the crash-loop event. Layer 4 — The notifier dispatches into the void Now the rule is loaded, the selector is correct, and the expression evaluates to a non-zero rate during the crash-loop window — verified directly: # At the time the pod was crash-looping curl --data-urlencode 'query=&lt;expr&gt; &gt; 0' --data-urlencode "time=&lt;that timestamp&gt;" … [{"metric":{...},"value":[&lt;ts&gt;,"0.0033333333"]}] 0.003 &gt; 0 → alert should be firing. Slack: still zero. The wip channel receives plenty of other alerts; it’s not the channel. The answer is in the ruler’s metrics. There’s a family of loki_prometheus_notifications_* counters that count attempts the ruler makes to dispatch to alertmanager: kubectl exec … -c sidecar -- python3 -c \ 'import urllib.request; [print(l) for l in urllib.request.urlopen("http://127.0.0.1:3100/metrics").read().decode().split("\n") if "loki_prometheus_notifications_" in l and not l.startswith("#")]' loki_prometheus_notifications_alertmanagers_discovered{user="anonymous"} 1 loki_prometheus_notifications_sent_total{alertmanager="http://…/api/v2/alerts"} 2590 loki_prometheus_notifications_errors_total{alertmanager="http://…/api/v2/alerts"} 2590 loki_prometheus_notifications_dropped_total{user="anonymous"} 2590 loki_prometheus_notifications_latency_seconds{quantile="0.5"} NaN loki_prometheus_notifications_latency_seconds_count 2590 Every attempted dispatch — 2,590 of them — errored out, was dropped, and produced a NaN latency quantile because no request ever succeeded long enough to record a duration. And alertmanagers_discovered=1 only means the URL parsed, not that it pointed at anything real. Why? DNS: kubectl exec … -c sidecar -- python3 -c \ 'import socket; print(socket.gethostbyname_ex("the-configured-alertmanager-service.monitoring.svc.cluster.local"))' socket.gaierror: [Errno -2] Name does not resolve The chart we use packages alertmanager as a sub-chart of a larger observability umbrella. When the umbrella is installed under a given release name, the sub-chart synthesises its service name from the umbrella’s name plus its own component tag — not just the umbrella’s release name. So kubectl get svc -n monitoring | grep alert shows a service name with an extra segment in the middle, several characters longer than what the original author of the rule had hand-typed into the values file. The notifier never logged the DNS failure (DNS errors are info-level in the upstream notifier code, and the bundled binary doesn’t surface them). The only place the failure appeared was in the metrics endpoint — and even there, the counters initially read as “lots of activity, all happening”, which is the wrong intuition until you look at errors_total and dropped_total side by side. One-character fix to the values file (well, one segment) and we wait for the pods to roll. Verification loki_prometheus_notifications_errors_total{alertmanager="…right-name…"} 0 loki_prometheus_notifications_dropped_total 0 loki_prometheus_notifications_sent_total 0 (no firings yet — the pod stopped crash-looping) loki_prometheus_notifications_latency_seconds_count 0 A finite latency quantile would have been even better evidence, but we’d need a firing to populate it. The right read on a freshly-rolled pod is: errors_total and dropped_total stop incrementing, the alertmanager target label in the metric carries the new URL, and alertmanagers_discovered is 1. Why all four hid behind each other Each layer fails silently in its own particular way, and each masks the next: Wrong path → the ruler can’t find the file. There’s no error log because the ruler isn’t even trying to read this file — it’s scanning a directory the file isn’t in. You can’t see the parse error from layer 2 until you fix layer 1. Bad regex → the ruler tries to parse the file, fails, and logs the error. But you only notice the parse-error logs after you’ve fixed layer 1 and started looking. From the outside, layer 1 and layer 2 produce identical symptoms (“the alert isn’t loaded”). Narrow line filter → the rule loads and evaluates, but the query returns zero. You can only tell whether that’s “everything is fine, no real event” or “your regex is too narrow” by running the query directly and visually inspecting actual log lines for variant phrasings. Wrong alertmanager URL → the notifier dispatches, increments sent_total (which counts attempts, not successes), and silently fails. The DNS error is buried at info-level. Only the errors_total / sent_total / dropped_total triplet exposes the truth. None of the four layers emits a warning at the level the operator would see by accident. Every one needs a specific assertion when the rule is first wired up. The metrics endpoint, not the logs, is the source of truth at the dispatch layer. What to do differently Bench-test new rules before merging. Drop a vector(1) &gt; 0 test rule in for ten minutes after every ruler-related change and watch it land on Slack. A passing test there proves all four layers at once. Remove it before merging the real rule. Monitor the notifier metrics. A Grafana panel of loki_prometheus_notifications_errors_total per alertmanager target is five lines of PromQL. Without it, 2,590 failed dispatches look like 2,590 successful attempts until you check errors_total and dropped_total side by side. The whole chain — ConfigMap, sidecar, ruler, notifier, alertmanager, Slack — looks like a single declarative pipeline. It isn’t: each link is a separate process making independent decisions, and each fails without surfacing an error at the level an operator watches. Write the diagnostic chain down somewhere durable; you’ll need it again.]]></summary></entry><entry><title type="html">OCaml’s ‘unknown scheme’ on Debian slim — when /etc/services goes missing</title><link href="https://blog.oyatrino.com/2026/06/ocaml-https-unknown-scheme-debian-slim.html" rel="alternate" type="text/html" title="OCaml’s ‘unknown scheme’ on Debian slim — when /etc/services goes missing" /><published>2026-06-03T10:00:00-04:00</published><updated>2026-06-03T10:00:00-04:00</updated><id>https://blog.oyatrino.com/2026/06/ocaml-https-unknown-scheme-debian-slim</id><content type="html" xml:base="https://blog.oyatrino.com/2026/06/ocaml-https-unknown-scheme-debian-slim.html"><![CDATA[<p>I shipped a small OCaml HTTP service to a Kubernetes cluster. Inbound traffic worked. Slash commands (POST in, JSON out) worked. But every time the service had to <em>call</em> an <code class="language-plaintext highlighter-rouge">https://</code> endpoint, it died with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[EROR] failure (cid=N, error="resolution failed: unknown scheme")
[EROR] response (cid=N, code=500)
</code></pre></div></div>

<p>The path from that error to a one-line fix involved being publicly wrong about the cause first.</p>

<p>I’ll use generic names: <code class="language-plaintext highlighter-rouge">my-service</code> (the OCaml HTTP server), <code class="language-plaintext highlighter-rouge">my-service:latest</code> (its image), <code class="language-plaintext highlighter-rouge">https://hooks.example.com/...</code> (an outbound URL it tries to POST to). Stack: OCaml 5.2 + <code class="language-plaintext highlighter-rouge">cohttp-lwt-unix</code> 6.2 + <code class="language-plaintext highlighter-rouge">conduit-lwt-unix</code> 8.0 + a multi-stage Dockerfile that copies the built binary onto <code class="language-plaintext highlighter-rouge">debian:bookworm-slim</code>.</p>

<h2 id="symptoms">Symptoms</h2>

<p>The service has a <code class="language-plaintext highlighter-rouge">/metrics</code> Prometheus endpoint. After the first outbound failure, its counters looked like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>my_service_http_requests_total{method="POST",path="/inbound/slash",code="200"} 14
my_service_http_requests_total{method="POST",path="/inbound/interactivity",code="500"} 1
</code></pre></div></div>

<p>Server-side, every successful slash-command POST handled itself — read, decode, build JSON response, write — without ever touching the network. The 500 was the first request that needed to make an outbound HTTPS call (post a webhook back to the platform’s <code class="language-plaintext highlighter-rouge">response_url</code>). The bot logged the URL it was about to call:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[DBUG] send HTTP request (url="https://slack.com/api/chat.postMessage", verb="POST")
[EROR] failure (cid=N, error="resolution failed: unknown scheme")
</code></pre></div></div>

<p>The URL string is plainly well-formed: <code class="language-plaintext highlighter-rouge">https://</code>, host, path. So why “unknown scheme”?</p>

<h2 id="wrong-diagnosis-1-tls-isnt-linked">Wrong diagnosis #1: “TLS isn’t linked”</h2>

<p>My first hypothesis was that the binary lacks TLS support. Plausible: <code class="language-plaintext highlighter-rouge">cohttp-lwt-unix</code> alone is just an HTTP-over-TCP library; for <code class="language-plaintext highlighter-rouge">https://</code> you need a TLS implementation (<code class="language-plaintext highlighter-rouge">tls-lwt</code> for pure OCaml, or <code class="language-plaintext highlighter-rouge">lwt_ssl</code> for OpenSSL). If you forget to depend on one, <code class="language-plaintext highlighter-rouge">Conduit_lwt_unix</code> has no resolver registered for the <code class="language-plaintext highlighter-rouge">https</code> scheme.</p>

<p>I checked the published binary for TLS symbols by running:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--rm</span> <span class="nt">--entrypoint</span> /bin/sh my-service:latest <span class="se">\</span>
  <span class="nt">-c</span> <span class="s1">'strings /usr/local/bin/my-service | grep -iE "tls|x509|ssl|cert"'</span>
</code></pre></div></div>

<p>The output was empty. I took that as proof that no TLS library was linked, opened a PR adding <code class="language-plaintext highlighter-rouge">tls-lwt</code> and <code class="language-plaintext highlighter-rouge">ca-certs</code> to the dune <code class="language-plaintext highlighter-rouge">(libraries …)</code> stanza, and wrote a confident description that traced the symptom to missing TLS linkage.</p>

<p>The check was wrong. The runtime image is <code class="language-plaintext highlighter-rouge">debian:bookworm-slim</code>, which doesn’t ship <code class="language-plaintext highlighter-rouge">strings</code> — so the command silently produced no matches because <code class="language-plaintext highlighter-rouge">binutils</code> wasn’t installed, not because the symbols were absent. I’d run the check inside the wrong container.</p>

<h2 id="the-reviewers-pushback">The reviewer’s pushback</h2>

<p>A reviewer caught it:</p>

<blockquote>
  <p>The PR description claims <code class="language-plaintext highlighter-rouge">tls-lwt</code> wasn’t being linked into the executable, but the project already lists <code class="language-plaintext highlighter-rouge">tls-lwt</code> in the <code class="language-plaintext highlighter-rouge">(executables …)</code> stanza. If outbound HTTPS is still failing with unknown scheme, it likely means the specific Conduit HTTPS resolver/initializer module isn’t getting linked or initialized.</p>
</blockquote>

<p>I re-checked, this time extracting the binary onto the host (<code class="language-plaintext highlighter-rouge">docker cp</code> out of a container created with <code class="language-plaintext highlighter-rouge">docker create</code>, then running <code class="language-plaintext highlighter-rouge">strings</code> on the host where it actually exists):</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ cid</span><span class="o">=</span><span class="si">$(</span>docker create my-service:latest<span class="si">)</span>
<span class="nv">$ </span>docker <span class="nb">cp</span> <span class="s2">"</span><span class="nv">$cid</span><span class="s2">"</span>:/usr/local/bin/my-service /tmp/my-service-bin
<span class="nv">$ </span>docker <span class="nb">rm</span> <span class="nv">$cid</span>

<span class="nv">$ </span>strings /tmp/my-service-bin | <span class="nb">grep</span> <span class="nt">-cE</span> <span class="s2">"^(Tls|X509|Mirage_crypto|Tls_lwt|X509_lwt)(__|</span><span class="nv">$)</span><span class="s2">"</span>
1871

<span class="nv">$ </span>strings /tmp/my-service-bin | <span class="nb">grep</span> <span class="nt">-iE</span> <span class="s2">"^Tls_lwt|^X509|^Mirage_crypto_rng_unix"</span> | <span class="nb">sort</span> <span class="nt">-u</span> | <span class="nb">head
</span>Mirage_crypto_rng_unix
Tls
Tls_lwt
Tls__Core
Tls__State
Tls__Utils
X509
X509__
X509__Crl
X509__P12
</code></pre></div></div>

<p>Plenty of TLS code. So the binary was fine. <code class="language-plaintext highlighter-rouge">Conduit_lwt_unix</code> 8.0 also already lists <code class="language-plaintext highlighter-rouge">tls-lwt</code> and <code class="language-plaintext highlighter-rouge">ca-certs</code> in its META <code class="language-plaintext highlighter-rouge">requires</code>, so they get pulled in transitively from <code class="language-plaintext highlighter-rouge">cohttp-lwt-unix</code>. The first diagnosis was wrong; the binary has had HTTPS support all along.</p>

<p>Which meant my open PR was wrong too, and I had to figure out the actual cause.</p>

<h2 id="reading-conduits-source">Reading Conduit’s source</h2>

<p>The error string “resolution failed: unknown scheme” isn’t in my code or my dependencies’ <code class="language-plaintext highlighter-rouge">*.ml</code> files outside conduit. The path through conduit looks like this:</p>

<p><code class="language-plaintext highlighter-rouge">conduit-8.0.0/lib/conduit/resolver.ml</code>:</p>

<div class="language-ocaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">resolve_uri</span> <span class="o">?</span><span class="n">rewrites</span> <span class="o">~</span><span class="n">uri</span> <span class="n">t</span> <span class="o">=</span>
  <span class="k">match</span> <span class="nn">Uri</span><span class="p">.</span><span class="n">scheme</span> <span class="n">uri</span> <span class="k">with</span>
  <span class="o">|</span> <span class="nc">None</span> <span class="o">-&gt;</span> <span class="n">return</span> <span class="p">(</span><span class="nt">`Unknown</span> <span class="s2">"no scheme"</span><span class="p">)</span>
  <span class="o">|</span> <span class="nc">Some</span> <span class="n">scheme</span> <span class="o">-&gt;</span> <span class="p">(</span>
      <span class="n">t</span><span class="o">.</span><span class="n">service</span> <span class="n">scheme</span> <span class="o">&gt;&gt;=</span> <span class="k">function</span>
      <span class="o">|</span> <span class="nc">None</span> <span class="o">-&gt;</span> <span class="n">return</span> <span class="p">(</span><span class="nt">`Unknown</span> <span class="s2">"unknown scheme"</span><span class="p">)</span>
      <span class="o">...</span>
</code></pre></div></div>

<p>So <code class="language-plaintext highlighter-rouge">unknown scheme</code> fires when <code class="language-plaintext highlighter-rouge">t.service "https"</code> returns <code class="language-plaintext highlighter-rouge">None</code>. The default <code class="language-plaintext highlighter-rouge">t.service</code> is <code class="language-plaintext highlighter-rouge">system_service</code>, defined in <code class="language-plaintext highlighter-rouge">conduit-lwt-unix-8.0.0/lib/resolver_lwt_unix.ml</code>:</p>

<div class="language-ocaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">is_tls_service</span> <span class="o">=</span>
  <span class="k">function</span>
  <span class="o">|</span> <span class="s2">"https"</span> <span class="o">|</span> <span class="s2">"imaps"</span> <span class="o">-&gt;</span> <span class="bp">true</span>
  <span class="o">|</span> <span class="n">_</span> <span class="o">-&gt;</span> <span class="bp">false</span>

<span class="k">let</span> <span class="n">system_service</span> <span class="n">name</span> <span class="o">=</span>
  <span class="nn">Lwt</span><span class="p">.</span><span class="n">catch</span>
    <span class="p">(</span><span class="k">fun</span> <span class="bp">()</span> <span class="o">-&gt;</span>
      <span class="nn">Lwt_unix</span><span class="p">.</span><span class="n">getservbyname</span> <span class="n">name</span> <span class="s2">"tcp"</span> <span class="o">&gt;&gt;=</span> <span class="k">fun</span> <span class="n">s</span> <span class="o">-&gt;</span>
      <span class="k">let</span> <span class="n">tls</span> <span class="o">=</span> <span class="n">is_tls_service</span> <span class="n">name</span> <span class="k">in</span>
      <span class="k">let</span> <span class="n">svc</span> <span class="o">=</span> <span class="p">{</span> <span class="nn">Resolver</span><span class="p">.</span><span class="n">name</span><span class="p">;</span> <span class="n">port</span> <span class="o">=</span> <span class="n">s</span><span class="o">.</span><span class="nn">Lwt_unix</span><span class="p">.</span><span class="n">s_port</span><span class="p">;</span> <span class="n">tls</span> <span class="p">}</span> <span class="k">in</span>
      <span class="nn">Lwt</span><span class="p">.</span><span class="n">return</span> <span class="p">(</span><span class="nc">Some</span> <span class="n">svc</span><span class="p">))</span>
    <span class="p">(</span><span class="k">function</span> <span class="nc">Not_found</span> <span class="o">-&gt;</span> <span class="nn">Lwt</span><span class="p">.</span><span class="n">return_none</span> <span class="o">|</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="nn">Lwt</span><span class="p">.</span><span class="n">reraise</span> <span class="n">e</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Lwt_unix.getservbyname "https" "tcp"</code>. That’s the libc <code class="language-plaintext highlighter-rouge">getservbyname(3)</code> syscall, which reads <code class="language-plaintext highlighter-rouge">/etc/services</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ man 5 services
DESCRIPTION
       services is a plain ASCII file providing a mapping between human-friendly
       textual names for internet services, and their underlying assigned port
       numbers...
</code></pre></div></div>

<p>If the file is absent, <code class="language-plaintext highlighter-rouge">getservbyname</code> returns <code class="language-plaintext highlighter-rouge">NULL</code>, OCaml raises <code class="language-plaintext highlighter-rouge">Not_found</code>, <code class="language-plaintext highlighter-rouge">system_service</code> returns <code class="language-plaintext highlighter-rouge">None</code>, conduit returns <code class="language-plaintext highlighter-rouge">Unknown "unknown scheme"</code>. <strong>Independently of TLS.</strong> Independently of the URI scheme too, if the OS lookup for that scheme fails.</p>

<h2 id="confirming-the-actual-cause">Confirming the actual cause</h2>

<p>Check the file in the running container:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>docker run <span class="nt">--rm</span> <span class="nt">--entrypoint</span> /bin/sh my-service:latest <span class="nt">-c</span> <span class="s1">'ls -la /etc/services'</span>
<span class="nb">ls</span>: cannot access <span class="s1">'/etc/services'</span>: No such file or directory
</code></pre></div></div>

<p>And on a stock <code class="language-plaintext highlighter-rouge">debian:bookworm-slim</code>:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>docker run <span class="nt">--rm</span> <span class="nt">--entrypoint</span> /bin/sh debian:bookworm-slim <span class="nt">-c</span> <span class="s1">'ls -la /etc/services'</span>
<span class="nb">ls</span>: cannot access <span class="s1">'/etc/services'</span>: No such file or directory
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">debian:bookworm-slim</code> doesn’t ship <code class="language-plaintext highlighter-rouge">/etc/services</code>. The Debian package that provides it is <code class="language-plaintext highlighter-rouge">netbase</code>, which is <em>not</em> in the slim base. Same story with <code class="language-plaintext highlighter-rouge">iana-etc</code> on Alpine, and most distroless variants.</p>

<h2 id="the-fix">The fix</h2>

<p>One line in the runtime stage of the Dockerfile:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
     libgmp10 \
     libssl3 \
     zlib1g \
     ca-certificates \
<span class="gi">+    netbase \
</span>  &amp;&amp; rm -rf /var/lib/apt/lists/*
</code></pre></div></div>

<p>That’s it. No dune changes, no opam changes, no code changes. The Dockerfile had been omitting one OS-level data file the OCaml runtime depends on, and <code class="language-plaintext highlighter-rouge">cohttp-lwt-unix</code> translated the absence into a misleading-looking library-level error.</p>

<h2 id="verifying-it-actually-works">Verifying it actually works</h2>

<p>I went back and proved the fix end-to-end with a controlled reproducer. Built both the broken and fixed images, ran each one, and sent a properly HMAC-signed POST to the interactivity endpoint:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">hmac</span><span class="p">,</span> <span class="n">hashlib</span><span class="p">,</span> <span class="n">time</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">,</span> <span class="n">json</span>

<span class="n">secret</span> <span class="o">=</span> <span class="sa">b</span><span class="s">"signing-secret"</span>
<span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"type"</span><span class="p">:</span> <span class="s">"block_actions"</span><span class="p">,</span>
    <span class="s">"user"</span><span class="p">:</span> <span class="p">{</span><span class="s">"id"</span><span class="p">:</span> <span class="s">"U1"</span><span class="p">,</span> <span class="s">"team_id"</span><span class="p">:</span> <span class="s">"T"</span><span class="p">,</span> <span class="s">"username"</span><span class="p">:</span> <span class="s">"test"</span><span class="p">,</span> <span class="s">"name"</span><span class="p">:</span> <span class="s">"test"</span><span class="p">},</span>
    <span class="s">"team"</span><span class="p">:</span> <span class="p">{</span><span class="s">"id"</span><span class="p">:</span> <span class="s">"T"</span><span class="p">,</span> <span class="s">"domain"</span><span class="p">:</span> <span class="s">"d"</span><span class="p">},</span>
    <span class="s">"channel"</span><span class="p">:</span> <span class="p">{</span><span class="s">"id"</span><span class="p">:</span> <span class="s">"C"</span><span class="p">,</span> <span class="s">"name"</span><span class="p">:</span> <span class="s">"c"</span><span class="p">},</span>
    <span class="s">"container"</span><span class="p">:</span> <span class="p">{</span><span class="s">"type"</span><span class="p">:</span><span class="s">"message"</span><span class="p">,</span><span class="s">"message_ts"</span><span class="p">:</span><span class="s">"1"</span><span class="p">,</span><span class="s">"channel_id"</span><span class="p">:</span><span class="s">"C"</span><span class="p">,</span><span class="s">"is_ephemeral"</span><span class="p">:</span><span class="bp">True</span><span class="p">},</span>
    <span class="s">"trigger_id"</span><span class="p">:</span> <span class="s">"T"</span><span class="p">,</span>
    <span class="s">"is_enterprise_install"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
    <span class="s">"response_url"</span><span class="p">:</span> <span class="s">"https://hooks.example.com/actions/T/1/x"</span><span class="p">,</span>
    <span class="s">"actions"</span><span class="p">:</span> <span class="p">[{</span><span class="s">"type"</span><span class="p">:</span><span class="s">"users_select"</span><span class="p">,</span><span class="s">"action_id"</span><span class="p">:</span><span class="s">"a"</span><span class="p">,</span><span class="s">"block_id"</span><span class="p">:</span><span class="s">"b"</span><span class="p">,</span>
                 <span class="s">"selected_user"</span><span class="p">:</span><span class="s">"U2"</span><span class="p">,</span><span class="s">"action_ts"</span><span class="p">:</span><span class="s">"1"</span><span class="p">}],</span>
<span class="p">}</span>
<span class="n">body</span> <span class="o">=</span> <span class="s">"payload="</span> <span class="o">+</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">.</span><span class="n">quote</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">payload</span><span class="p">),</span> <span class="n">safe</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="n">ts</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()))</span>
<span class="n">sig</span> <span class="o">=</span> <span class="s">"v0="</span> <span class="o">+</span> <span class="n">hmac</span><span class="p">.</span><span class="n">new</span><span class="p">(</span><span class="n">secret</span><span class="p">,</span> <span class="sa">f</span><span class="s">"v0:</span><span class="si">{</span><span class="n">ts</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">body</span><span class="si">}</span><span class="s">"</span><span class="p">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">sha256</span><span class="p">).</span><span class="n">hexdigest</span><span class="p">()</span>

<span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="n">Request</span><span class="p">(</span>
    <span class="s">"http://localhost:8080/inbound/interactivity"</span><span class="p">,</span>
    <span class="n">data</span><span class="o">=</span><span class="n">body</span><span class="p">.</span><span class="n">encode</span><span class="p">(),</span>
    <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s">"Content-Type"</span><span class="p">:</span> <span class="s">"application/x-www-form-urlencoded"</span><span class="p">,</span>
             <span class="s">"X-Slack-Signature"</span><span class="p">:</span> <span class="n">sig</span><span class="p">,</span>
             <span class="s">"X-Slack-Request-Timestamp"</span><span class="p">:</span> <span class="n">ts</span><span class="p">},</span>
    <span class="n">method</span><span class="o">=</span><span class="s">"POST"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">try</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"HTTP"</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="n">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">10</span><span class="p">).</span><span class="n">status</span><span class="p">)</span>
<span class="k">except</span> <span class="n">urllib</span><span class="p">.</span><span class="n">error</span><span class="p">.</span><span class="n">HTTPError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"HTTP"</span><span class="p">,</span> <span class="n">e</span><span class="p">.</span><span class="n">code</span><span class="p">)</span>
</code></pre></div></div>

<p>The fake <code class="language-plaintext highlighter-rouge">response_url</code> (<code class="language-plaintext highlighter-rouge">https://hooks.example.com/actions/T/1/x</code>) doesn’t exist on the platform, so the platform’s edge will return 404 — but that’s a <em>success</em> indicator for the experiment. If the outbound HTTPS call goes through at the TLS layer, we’ll see a 4xx body coming back from the platform. If it fails at conduit’s resolver, we’ll see the original <code class="language-plaintext highlighter-rouge">unknown scheme</code> and HTTP 500.</p>

<p><strong>Broken image:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP 500
[INFO] request (cid=0, verb="POST", path="/inbound/interactivity")
[WARN] add assignee (cid=0, ..., result="no such alert")
[EROR] failure (cid=0, error="resolution failed: unknown scheme")
[EROR] response (cid=0, code=500)
</code></pre></div></div>

<p><strong>Fixed image:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP 200
[INFO] request (cid=0, verb="POST", path="/inbound/interactivity")
[WARN] add assignee (cid=0, ..., result="no such alert")
[EROR] platform error (cid=0, error="HTTP 404 Not Found")
[INFO] response (cid=0, code=200)
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">HTTP 404 Not Found</code> is the <em>progress</em> signal: TLS handshake succeeded, DNS resolved, the platform’s edge returned the expected error for the fake URL. The transport layer works.</p>

<h2 id="lessons">Lessons</h2>

<p>There’s one bug here and two takeaways.</p>

<p><strong>1. Slim base images strip OS data files that runtime libraries read.</strong></p>

<p><code class="language-plaintext highlighter-rouge">netbase</code> (provides <code class="language-plaintext highlighter-rouge">/etc/services</code>, <code class="language-plaintext highlighter-rouge">/etc/protocols</code>, <code class="language-plaintext highlighter-rouge">/etc/rpc</code>), <code class="language-plaintext highlighter-rouge">tzdata</code> (<code class="language-plaintext highlighter-rouge">/usr/share/zoneinfo/*</code>), <code class="language-plaintext highlighter-rouge">iana-etc</code> on Alpine — none of these are in the minimal images, and lots of libraries quietly need them. The breakage looks like a library bug because the error originates inside the library; the root cause is the missing data file the library expected.</p>

<p>A small audit list before deploying any slim/distroless image with HTTP/TLS/timezone/protocol-name behaviour:</p>

<ul>
  <li>Outbound HTTPS or HTTP with named ports → <code class="language-plaintext highlighter-rouge">/etc/services</code> → <code class="language-plaintext highlighter-rouge">netbase</code></li>
  <li>Timezone-aware logging or scheduling → <code class="language-plaintext highlighter-rouge">/usr/share/zoneinfo</code> → <code class="language-plaintext highlighter-rouge">tzdata</code></li>
  <li>DNS via name resolution → <code class="language-plaintext highlighter-rouge">/etc/nsswitch.conf</code> may be needed → <code class="language-plaintext highlighter-rouge">libc-bin</code></li>
  <li>TLS verification → CA bundle → <code class="language-plaintext highlighter-rouge">ca-certificates</code></li>
</ul>

<p>Each of these is one Debian/Alpine package. Each is one line in the Dockerfile.</p>

<p><strong>2. “I checked” is not the same as “I checked correctly.”</strong></p>

<p>My first check — <code class="language-plaintext highlighter-rouge">strings | grep</code> for TLS symbols — was the right idea executed wrong. The runtime container didn’t have <code class="language-plaintext highlighter-rouge">strings</code>, so an empty output meant “couldn’t run the check,” not “no symbols found.” I read the result as proof and shipped a wrong diagnosis on the back of it.</p>

<p>What I should have done before claiming the binary lacked TLS: extract the binary out of the container to a host that has <code class="language-plaintext highlighter-rouge">strings</code>, or use <code class="language-plaintext highlighter-rouge">apk add binutils</code> / <code class="language-plaintext highlighter-rouge">apt install binutils</code> inside, or use a different tool entirely (<code class="language-plaintext highlighter-rouge">nm</code>, <code class="language-plaintext highlighter-rouge">readelf</code>). When a verification check returns the answer you expected, that’s exactly when to double-check that the check actually ran.</p>

<p>The reviewer’s “but the project already lists <code class="language-plaintext highlighter-rouge">tls-lwt</code>” was the cheap nudge that re-opened the diagnosis. Without it I would have shipped the wrong PR. Cheap nudges from reviewers who are reading the same code are worth more than confidence in your own reasoning.</p>

<h2 id="tldr">TL;DR</h2>

<p>If you ship an OCaml binary using <code class="language-plaintext highlighter-rouge">cohttp-lwt-unix</code> or anything else that reaches for <code class="language-plaintext highlighter-rouge">Lwt_unix.getservbyname</code> and put it on <code class="language-plaintext highlighter-rouge">debian:bookworm-slim</code>, your outbound calls will die with <code class="language-plaintext highlighter-rouge">resolution failed: unknown scheme</code>. Add <code class="language-plaintext highlighter-rouge">netbase</code> to the runtime stage. One line.</p>

<p>And if you’re staring at a binary trying to prove a negative (“symbol X isn’t here”), make sure the tool you’re using to look exists in the same image you’re looking inside.</p>]]></content><author><name>cm</name></author><category term="ocaml" /><category term="cohttp" /><category term="conduit" /><category term="tls" /><category term="docker" /><category term="debian-slim" /><category term="netbase" /><category term="https" /><category term="debugging" /><category term="kubernetes" /><category term="incident" /><summary type="html"><![CDATA[I shipped a small OCaml HTTP service to a Kubernetes cluster. Inbound traffic worked. Slash commands (POST in, JSON out) worked. But every time the service had to call an https:// endpoint, it died with: [EROR] failure (cid=N, error="resolution failed: unknown scheme") [EROR] response (cid=N, code=500) The path from that error to a one-line fix involved being publicly wrong about the cause first. I’ll use generic names: my-service (the OCaml HTTP server), my-service:latest (its image), https://hooks.example.com/... (an outbound URL it tries to POST to). Stack: OCaml 5.2 + cohttp-lwt-unix 6.2 + conduit-lwt-unix 8.0 + a multi-stage Dockerfile that copies the built binary onto debian:bookworm-slim. Symptoms The service has a /metrics Prometheus endpoint. After the first outbound failure, its counters looked like this: my_service_http_requests_total{method="POST",path="/inbound/slash",code="200"} 14 my_service_http_requests_total{method="POST",path="/inbound/interactivity",code="500"} 1 Server-side, every successful slash-command POST handled itself — read, decode, build JSON response, write — without ever touching the network. The 500 was the first request that needed to make an outbound HTTPS call (post a webhook back to the platform’s response_url). The bot logged the URL it was about to call: [DBUG] send HTTP request (url="https://slack.com/api/chat.postMessage", verb="POST") [EROR] failure (cid=N, error="resolution failed: unknown scheme") The URL string is plainly well-formed: https://, host, path. So why “unknown scheme”? Wrong diagnosis #1: “TLS isn’t linked” My first hypothesis was that the binary lacks TLS support. Plausible: cohttp-lwt-unix alone is just an HTTP-over-TCP library; for https:// you need a TLS implementation (tls-lwt for pure OCaml, or lwt_ssl for OpenSSL). If you forget to depend on one, Conduit_lwt_unix has no resolver registered for the https scheme. I checked the published binary for TLS symbols by running: docker run --rm --entrypoint /bin/sh my-service:latest \ -c 'strings /usr/local/bin/my-service | grep -iE "tls|x509|ssl|cert"' The output was empty. I took that as proof that no TLS library was linked, opened a PR adding tls-lwt and ca-certs to the dune (libraries …) stanza, and wrote a confident description that traced the symptom to missing TLS linkage. The check was wrong. The runtime image is debian:bookworm-slim, which doesn’t ship strings — so the command silently produced no matches because binutils wasn’t installed, not because the symbols were absent. I’d run the check inside the wrong container. The reviewer’s pushback A reviewer caught it: The PR description claims tls-lwt wasn’t being linked into the executable, but the project already lists tls-lwt in the (executables …) stanza. If outbound HTTPS is still failing with unknown scheme, it likely means the specific Conduit HTTPS resolver/initializer module isn’t getting linked or initialized. I re-checked, this time extracting the binary onto the host (docker cp out of a container created with docker create, then running strings on the host where it actually exists): $ cid=$(docker create my-service:latest) $ docker cp "$cid":/usr/local/bin/my-service /tmp/my-service-bin $ docker rm $cid $ strings /tmp/my-service-bin | grep -cE "^(Tls|X509|Mirage_crypto|Tls_lwt|X509_lwt)(__|$)" 1871 $ strings /tmp/my-service-bin | grep -iE "^Tls_lwt|^X509|^Mirage_crypto_rng_unix" | sort -u | head Mirage_crypto_rng_unix Tls Tls_lwt Tls__Core Tls__State Tls__Utils X509 X509__ X509__Crl X509__P12 Plenty of TLS code. So the binary was fine. Conduit_lwt_unix 8.0 also already lists tls-lwt and ca-certs in its META requires, so they get pulled in transitively from cohttp-lwt-unix. The first diagnosis was wrong; the binary has had HTTPS support all along. Which meant my open PR was wrong too, and I had to figure out the actual cause. Reading Conduit’s source The error string “resolution failed: unknown scheme” isn’t in my code or my dependencies’ *.ml files outside conduit. The path through conduit looks like this: conduit-8.0.0/lib/conduit/resolver.ml: let resolve_uri ?rewrites ~uri t = match Uri.scheme uri with | None -&gt; return (`Unknown "no scheme") | Some scheme -&gt; ( t.service scheme &gt;&gt;= function | None -&gt; return (`Unknown "unknown scheme") ... So unknown scheme fires when t.service "https" returns None. The default t.service is system_service, defined in conduit-lwt-unix-8.0.0/lib/resolver_lwt_unix.ml: let is_tls_service = function | "https" | "imaps" -&gt; true | _ -&gt; false let system_service name = Lwt.catch (fun () -&gt; Lwt_unix.getservbyname name "tcp" &gt;&gt;= fun s -&gt; let tls = is_tls_service name in let svc = { Resolver.name; port = s.Lwt_unix.s_port; tls } in Lwt.return (Some svc)) (function Not_found -&gt; Lwt.return_none | e -&gt; Lwt.reraise e) Lwt_unix.getservbyname "https" "tcp". That’s the libc getservbyname(3) syscall, which reads /etc/services: $ man 5 services DESCRIPTION services is a plain ASCII file providing a mapping between human-friendly textual names for internet services, and their underlying assigned port numbers... If the file is absent, getservbyname returns NULL, OCaml raises Not_found, system_service returns None, conduit returns Unknown "unknown scheme". Independently of TLS. Independently of the URI scheme too, if the OS lookup for that scheme fails. Confirming the actual cause Check the file in the running container: $ docker run --rm --entrypoint /bin/sh my-service:latest -c 'ls -la /etc/services' ls: cannot access '/etc/services': No such file or directory And on a stock debian:bookworm-slim: $ docker run --rm --entrypoint /bin/sh debian:bookworm-slim -c 'ls -la /etc/services' ls: cannot access '/etc/services': No such file or directory debian:bookworm-slim doesn’t ship /etc/services. The Debian package that provides it is netbase, which is not in the slim base. Same story with iana-etc on Alpine, and most distroless variants. The fix One line in the runtime stage of the Dockerfile: RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \ libgmp10 \ libssl3 \ zlib1g \ ca-certificates \ + netbase \ &amp;&amp; rm -rf /var/lib/apt/lists/* That’s it. No dune changes, no opam changes, no code changes. The Dockerfile had been omitting one OS-level data file the OCaml runtime depends on, and cohttp-lwt-unix translated the absence into a misleading-looking library-level error. Verifying it actually works I went back and proved the fix end-to-end with a controlled reproducer. Built both the broken and fixed images, ran each one, and sent a properly HMAC-signed POST to the interactivity endpoint: import hmac, hashlib, time, urllib.parse, urllib.request, json secret = b"signing-secret" payload = { "type": "block_actions", "user": {"id": "U1", "team_id": "T", "username": "test", "name": "test"}, "team": {"id": "T", "domain": "d"}, "channel": {"id": "C", "name": "c"}, "container": {"type":"message","message_ts":"1","channel_id":"C","is_ephemeral":True}, "trigger_id": "T", "is_enterprise_install": False, "response_url": "https://hooks.example.com/actions/T/1/x", "actions": [{"type":"users_select","action_id":"a","block_id":"b", "selected_user":"U2","action_ts":"1"}], } body = "payload=" + urllib.parse.quote(json.dumps(payload), safe="") ts = str(int(time.time())) sig = "v0=" + hmac.new(secret, f"v0:{ts}:{body}".encode(), hashlib.sha256).hexdigest() req = urllib.request.Request( "http://localhost:8080/inbound/interactivity", data=body.encode(), headers={"Content-Type": "application/x-www-form-urlencoded", "X-Slack-Signature": sig, "X-Slack-Request-Timestamp": ts}, method="POST", ) try: print("HTTP", urllib.request.urlopen(req, timeout=10).status) except urllib.error.HTTPError as e: print("HTTP", e.code) The fake response_url (https://hooks.example.com/actions/T/1/x) doesn’t exist on the platform, so the platform’s edge will return 404 — but that’s a success indicator for the experiment. If the outbound HTTPS call goes through at the TLS layer, we’ll see a 4xx body coming back from the platform. If it fails at conduit’s resolver, we’ll see the original unknown scheme and HTTP 500. Broken image: HTTP 500 [INFO] request (cid=0, verb="POST", path="/inbound/interactivity") [WARN] add assignee (cid=0, ..., result="no such alert") [EROR] failure (cid=0, error="resolution failed: unknown scheme") [EROR] response (cid=0, code=500) Fixed image: HTTP 200 [INFO] request (cid=0, verb="POST", path="/inbound/interactivity") [WARN] add assignee (cid=0, ..., result="no such alert") [EROR] platform error (cid=0, error="HTTP 404 Not Found") [INFO] response (cid=0, code=200) The HTTP 404 Not Found is the progress signal: TLS handshake succeeded, DNS resolved, the platform’s edge returned the expected error for the fake URL. The transport layer works. Lessons There’s one bug here and two takeaways. 1. Slim base images strip OS data files that runtime libraries read. netbase (provides /etc/services, /etc/protocols, /etc/rpc), tzdata (/usr/share/zoneinfo/*), iana-etc on Alpine — none of these are in the minimal images, and lots of libraries quietly need them. The breakage looks like a library bug because the error originates inside the library; the root cause is the missing data file the library expected. A small audit list before deploying any slim/distroless image with HTTP/TLS/timezone/protocol-name behaviour: Outbound HTTPS or HTTP with named ports → /etc/services → netbase Timezone-aware logging or scheduling → /usr/share/zoneinfo → tzdata DNS via name resolution → /etc/nsswitch.conf may be needed → libc-bin TLS verification → CA bundle → ca-certificates Each of these is one Debian/Alpine package. Each is one line in the Dockerfile. 2. “I checked” is not the same as “I checked correctly.” My first check — strings | grep for TLS symbols — was the right idea executed wrong. The runtime container didn’t have strings, so an empty output meant “couldn’t run the check,” not “no symbols found.” I read the result as proof and shipped a wrong diagnosis on the back of it. What I should have done before claiming the binary lacked TLS: extract the binary out of the container to a host that has strings, or use apk add binutils / apt install binutils inside, or use a different tool entirely (nm, readelf). When a verification check returns the answer you expected, that’s exactly when to double-check that the check actually ran. The reviewer’s “but the project already lists tls-lwt” was the cheap nudge that re-opened the diagnosis. Without it I would have shipped the wrong PR. Cheap nudges from reviewers who are reading the same code are worth more than confidence in your own reasoning. TL;DR If you ship an OCaml binary using cohttp-lwt-unix or anything else that reaches for Lwt_unix.getservbyname and put it on debian:bookworm-slim, your outbound calls will die with resolution failed: unknown scheme. Add netbase to the runtime stage. One line. And if you’re staring at a binary trying to prove a negative (“symbol X isn’t here”), make sure the tool you’re using to look exists in the same image you’re looking inside.]]></summary></entry></feed>