Files
exploded-cluster/pilot/mirror.tpl.html
T
jwright 7f7035b98f mirror rev 4: evidence-verified rewrite, six-diagram artefact set, front-end and build-gate hardening
Content corrected against upstream docs, deployed-version source and the live
config: retention/GC semantics (untagged manifests including digest-only
pull-through entries are GC-eligible; keepUntagged is v2.1.19+, not deployed),
warm-cache guarantees (tag pulls revalidate upstream per open issue; digest
pulls serve locally), retry arithmetic (buildah --retry counts retries: 16 max,
not 12), five-upstream flat-namespace routing stated as observed risk with a
proposed prefix redesign, auth-canary design validated against longest-match
policy precedence with the Docker-client caveat, coalescing reframed from
deadlock to design with the restart as recovery-not-root-cause. Adds fact
panel, contents, failure-mode and cache test matrices, decision table, open
verification register and primary-source references. Front-end: grid full-bleed
(no viewport units - no scrollbar overflow), shell-level skip link, real
1600x872 intrinsic dims injected from bytes, lazy below-fold plates, AA
code-comment contrast, scene engine rewritten (IO-gated, rAF-coalesced, live
reduced-motion, hidden-tab pause, zero guards). Assembler: per-page gates plus
mirror-specific required/banned content assertions. README describes the
multi-page site honestly.
2026-08-25 08:08:18 +10:00

877 lines
68 KiB
HTML

<style>
:root{
--void:#070b14; --void-2:#0a0f1b; --ink:#dfe6f0; --ink-dim:#98a2b4;
--cyan:#3fbaf5; --magenta:#e879f9; --amber:#e8b44a; --line:#1c2434; --card:#0d1424;
--good:#7fe0a7; --bad:#ff8fa3;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--void);color:var(--ink);
font-family:var(--sans);font-size:1.05rem;line-height:1.65}
#bar{position:fixed;top:0;left:0;height:2px;width:0;background:linear-gradient(90deg,var(--cyan),var(--magenta));z-index:9}
/* full-bleed via named grid lines - no viewport units, so the vertical scrollbar
can never make the document wider than the visible viewport */
.layout{display:grid;
grid-template-columns:[full-start] minmax(1.25rem,1fr) [main-start] min(46rem,calc(100% - 2.5rem)) [main-end] minmax(1.25rem,1fr) [full-end]}
.layout>*{grid-column:main}
.layout>.scene{grid-column:full}
header.hero{min-height:70vh;display:flex;flex-direction:column;justify-content:center;padding:4.5rem 0 1.5rem;position:relative}
.eyebrow{font-family:var(--mono);font-size:.78rem;letter-spacing:.22em;color:var(--cyan);text-transform:uppercase}
h1{font-family:var(--sans);font-weight:750;font-size:clamp(2.8rem,8vw,4.6rem);
line-height:.98;margin:.5rem 0 1rem;text-wrap:balance;letter-spacing:-.01em}
.outcome{max-width:36rem;color:var(--ink-dim);font-size:1.12rem}
.outcome b{color:var(--ink);font-weight:600}
.backlink{font-family:var(--mono);font-size:.75rem;letter-spacing:.1em;margin-bottom:2rem}
.backlink a{color:var(--cyan);text-decoration:none}
.backlink a:hover{text-decoration:underline}
h2{font-family:var(--sans);font-weight:700;font-size:1.85rem;letter-spacing:0;
margin:4.2rem 0 1rem;text-wrap:balance;scroll-margin-top:2.5rem}
h2 .n{color:var(--cyan);font-family:var(--mono);font-weight:500;font-size:1rem;vertical-align:.35em;margin-right:.65rem;letter-spacing:.1em}
h3{font-weight:650;font-size:1.28rem;margin:2.2rem 0 .6rem;scroll-margin-top:2.5rem}
p{margin:.9rem 0}
code,.mono{font-family:var(--mono);font-size:.92em}
.scene{margin:3rem 0;overflow:hidden;position:relative}
.scene .inner{max-width:72rem;margin:0 auto;padding:0 1rem;position:relative}
.scene img{width:100%;height:auto;display:block;border-radius:.4rem}
.scene .cap{font-family:var(--mono);font-size:.74rem;color:var(--ink-dim);
letter-spacing:.1em;text-align:center;margin-top:.7rem;padding:0 1rem}
.sayit{border-left:3px solid var(--magenta);margin:2rem 0;padding:.4rem 0 .4rem 1.1rem;
font-size:1.26rem;font-weight:650;line-height:1.35;color:#fff}
.sayit .k{display:block;font-family:var(--mono);font-size:.7rem;letter-spacing:.2em;color:var(--magenta);margin-bottom:.3rem;font-weight:500}
.note{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--cyan);
border-radius:.45rem;padding:1rem 1.2rem;margin:1.6rem 0;font-size:.98rem}
.note .k{font-family:var(--mono);font-size:.72rem;letter-spacing:.18em;color:var(--cyan)}
.note.warn{border-left-color:var(--amber)}
.note.warn .k{color:var(--amber)}
.clause{background:linear-gradient(135deg,rgba(232,121,249,.07),transparent 60%);
border:1px solid var(--line);border-radius:.45rem;padding:1rem 1.2rem;margin:1.6rem 0;font-size:.98rem}
.clause .k{font-family:var(--mono);font-size:.72rem;letter-spacing:.18em;color:var(--magenta)}
pre{background:#050810;border:1px solid var(--line);border-radius:.5rem;padding:1rem 1.1rem;
overflow-x:auto;font-family:var(--mono);font-size:.83rem;line-height:1.55;color:#c8d2e0}
pre.pane{border-left:3px solid var(--cyan)}
pre .cm{color:#8a95ab}
pre .good{color:var(--good)}
pre .bad{color:var(--bad)}
.pane-k{font-family:var(--mono);font-size:.72rem;letter-spacing:.18em;color:var(--ink-dim);
text-transform:uppercase;margin:1.6rem 0 .4rem}
.diagram{background:var(--card);border:1px solid var(--line);border-radius:.55rem;
padding:1.2rem;margin:2rem 0;overflow-x:auto}
.diagram .k{font-family:var(--mono);font-size:.72rem;letter-spacing:.18em;color:var(--cyan);display:block;margin-bottom:.8rem}
.diagram svg{width:100%;height:auto;display:block;min-width:560px}
.diagram .why{font-size:.9rem;color:var(--ink-dim);margin:.8rem 0 0}
.diagram .legend{font-family:var(--mono);font-size:.72rem;color:var(--ink-dim);margin:.6rem 0 0}
table{border-collapse:collapse;width:100%;font-size:.88rem;margin:1.4rem 0}
.tablewrap{overflow-x:auto;margin:1.4rem 0}
.tablewrap table{margin:0;min-width:640px}
th,td{border:1px solid var(--line);padding:.5rem .65rem;text-align:left;vertical-align:top}
th{font-family:var(--mono);font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;color:var(--cyan);background:var(--void-2)}
td .st{font-family:var(--mono);font-size:.7rem;letter-spacing:.06em;padding:.05rem .4rem;border-radius:.25rem;border:1px solid var(--line);white-space:nowrap}
.st.ok{color:var(--good);border-color:rgba(127,224,167,.4)}
.st.open{color:var(--amber);border-color:rgba(232,180,74,.4)}
.st.prop{color:var(--magenta);border-color:rgba(232,121,249,.4)}
.facts{border:1px solid var(--line);border-radius:.55rem;background:var(--card);
padding:1.1rem 1.3rem;margin:2.2rem 0;font-size:.92rem}
.facts dl{display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1.2rem;margin:0}
.facts dt{font-family:var(--mono);font-size:.72rem;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-dim);padding-top:.15rem}
.facts dd{margin:0}
.toc{border:1px solid var(--line);border-radius:.55rem;padding:1rem 1.3rem;margin:2rem 0;background:var(--void-2)}
.toc .k{font-family:var(--mono);font-size:.72rem;letter-spacing:.18em;color:var(--cyan);text-transform:uppercase}
.toc ol{margin:.6rem 0 0;padding-left:1.4rem;columns:2;column-gap:2.5rem;font-size:.92rem}
.toc li{margin:.3rem 0;break-inside:avoid}
.toc a{color:var(--ink);text-decoration:none}
.toc a:hover{color:var(--cyan)}
@media (max-width:640px){.toc ol{columns:1}}
ol{padding-left:1.3rem} ol li{margin:.6rem 0}
ul{padding-left:1.2rem} ul li{margin:.5rem 0}
em{color:#fff}
.traps p{margin:1.1rem 0}
.traps b,strong{color:#fff}
footer{margin:5rem 0 4rem;border-top:1px solid var(--line);padding-top:1.4rem;
color:var(--ink-dim);font-size:.88rem;font-family:var(--mono)}
a{color:var(--cyan)}
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
.s21-ring{position:absolute;inset:0;pointer-events:none;mix-blend-mode:screen;opacity:.5;
background:conic-gradient(from 0deg at 50% 50%, transparent 0deg, rgba(63,186,245,.28) 24deg, transparent 60deg)}
.s22-glow{position:absolute;width:24%;aspect-ratio:1;border-radius:50%;pointer-events:none;
mix-blend-mode:screen;opacity:0;background:radial-gradient(circle,rgba(63,186,245,.5),transparent 65%)}
.s23-pulse{position:absolute;left:34%;top:44%;width:26%;aspect-ratio:1;border-radius:50%;pointer-events:none;
mix-blend-mode:screen;opacity:.25;background:radial-gradient(circle,rgba(232,121,249,.55),transparent 62%)}
.refs li{margin:.45rem 0;font-size:.92rem}
@media (prefers-reduced-motion: reduce){
html{scroll-behavior:auto}
.s21-ring,.s23-pulse{display:none}
.s22-glow{opacity:.35}
}
</style>
<div id="bar"></div>
<div class="layout">
<header class="hero">
<p class="backlink"><a href="/">&larr; The Teaching Lab</a></p>
<p class="eyebrow">Dossier / 005 &middot; operations</p>
<h1>The Mirror</h1>
<p class="outcome"><b>Outcome</b> &mdash; a pull-through OCI registry taken apart: why this
lab runs its own mirror, what the deployed version actually guarantees, the retention trap
that quietly evicts digest-pinned content, and how each lesson translates into production
platform engineering. Claims are labelled: observed here, documented upstream, or not yet
verified.</p>
</header>
<div class="facts">
<dl>
<dt>Registry</dt><dd>zot v2.1.17 (digest-pinned image; latest upstream release at last check: v2.1.20)</dd>
<dt>Platform</dt><dd>Talos Linux v1.13.4, containerd 2.2.4, single-node Kubernetes</dd>
<dt>Upstreams</dt><dd>5 (Docker Hub, GHCR, Quay, registry.k8s.io, NGC), all on-demand pull-through</dd>
<dt>Storage</dt><dd>50Gi local PVC; dedupe on, GC on (1h delay / 24h interval); no retention config</dd>
<dt>Auth state</dt><dd>anonymous read enabled; per-node pull credentials staged, not yet enforced</dd>
<dt>Fallback</dt><dd>origin fallback ON (platform default; deliberately not disabled)</dd>
<dt>Validated</dt><dd>2026-08-25 (live config read + upstream doc/source verification)</dd>
<dt>Open items</dt><dd>digest-entry retention (needs v2.1.19+), auth canary negative test, upstream namespace prefixes, disconnected-serve drill</dd>
</dl>
</div>
<nav class="toc" aria-labelledby="toc-h">
<p class="k" id="toc-h">Contents</p>
<ol>
<li><a href="#incident">The build that "hung"</a></li>
<li><a href="#anatomy">What the mirror actually does</a></li>
<li><a href="#routing">Request routing and five upstreams</a></li>
<li><a href="#bootstrap">Bootstrap, fallback and failure modes</a></li>
<li><a href="#digests">Digests, manifests and supply chain</a></li>
<li><a href="#retention">Retention, GC and the digest-only trap</a></li>
<li><a href="#auth">The authentication migration</a></li>
<li><a href="#observability">Observability that means something</a></li>
<li><a href="#production">The production translation</a></li>
<li><a href="#verified">Verified, open and references</a></li>
</ol>
</nav>
<h2 id="incident"><span class="n" aria-hidden="true">01</span>The build that "hung"</h2>
<div class="scene" id="s20"><div class="inner">
<img src="__M20__" alt="An armoured way-station machine exploded into parts: hexagonal hull, roof plate, a shelf of glowing bricks, intake pods and an output nozzle - the mirror as a physical machine.">
<div class="cap">The mirror as a machine: upstream intakes, one cache shelf, one serving nozzle.</div>
</div></div>
<p>Nobody sets out to run a registry. You inherit the need the day a CI build stalls for no
reason you can see.</p>
<p>This site's own build pulls one base image from Docker Hub, anonymously. One evening the
build sat there - no visible error, no progress. The accurate version of that story: Docker
Hub does not go silent when it limits you. It answers with an explicit HTTP 429 and the error
code <code>toomanyrequests</code>; under the current published limits, anonymous pulls get
100 manifest requests per six hours per IPv4 address, and the accounting is per manifest
<code>GET</code>. The <em>silence</em> was manufactured on my side, by retries swallowing
that answer.</p>
<p>The swallower, recovered from the build script's git history, was a double retry stack: an
outer shell wrapper allowing four attempts, wrapped around <code>buildah build --retry 3</code>.
Buildah's flag counts <em>retries</em>, not attempts - one initial try plus up to three
retries, and it applies to registry push/pull operations. Four outer attempts, each
containing up to four inner attempts: <strong>up to sixteen requests</strong> for one
consistently failing pull, each one billed against the same rate limit it was trying to
outlast. Retry amplification turns a throttle into an outage and hides the evidence.</p>
<div class="pane-k">Receipt &middot; the retry stack, as committed to the repo</div>
<pre class="pane">retry() {
local n=0 max=4
until "$@"; do
n=$((n+1)); [ "$n" -ge "$max" ] &amp;&amp; { echo "&gt;&gt; failed after ${max} attempts" &gt;&amp;2; return 1; }
echo "&gt;&gt; attempt ${n} failed, retrying in $((n*8))s..." &gt;&amp;2; sleep $((n*8))
done
}
retry buildah build --retry 3 --retry-delay 5s ...
<span class="cm"># --retry 3 = one attempt + three retries; the wrapper multiplies that by four.</span>
<span class="cm"># A 429 answered every one of them, and the backoffs read as a hang.</span></pre>
<p>The immediate fix was to pin the base image by digest - which stops tag re-resolution, but
does not remove the network round-trip: an ephemeral runner with a cold local store still
fetches the manifest behind that digest every build. The durable answer is structural:</p>
<div class="sayit"><span class="k">SAY IT</span>Why does a fleet of machines ask the public
internet for the same bytes, hundreds of times, forever?</div>
<p>A production platform answers with a mirror: one registry that fetches once and serves the
estate. The lab built one. This page takes it apart - including the parts that turned out
not to work the way I first believed.</p>
<div class="note"><span class="k">FIELD NOTE</span><br>The mirror did not fix the red X in
CI. That red was a separate upstream bug in the CI system's log-finalise step - cosmetic,
tolerated, documented. Two problems, one symptom. Diagnose them separately or you will fix
the wrong one and declare victory.</div>
<h2 id="anatomy"><span class="n" aria-hidden="true">02</span>What the mirror actually does</h2>
<p>The mirror is zot: a single OCI registry in its own namespace, one 50Gi cache volume,
running the same digest-pinned deployment discipline as everything it serves. It fronts five
public registries in <strong>on-demand pull-through</strong> mode: a miss fetches from
upstream and caches; a hit serves from the shelf. zot also supports polled mirroring
(periodic full sync of matching content) and pre-seeding; Docker Hub is on-demand only -
upstream documentation is explicit that polled mirroring should not be pointed at Hub.</p>
<p>The storage block, from the live config:</p>
<pre>"storage": {
"rootDirectory": "/var/lib/registry",
"commit": true,
"dedupe": true,
"gc": true,
"gcDelay": "1h",
"gcInterval": "24h"
}</pre>
<p><code>commit</code> fsyncs writes before acknowledging - crash safety. <code>dedupe</code>
hard-links identical blobs, which pays for itself when five upstreams ship the same base
layers under different names. And <code>gc</code> with its two timers looks innocuous here;
section 06 is about why it is the most consequential block on this page.</p>
<div class="diagram"><span class="k">Diagram A &middot; system context</span>
<svg viewBox="0 0 860 400" role="img" aria-labelledby="da-t da-d">
<title id="da-t">System context: consumers, the mirror, its storage, five upstreams, and the trust boundaries between them</title>
<desc id="da-d">CI and Kubernetes nodes pull from the zot mirror over the LAN. The mirror stores content on a 50Gi volume and syncs on demand from five upstream registries across the internet boundary. Anonymous read is currently allowed inbound; outbound upstream credentials live in one mounted secret. The origin-fallback path from nodes directly to upstreams is shown dashed.</desc>
<defs><marker id="da-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#3fbaf5"/></marker>
<marker id="da-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#e879f9"/></marker></defs>
<g font-family="ui-monospace,Menlo,monospace" font-size="12" text-anchor="middle">
<rect x="12" y="60" width="150" height="52" rx="8" fill="none" stroke="#3fbaf5"/><text x="87" y="82" fill="#dfe6f0">CI (buildah)</text><text x="87" y="99" fill="#98a2b4">bastion host</text>
<rect x="12" y="150" width="150" height="52" rx="8" fill="none" stroke="#3fbaf5"/><text x="87" y="172" fill="#dfe6f0">k8s nodes</text><text x="87" y="189" fill="#98a2b4">containerd + local store</text>
<rect x="300" y="95" width="180" height="72" rx="8" fill="none" stroke="#dfe6f0"/><text x="390" y="122" fill="#dfe6f0">zot v2.1.17</text><text x="390" y="139" fill="#98a2b4">anon read (today)</text><text x="390" y="156" fill="#98a2b4">sync: onDemand x5</text>
<rect x="322" y="205" width="136" height="46" rx="8" fill="none" stroke="#98a2b4"/><text x="390" y="224" fill="#dfe6f0">50Gi PVC</text><text x="390" y="241" fill="#98a2b4">dedupe + GC 1h/24h</text>
<line x1="390" y1="167" x2="390" y2="200" stroke="#98a2b4"/>
<rect x="660" y="14" width="186" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="753" y="39" fill="#dfe6f0">docker.io</text>
<rect x="660" y="82" width="186" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="753" y="107" fill="#dfe6f0">ghcr.io</text>
<rect x="660" y="150" width="186" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="753" y="175" fill="#dfe6f0">quay.io</text>
<rect x="660" y="218" width="186" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="753" y="243" fill="#dfe6f0">registry.k8s.io</text>
<rect x="660" y="286" width="186" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="753" y="311" fill="#dfe6f0">nvcr.io</text>
<line x1="162" y1="86" x2="295" y2="115" stroke="#3fbaf5" marker-end="url(#da-a)"/>
<line x1="162" y1="176" x2="295" y2="145" stroke="#3fbaf5" marker-end="url(#da-a)"/>
<line x1="480" y1="110" x2="655" y2="40" stroke="#3fbaf5" stroke-dasharray="5 4" marker-end="url(#da-a)"/>
<line x1="480" y1="120" x2="655" y2="100" stroke="#3fbaf5" stroke-dasharray="5 4" marker-end="url(#da-a)"/>
<line x1="480" y1="131" x2="655" y2="168" stroke="#3fbaf5" stroke-dasharray="5 4" marker-end="url(#da-a)"/>
<line x1="480" y1="142" x2="655" y2="235" stroke="#3fbaf5" stroke-dasharray="5 4" marker-end="url(#da-a)"/>
<line x1="480" y1="153" x2="655" y2="300" stroke="#3fbaf5" stroke-dasharray="5 4" marker-end="url(#da-a)"/>
<path d="M162 200 Q 390 330 660 315" fill="none" stroke="#e879f9" stroke-dasharray="2 6" marker-end="url(#da-m)"/>
<text x="360" y="322" fill="#e879f9">origin fallback (dashed = only when the mirror cannot serve)</text>
<line x1="580" y1="8" x2="580" y2="345" stroke="#1c2434"/>
<text x="580" y="368" fill="#98a2b4">internet boundary - outbound creds in ONE mounted secret</text>
<text x="230" y="45" fill="#98a2b4">LAN - anonymous read today; per-node creds staged</text>
</g>
</svg>
<p class="legend">solid = normal pull path &middot; dashed cyan = on-demand sync on miss &middot; dashed magenta = origin fallback</p>
<p class="why">The decision this explains: one cache serves two consumer classes, and the
two credential domains (nodes-to-mirror, mirror-to-upstreams) never mix.</p></div>
<p>Consumers reach the mirror through the platform's machine-level registry config:</p>
<pre>machine:
registries:
mirrors:
docker.io:
endpoints:
- https://zot.bztmon.org
ghcr.io:
endpoints:
- https://zot.bztmon.org</pre>
<p>The mirror is the only listed endpoint, and that is still not a hard dependency, because
Talos documents an implicit final fallback: endpoints are tried in order, "and by default
the last implicit endpoint is the original upstream registry", unless
<code>skipFallback: true</code> says otherwise. The lab's first version listed the upstream
as an explicit second endpoint; the refinement that removed it came from reading the
behaviour properly - the default already guaranteed it. Know which of your safety nets you
built, and which are defaults you merely have not broken.</p>
<p>One asymmetry decides the whole rollout order in section 07: registry <em>auth</em> in
the machine config is documented by Talos as requiring a reboot before the CRI picks it up.
Mirror <em>endpoint</em> changes applied live in this lab (observed on Talos v1.13.4 /
containerd 2.2.4; the docs are silent on this half, so treat it as an observation, not a
guarantee). Credentials the registry consumes from a mounted secret rotate with a pod
restart. Put each credential on the side that can move.</p>
<div class="clause"><span class="k">THE HOMELAB CLAUSE</span><br>One pod, one PVC, one node
is an accepted failure domain, not a pattern. Section 09 covers what changes when the
requirements change - and what genuinely does not.</div>
<h2 id="routing"><span class="n" aria-hidden="true">03</span>Request routing and five upstreams</h2>
<p>Five registries feed one endpoint, which raises a question the first version of this page
skated past: when a node asks the mirror for <code>pause:3.10</code>, how does the mirror
know whether that means Docker Hub, GHCR, Quay, registry.k8s.io or NGC?</p>
<p>Three facts, all verified:</p>
<ol>
<li><strong>containerd tells the mirror where the request came from</strong> - a mirror
request carries the original registry as a query parameter:
<code>/v2/pause/manifests/3.10?ns=registry.k8s.io</code>. That is documented containerd
behaviour.</li>
<li><strong>zot v2.1.17 ignores it.</strong> There is no handling of the <code>ns</code>
parameter in the deployed version's request path, and using it for upstream selection is an
open upstream feature request (zot issue 4187) with, at last check, no maintainer response.
The URL path alone selects the local repository.</li>
<li><strong>This deployment has no per-upstream prefixes.</strong> The live sync config
gives all five upstreams <code>prefix: "**"</code> and no <code>destination</code> - one
flat namespace. On a miss, zot tries the configured upstreams in order until one has the
path. Upstream documentation's own multi-registry example instead gives each upstream a
distinct <code>destination</code> so the requested path selects the origin.</li>
</ol>
<div class="tablewrap"><table>
<caption class="mono" style="text-align:left;color:var(--ink-dim);font-size:.78rem;padding-bottom:.4rem">Upstream map - observed configuration</caption>
<tr><th>Origin</th><th>Local namespace</th><th>Outbound auth</th><th>Sync mode</th><th>Digest preserved</th><th>On miss</th><th>Status</th></tr>
<tr><td>docker.io</td><td rowspan="5">flat (no prefix - collision-ambiguous by construction)</td><td>Hub login (rate-limit lift)</td><td>onDemand</td><td>yes</td><td rowspan="5">upstreams tried in config order; first that resolves the path wins</td><td><span class="st ok">observed</span></td></tr>
<tr><td>ghcr.io</td><td>none</td><td>onDemand</td><td>yes</td><td><span class="st ok">observed</span></td></tr>
<tr><td>quay.io</td><td>none</td><td>onDemand</td><td>yes</td><td><span class="st ok">observed</span></td></tr>
<tr><td>registry.k8s.io</td><td>none</td><td>onDemand</td><td>yes</td><td><span class="st ok">observed</span></td></tr>
<tr><td>nvcr.io</td><td>$oauthtoken + key</td><td>onDemand</td><td>yes</td><td><span class="st ok">observed</span></td></tr>
</table></div>
<p>Why has the flat namespace not bitten? Because the five origins use largely disjoint path
conventions - Hub's official images live under <code>library/</code> (the implicit prefix
behind bare names like <code>alpine</code>), NGC content sits under <code>nvidia/</code>,
registry.k8s.io has its own layout. "Largely disjoint" is a probability, not a guarantee: an
organisation name that exists on both GHCR and Quay would collide silently, and the winner
would be config order. That is an accepted risk in a lab; it is not a design.</p>
<div class="note warn"><span class="k">PROPOSED - NOT YET DEPLOYED</span><br>The safer shape,
verified against the documentation of both halves but not yet applied here: give each
upstream a distinct <code>destination</code> prefix in zot
(<code>/docker</code>, <code>/ghcr</code>, ...), and point each Talos mirror at the
prefixed path with <code>overridePath: true</code> (which stops the automatic
<code>/v2</code> append so the prefix survives). Then the requested path itself names the
origin, and collisions become impossible rather than improbable. The migration cost: every
cached repository changes its local path, so the cache re-warms.</div>
<div class="diagram"><span class="k">Diagram B &middot; request flow</span>
<svg viewBox="0 0 860 330" role="img" aria-labelledby="db-t db-d">
<title id="db-t">Request flow from a node through containerd to the mirror, with hit, miss, revalidation and fallback branches</title>
<desc id="db-d">A pull begins at the node's local content store. On local miss, containerd asks the mirror. A cached digest request is served from mirror storage without upstream contact. A tag request is revalidated against the upstream even when cached, on the deployed version. A storage miss triggers on-demand sync from the matching upstream. If the mirror cannot serve, containerd falls back to the origin registry.</desc>
<defs><marker id="db-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#3fbaf5"/></marker>
<marker id="db-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#e879f9"/></marker>
<marker id="db-g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#7fe0a7"/></marker></defs>
<g font-family="ui-monospace,Menlo,monospace" font-size="12" text-anchor="middle">
<rect x="10" y="30" width="170" height="46" rx="8" fill="none" stroke="#3fbaf5"/><text x="95" y="49" fill="#dfe6f0">node content store</text><text x="95" y="66" fill="#98a2b4">hit = no network at all</text>
<rect x="10" y="130" width="170" height="40" rx="8" fill="none" stroke="#3fbaf5"/><text x="95" y="155" fill="#dfe6f0">containerd -&gt; mirror</text>
<rect x="300" y="40" width="200" height="44" rx="8" fill="none" stroke="#7fe0a7"/><text x="400" y="58" fill="#dfe6f0">digest request, cached</text><text x="400" y="75" fill="#7fe0a7">served locally (verified)</text>
<rect x="300" y="120" width="200" height="44" rx="8" fill="none" stroke="#e8b44a"/><text x="400" y="138" fill="#dfe6f0">tag request, cached</text><text x="400" y="155" fill="#e8b44a">still revalidates upstream</text>
<rect x="300" y="200" width="200" height="44" rx="8" fill="none" stroke="#3fbaf5"/><text x="400" y="218" fill="#dfe6f0">storage miss</text><text x="400" y="235" fill="#98a2b4">on-demand sync + cache</text>
<rect x="640" y="120" width="200" height="44" rx="8" fill="none" stroke="#98a2b4"/><text x="740" y="138" fill="#dfe6f0">origin registry</text><text x="740" y="155" fill="#98a2b4">rate limits live here</text>
<line x1="95" y1="76" x2="95" y2="125" stroke="#3fbaf5" marker-end="url(#db-a)"/><text x="150" y="105" fill="#98a2b4">local miss</text>
<line x1="180" y1="140" x2="295" y2="66" stroke="#7fe0a7" marker-end="url(#db-g)"/>
<line x1="180" y1="150" x2="295" y2="142" stroke="#e8b44a" marker-end="url(#db-a)"/>
<line x1="180" y1="160" x2="295" y2="218" stroke="#3fbaf5" marker-end="url(#db-a)"/>
<line x1="500" y1="142" x2="635" y2="142" stroke="#e8b44a" stroke-dasharray="5 4" marker-end="url(#db-a)"/>
<line x1="500" y1="222" x2="640" y2="158" stroke="#3fbaf5" stroke-dasharray="5 4" marker-end="url(#db-a)"/>
<path d="M95 170 Q 95 300 400 300 L 660 300 Q 740 300 740 168" fill="none" stroke="#e879f9" stroke-dasharray="2 6" marker-end="url(#db-m)"/>
<text x="400" y="290" fill="#e879f9">containerd fallback: mirror endpoints exhausted -&gt; origin direct</text>
</g>
</svg>
<p class="legend">green = fully local &middot; amber = the surprising branch on v2.1.17 &middot; magenta = fallback</p>
<p class="why">The amber branch is section 04's punchline: a warm cache does not mean the
network is out of the story.</p></div>
<h2 id="bootstrap"><span class="n" aria-hidden="true">04</span>Bootstrap, fallback and failure modes</h2>
<div class="scene" id="s21"><div class="inner">
<img src="__M21__" alt="Six machine parts arranged in a ring around an empty centre; one of them is a quarter-scale replica of the large way-station machine, representing the mirror's own image passing through the mirror.">
<div class="s21-ring" id="ring"></div>
<div class="cap">The dependency ring: one of the orbiting parts is the mirror itself - its own image is served through the thing it is.</div>
</div></div>
<p>Every infrastructure service eventually meets the question: what do you depend on, and
what happens when you ARE the dependency? The mirror has four such loops, and each got a
different answer.</p>
<h3 id="loop1">Loop 1 - the mirror's own image comes through the mirror</h3>
<p>zot runs as a container whose image lives on a registry zot mirrors. When the hosting
node boots, it asks the mirror - which is not running, because the node is booting. The
loop breaks on the documented fallback: mirror endpoints exhaust, containerd falls through
to the origin. The design decision is restraint - <em>not</em> setting
<code>skipFallback: true</code>.</p>
<div class="note"><span class="k">FIELD NOTE &middot; OBSERVED</span><br>Proven by accident:
a config change shipped with <code>preserveDigest</code> enabled but without its mandatory
partner <code>http.compat</code> - a pairing the registry refuses to start without (the
documentation is explicit; so was the crashloop). The mirror went down; the fleet quietly
fell through to upstream and nothing user-visible broke. An unplanned failover exercise,
passed. The standing rule it bought: validate the config with the registry's own
<code>verify</code> in a throwaway pod before merging, every time.</div>
<h3 id="loop2">Loop 2 - the recovery tooling deliberately ignores the mirror</h3>
<p>The fleet's rescue tooling could pull its execution image through the mirror like
everything else. It does not: its image is cached on the operations host, outside the
cluster. A recovery tool that depends on the thing it recovers is not a recovery tool.</p>
<h3 id="loop3">Loop 3 - authentication cannot flip everywhere at once</h3>
<p>Covered properly in section 07; the loop shape: enforcing auth requires every node to
carry credentials that only apply after a reboot, so anonymous read must survive until the
last node is proven, and the proof itself must be designed not to lie.</p>
<h3 id="loop4">Loop 4 - the mirror's host is also the mirror's customer</h3>
<p>The node hosting the mirror boots its own workloads through it - including the tunnel
that serves this very page. "Restart the mirror's node" therefore carries a blast radius
far beyond the mirror, and the runbook for that reboot lists every public-facing thing
riding on it and the order they return. Draw the dependency arrows for your own estate; the
ones that surprise you are the ones that will page you.</p>
<div class="diagram"><span class="k">Diagram D &middot; two bootstrap worlds</span>
<svg viewBox="0 0 860 300" role="img" aria-labelledby="dd-t dd-d">
<title id="dd-t">Connected fail-open bootstrap versus disconnected preseeded bootstrap, as separate paths</title>
<desc id="dd-d">Left: the connected lab path - node boots, mirror miss, implicit fallback to origin, mirror comes up afterwards. Right: the disconnected path - no origin exists; the registry image and release set must be preseeded onto the host or imported from disk before anything else can start.</desc>
<g font-family="ui-monospace,Menlo,monospace" font-size="12" text-anchor="middle">
<text x="215" y="28" fill="#3fbaf5">CONNECTED LAB - FAIL-OPEN (observed)</text>
<rect x="60" y="50" width="310" height="38" rx="8" fill="none" stroke="#3fbaf5"/><text x="215" y="74" fill="#dfe6f0">node boots, asks mirror: down</text>
<rect x="60" y="112" width="310" height="38" rx="8" fill="none" stroke="#e879f9"/><text x="215" y="136" fill="#dfe6f0">implicit fallback -&gt; origin serves boot images</text>
<rect x="60" y="174" width="310" height="38" rx="8" fill="none" stroke="#3fbaf5"/><text x="215" y="198" fill="#dfe6f0">mirror starts (its image came via fallback)</text>
<rect x="60" y="236" width="310" height="38" rx="8" fill="none" stroke="#7fe0a7"/><text x="215" y="260" fill="#dfe6f0">estate converges back onto the mirror</text>
<line x1="215" y1="88" x2="215" y2="108" stroke="#98a2b4"/><line x1="215" y1="150" x2="215" y2="170" stroke="#98a2b4"/><line x1="215" y1="212" x2="215" y2="232" stroke="#98a2b4"/>
<text x="645" y="28" fill="#e8b44a">DISCONNECTED SITE - PRESEEDED (design, not deployed here)</text>
<rect x="490" y="50" width="310" height="38" rx="8" fill="none" stroke="#e8b44a"/><text x="645" y="74" fill="#dfe6f0">no origin exists - fallback is not a plan</text>
<rect x="490" y="112" width="310" height="38" rx="8" fill="none" stroke="#e8b44a"/><text x="645" y="136" fill="#dfe6f0">registry image preseeded / imported from disk</text>
<rect x="490" y="174" width="310" height="38" rx="8" fill="none" stroke="#e8b44a"/><text x="645" y="198" fill="#dfe6f0">release set, trust roots, creds staged locally</text>
<rect x="490" y="236" width="310" height="38" rx="8" fill="none" stroke="#7fe0a7"/><text x="645" y="260" fill="#dfe6f0">site serves itself; syncs on a schedule</text>
<line x1="645" y1="88" x2="645" y2="108" stroke="#98a2b4"/><line x1="645" y1="150" x2="645" y2="170" stroke="#98a2b4"/><line x1="645" y1="212" x2="645" y2="232" stroke="#98a2b4"/>
<line x1="430" y1="40" x2="430" y2="285" stroke="#1c2434"/>
</g>
</svg>
<p class="legend">left = this lab, observed &middot; right = the airgap translation, a design requirement not a deployed claim</p>
<p class="why">Loop 1's trick assumes an upstream exists to fall through to. The airgap
deletes that assumption, and forces Loop 2's discipline onto the mirror itself.</p></div>
<p><strong>Fallback is a policy decision, not a universal win.</strong> This lab keeps it on:
better bootstrap survivability, availability through mirror outages, and the cost is real -
pulls can silently bypass the mirror (losing cache benefit and any future policy point) and
land on origin rate limits. A regulated or disconnected estate makes the opposite call:
<code>skipFallback: true</code>, egress restrictions, preseeded release stock, and a tested
recovery path, because silent bypass is the failure mode there, not the safety net.</p>
<p>And "fails fast" is not one behaviour. The failure modes differ in symptom, path and
consequence:</p>
<div class="tablewrap"><table>
<tr><th>Failure</th><th>User-visible symptom</th><th>Request path</th><th>Fallback?</th><th>Detection</th><th>Security consequence</th></tr>
<tr><td>Mirror pod down</td><td>none (pulls slower)</td><td>node -&gt; origin</td><td>yes, immediate</td><td>mirror probes red; origin egress rises</td><td>policy/audit bypass while down</td></tr>
<tr><td>Mirror up, sync wedged</td><td>pulls hang up to sync timeout</td><td>node -&gt; mirror (blocked)</td><td>only after timeout</td><td>manifest probe stalls while /v2/ still answers</td><td>availability, not integrity</td></tr>
<tr><td>Upstream 429</td><td>miss/tag pulls fail or crawl</td><td>mirror -&gt; origin refused</td><td>fallback hits the same limit</td><td>zot logs; retry storms amplify</td><td>none; self-inflicted DoS via retries</td></tr>
<tr><td>Upstream down, digest cached</td><td>none</td><td>mirror serves locally</td><td>not needed</td><td>-</td><td>none (verified behaviour)</td></tr>
<tr><td>Upstream down, tag cached</td><td>fails/stalls on v2.1.17</td><td>mirror insists on revalidating</td><td>origin also down</td><td>the test matrix in section 08</td><td>availability surprise - the trap of assuming "cached = offline-safe"</td></tr>
<tr><td>PVC full</td><td>new pulls fail, cached OK-ish</td><td>mirror 5xx on writes</td><td>yes for missing content</td><td>capacity metrics (proposed)</td><td>availability; GC pressure</td></tr>
<tr><td>Digest entry GC-evicted</td><td>silent re-fetch on next pull</td><td>mirror -&gt; origin re-sync</td><td>n/a</td><td>upstream egress for "cached" content</td><td>rate-limit exposure returns (section 06)</td></tr>
<tr><td>Node auth wrong (post-flip)</td><td>ImagePullBackOff</td><td>mirror 401; fallback unreliable on 401</td><td>unreliable</td><td>the canary gate (section 07)</td><td>the outage the gate exists to prevent</td></tr>
</table></div>
<h2 id="digests"><span class="n" aria-hidden="true">05</span>Digests, manifests and the supply chain</h2>
<p>Precision matters here, because half the traps on this page come from conflating these
objects:</p>
<ul>
<li>A <strong>tag</strong> is a mutable, human-readable pointer. Zero, one or many tags can
reference one manifest.</li>
<li>A <strong>manifest digest</strong> identifies exact manifest bytes. A multi-platform
<strong>index</strong> has its own digest; each platform manifest has another; layers and
config blobs have their own again.</li>
<li><code>name:tag@digest</code> is client-side grammar, not wire protocol: the OCI
distribution spec takes a tag <em>or</em> a digest in the URL. Clients resolve the digest
and ignore the tag (Kubernetes documents exactly this), so digest-pinned pulls are immune
to tag moves - a fact this page once mislearned as a cache trap and later retested: the
historical zot rejection of combined references did not reproduce on v2.1.17. Traps are
perishable; retest your scars.</li>
<li>Digest pinning provides <strong>content integrity</strong> - not provenance, not
authorisation, not availability, and not freedom from network fetches on a cold store.</li>
</ul>
<p>For a mirror, digests carry one sharp operational rule, learned here as a crashloop:
zot converts Docker-schema manifests to OCI by default, and conversion changes the digest -
silently breaking every digest pin and signature downstream. The pairing that prevents it
(<code>preserveDigest: true</code> per upstream, with <code>http.compat: ["docker2s2"]</code>)
is mandatory in both directions: the registry refuses to start with one and not the other.
This deployment runs both, on all five upstreams - observed in the live config.</p>
<p>Preserving digests does <em>not</em> mean the supply chain travels whole. OCI 1.1
referrers - signatures, SBOMs, attestations attached via the <code>subject</code> field and
served by the referrers API - are separate objects with their own discovery path. A
pull-through cache that mirrors manifests and blobs does not automatically carry them.
Nothing here verifies signatures today; if it did, disconnected verification would also
need the trust material carried locally (a public key, or a trusted-root bundle for a
private signing stack). That is stated as a boundary, not an aspiration.</p>
<div class="clause"><span class="k">THE HOMELAB CLAUSE</span><br>Production estates promote
releases by digest between registries - a tag is a suggestion, a digest is a fact - and
decide explicitly whether referrers travel with them. The lab runs the digest half of that
discipline; the referrer half is future work and labelled as such.</div>
<h2 id="retention"><span class="n" aria-hidden="true">06</span>Retention, GC and the digest-only trap</h2>
<div class="scene" id="s23"><div class="inner">
<img src="__M23__" alt="A stuck intake pod ringed in magenta with three glowing cargo bricks queued behind it - cached content waiting on a jammed process.">
<div class="s23-pulse" id="pulse"></div>
<div class="cap">Cached bricks queued behind a jammed intake: availability problems here are quiet, not loud.</div>
</div></div>
<p>This section corrects the largest error in this page's own first edition. I wrote:
"no retention config means keep everything". That is true for tags, and dangerously false
for everything else - and "everything else" includes precisely the content this mirror
exists to hold.</p>
<p>The verified semantics, from upstream documentation and the deployed version's source:</p>
<ul>
<li>With no retention configuration, <strong>all tags are retained</strong> - and
<strong>all untagged manifests are deleted</strong> by garbage collection (unless referenced
by an index or artifact) once they are older than <code>gcDelay</code>. This deployment's
delay is the 1-hour default, with GC sweeping daily.</li>
<li><strong>Digest-only pull-through entries are stored as untagged manifests.</strong>
That is the exact wording of the upstream fix's problem statement: pull an image through
the mirror by digest, and the cache entry has no tag to protect it.</li>
</ul>
<div class="sayit"><span class="k">SAY IT</span>A digest-pinned fleet, pulling through a
mirror whose GC treats digest-only entries as garbage: the cache evicts exactly what the
estate is built on.</div>
<p>Put the pieces together with section 04's revalidation fact and the deployed behaviour
is this: <em>tag</em> pulls are cached but always phone upstream anyway; <em>digest</em>
pulls are served locally but their cache entries are eligible for eviction within hours.
On v2.1.17, this mirror is a rate-limit shield and a latency win - it is <strong>not yet a
disconnection shelf</strong>, and the first edition of this page was wrong to imply
otherwise.</p>
<div class="diagram"><span class="k">Diagram C &middot; what keeps an object alive</span>
<svg viewBox="0 0 860 360" role="img" aria-labelledby="dc-t dc-d">
<title id="dc-t">The OCI object graph and which references protect content from garbage collection</title>
<desc id="dc-d">A tag points to an index; the index references platform manifests; manifests reference layers and a config blob. Referrers attach to a manifest via a subject field. A separate digest-only manifest sits with no tag pointing at it; it is eligible for garbage collection after the delay on the deployed version. Retention rules that keep tags do not protect the untagged manifest.</desc>
<defs><marker id="dc-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#3fbaf5"/></marker>
<marker id="dc-b" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#98a2b4"/></marker></defs>
<g font-family="ui-monospace,Menlo,monospace" font-size="12" text-anchor="middle">
<rect x="30" y="30" width="150" height="40" rx="8" fill="none" stroke="#7fe0a7"/><text x="105" y="55" fill="#dfe6f0">tag: v1.2</text>
<rect x="240" y="30" width="170" height="40" rx="8" fill="none" stroke="#3fbaf5"/><text x="325" y="49" fill="#dfe6f0">index (multi-arch)</text><text x="325" y="64" fill="#98a2b4">own digest</text>
<rect x="470" y="10" width="170" height="40" rx="8" fill="none" stroke="#3fbaf5"/><text x="555" y="35" fill="#dfe6f0">manifest amd64</text>
<rect x="470" y="62" width="170" height="40" rx="8" fill="none" stroke="#3fbaf5"/><text x="555" y="87" fill="#dfe6f0">manifest arm64</text>
<rect x="700" y="10" width="140" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="770" y="35" fill="#dfe6f0">layers + config</text>
<rect x="700" y="62" width="140" height="40" rx="8" fill="none" stroke="#98a2b4"/><text x="770" y="87" fill="#dfe6f0">layers + config</text>
<rect x="470" y="130" width="170" height="40" rx="8" fill="none" stroke="#e879f9" stroke-dasharray="4 3"/><text x="555" y="149" fill="#dfe6f0">referrer (sig/SBOM)</text><text x="555" y="164" fill="#e879f9">subject -&gt; manifest</text>
<line x1="180" y1="50" x2="235" y2="50" stroke="#7fe0a7" marker-end="url(#dc-a)"/>
<line x1="410" y1="42" x2="465" y2="30" stroke="#3fbaf5" marker-end="url(#dc-a)"/>
<line x1="410" y1="58" x2="465" y2="80" stroke="#3fbaf5" marker-end="url(#dc-a)"/>
<line x1="640" y1="30" x2="695" y2="30" stroke="#98a2b4" marker-end="url(#dc-b)"/>
<line x1="640" y1="82" x2="695" y2="82" stroke="#98a2b4" marker-end="url(#dc-b)"/>
<line x1="555" y1="130" x2="555" y2="106" stroke="#e879f9" stroke-dasharray="4 3" marker-end="url(#dc-a)"/>
<text x="105" y="120" fill="#7fe0a7">a tag is a keep-alive</text>
<rect x="30" y="215" width="280" height="52" rx="8" fill="none" stroke="#e8b44a"/><text x="170" y="236" fill="#dfe6f0">digest-only cached manifest</text><text x="170" y="253" fill="#e8b44a">NO tag references it</text>
<rect x="380" y="215" width="220" height="52" rx="8" fill="none" stroke="#ff8fa3"/><text x="490" y="236" fill="#dfe6f0">GC after gcDelay (1h here)</text><text x="490" y="253" fill="#ff8fa3">deleteUntagged default: true</text>
<line x1="310" y1="241" x2="375" y2="241" stroke="#ff8fa3" marker-end="url(#dc-a)"/>
<text x="430" y="310" fill="#98a2b4">keepTags rules protect TAGS in their repo. Nothing on v2.1.17/18 protects the amber box;</text>
<text x="430" y="330" fill="#98a2b4">pull-aware keepUntagged ships in v2.1.19 (2026-08-04). Node-local caches can mask the eviction for days.</text>
</g>
</svg>
<p class="legend">green = protected by a tag &middot; amber/red = the eviction path &middot; dashed magenta = referrers (separate lifecycle)</p>
<p class="why">The lesson: reachability, not existence, is what GC respects - and a
pull-through cache full of digest pulls is a graveyard of unreachable-by-tag objects.</p></div>
<p>Retention policy semantics, stated precisely because two nearby systems use opposite
rules:</p>
<ul>
<li>Retention policies match per repository, <strong>first match wins</strong> - order the
list most-specific-first. A repository matching no policy keeps its tags.</li>
<li>Within a matched repository, one <code>keepTags</code> rule inverts the default:
non-matching tags in <em>that repository</em> become deletable. The inversion is scoped to
the repository, not global - the first edition of this page overstated it.</li>
<li>Untagged manifests are governed separately (<code>deleteUntagged</code>, default true),
and on the deployed version no retention rule can protect them. Pull-aware
<code>keepUntagged</code> exists upstream from v2.1.19.</li>
<li>Authorisation policies (section 07) use <strong>longest match</strong>, not first
match. Two adjacent config blocks, two opposite precedence rules. Label which one you are
reasoning about.</li>
</ul>
<p><strong>Mitigations, honestly ranked:</strong> upgrade to v2.1.19+ and configure
<code>keepUntagged</code> with pull-activity rules (the designed fix); until then, widen
<code>gcDelay</code>/<code>retention.delay</code> so eviction pressure drops (the upstream
maintainer's own interim suggestion), or set <code>deleteUntagged: false</code> and accept
that the cache only grows - a capacity trade, not a free lunch. Disabling GC entirely
trades eviction for guaranteed storage exhaustion with no reclaim path; it is listed here
to be argued against.</p>
<div class="note warn"><span class="k">SAFE VALIDATION - PROPOSED, NOT YET RUN</span><br>
The eviction claim above is documented upstream and consistent with this config; it has not
been reproduced in this lab yet. The safe reproduction, for a <em>disposable</em> zot
instance only: record version + sanitised config; pull an image by digest through it;
confirm on the registry side that the stored manifest is untagged; shorten GC timers (in
the disposable instance only); observe the manifest before and after the sweep; then block
upstream and repeat the pull from a clean runtime store, recording whether the mirror
serves or re-fetches. Two warnings from upstream documentation: the retention verification
tool executes orphan-blob GC for real even in dry-run, and on local storage it requires the
registry stopped. Never point retention experiments at live storage.</div>
<h2 id="auth"><span class="n" aria-hidden="true">07</span>The authentication migration</h2>
<div class="scene" id="s22"><div class="inner">
<img src="__M22__" alt="Four parts left to right: a waiting keyed cartridge, a key wedge, a tall gate frame, and a cartridge beyond the gate with its keyway lit - per-node credentials proven at a checkpoint.">
<div class="s22-glow" style="left:16%;top:30%" data-band="0"></div>
<div class="s22-glow" style="left:44%;top:28%" data-band="1"></div>
<div class="s22-glow" style="left:70%;top:26%" data-band="2"></div>
<div class="cap">The proving gate: a node counts as migrated when an authenticated pull succeeds where an anonymous one cannot.</div>
</div></div>
<p>Target state: the mirror refuses anonymous pulls. Current state: anonymous read on, with
per-node credentials staged in machine configs (inert until each node's reboot - the
documented behaviour). The migration is a sequencing problem with one trap at its centre:</p>
<p><strong>While anonymous read is on, an ordinary pull proves nothing about auth.</strong>
A node whose credentials never applied sends no authorisation header, gets served as an
anonymous reader, and false-passes the gate. The canary must live in a repository that
denies anonymous read, so a successful pull can only mean an authenticated pull.</p>
<p>That design is now upstream-validated rather than assumed: zot authorisation resolves
per-repository policies by <strong>longest match</strong> - the most specific path pattern
wins, and <code>**</code> is explicitly the default policy for anything unmatched. A
<code>canary/**</code> entry that grants named identities read and carries no
<code>anonymousPolicy</code> therefore denies anonymous on that path while the glob keeps
the rest of the shelf open. Maintainer guidance confirms anonymous and authenticated access
are evaluated independently. The staged policy:</p>
<pre>"accessControl": {
"repositories": {
"**": {
"anonymousPolicy": ["read"],
"policies": [
{ "users": ["zot-push"], "actions": ["read", "create", "update"] },
{ "users": ["zot-pull"], "actions": ["read"] }
]
},
"canary/**": {
"policies": [
{ "users": ["zot-pull", "zot-push"], "actions": ["read"] }
]
}
}
}</pre>
<div class="note"><span class="k">CLIENT CAVEAT &middot; DOCUMENTED</span><br>Mixed
anonymous/authenticated policies trigger a Docker-client-specific workaround present in
this exact version: <code>/v2/</code> returns 401 to Docker user agents so the Docker CLI
sends credentials, meaning anonymous <em>docker</em> users must log in even for anonymous
repos. Podman and containerd are unaffected. This estate pulls with containerd, podman and
buildah, so the caveat is noted rather than felt.</div>
<p>The gate, per node, and what each step proves:</p>
<ol>
<li>Stage credentials in the node's machine config (inert; documented as requiring reboot).</li>
<li>Reboot the node at a planned window.</li>
<li><strong>Prove identity:</strong> pull the protected canary through the node's own
runtime. Success = authenticated (anonymous cannot); failure = 401, back to step 1. The
expected split, to be confirmed against observed responses when the test runs: 401 for
missing/wrong credentials, 403 for a valid identity lacking the action - upstream docs
state the 403 case explicitly only for OIDC identities, so the basic-auth 403 boundary is
listed in the open-verification register rather than asserted.</li>
<li><strong>Prove interception separately:</strong> the canary pull is a direct reference
to the mirror, so it cannot prove that <code>docker.io/...</code> references are being
routed through the mirror at all. That second proof needs an original-upstream reference
pulled on the node <em>plus</em> the mirror's request log showing it arrive - and a cold
runtime store, because node-local content will satisfy the pull without any network and
fake a pass.</li>
<li>Only when every node passes both proofs does <code>anonymousPolicy</code> come off the
glob. Rollback is the previous config commit, and it is named before the flip, not after.</li>
</ol>
<div class="diagram"><span class="k">Diagram E &middot; the migration ladder</span>
<svg viewBox="0 0 860 210" role="img" aria-labelledby="de-t de-d">
<title id="de-t">The authentication migration ladder with its per-node proof gate and rollback point</title>
<desc id="de-d">Five stages: anonymous baseline, credentials staged inert, node reboot, the two-part proof - authenticated canary pull plus logged mirror interception - and the final anonymous-off flip with a named rollback commit. A failing node loops from the proof back to staging.</desc>
<defs><marker id="de-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#3fbaf5"/></marker>
<marker id="de-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#e879f9"/></marker></defs>
<g font-family="ui-monospace,Menlo,monospace" font-size="11" text-anchor="middle">
<rect x="10" y="70" width="140" height="48" rx="8" fill="none" stroke="#98a2b4"/><text x="80" y="90" fill="#dfe6f0">anon read ON</text><text x="80" y="106" fill="#98a2b4">baseline (today)</text>
<rect x="180" y="70" width="140" height="48" rx="8" fill="none" stroke="#3fbaf5"/><text x="250" y="90" fill="#dfe6f0">creds staged</text><text x="250" y="106" fill="#98a2b4">inert in config</text>
<rect x="350" y="70" width="140" height="48" rx="8" fill="none" stroke="#3fbaf5"/><text x="420" y="90" fill="#dfe6f0">node reboots</text><text x="420" y="106" fill="#98a2b4">auth goes live</text>
<rect x="520" y="70" width="160" height="48" rx="8" fill="none" stroke="#e879f9"/><text x="600" y="86" fill="#dfe6f0">two-part proof</text><text x="600" y="100" fill="#e879f9">canary + logged</text><text x="600" y="113" fill="#e879f9">interception</text>
<rect x="710" y="70" width="140" height="48" rx="8" fill="none" stroke="#7fe0a7"/><text x="780" y="90" fill="#dfe6f0">anon OFF</text><text x="780" y="106" fill="#98a2b4">rollback = prior commit</text>
<line x1="150" y1="94" x2="175" y2="94" stroke="#3fbaf5" marker-end="url(#de-a)"/>
<line x1="320" y1="94" x2="345" y2="94" stroke="#3fbaf5" marker-end="url(#de-a)"/>
<line x1="490" y1="94" x2="515" y2="94" stroke="#3fbaf5" marker-end="url(#de-a)"/>
<line x1="680" y1="94" x2="705" y2="94" stroke="#3fbaf5" marker-end="url(#de-a)"/>
<path d="M600 118 Q 600 175 480 175 L 320 175 Q 250 175 250 122" fill="none" stroke="#e879f9" stroke-dasharray="4 4" marker-end="url(#de-m)"/>
<text x="440" y="196" fill="#e879f9">401: node loops back - it does not count</text>
</g>
</svg>
<p class="legend">the flip is per-fleet; the proof is per-node - one unproven node under a fleet-wide flip is an outage wearing a green tick</p>
<p class="why">Why two proofs: identity and interception fail independently, and each has a
false-pass mode the other cannot detect.</p></div>
<p>Behind the gate sit five separate trust domains, deliberately not shared: node pull
credentials (machine config, reboot-bound), CI push credentials (human-held login), the
mirror's own upstream credentials (one mounted secret, pod-restart-bound), TLS trust
(estate wildcard, standard roots), and human admin access (SSO in front of the UI). The
push credential carries this page's oldest scar: its hash and plaintext once drifted apart,
rotation became impossible, and pushes stayed dead for twelve days until the rebuilt flow
landed - three applications shipped the same day it came back. Hash and plaintext now live
side by side, rotated together by a human, and the automation identity that syncs secrets
is read-only by design so it can never half-rotate them again.</p>
<h2 id="observability"><span class="n" aria-hidden="true">08</span>Observability that means something</h2>
<p>"The registry is up" is the least useful sentence in this page. The worst incident in
this mirror's life: an in-flight sync wedged, every pull from that upstream hung, and the
health endpoint returned 200 throughout. The log line was almost poetic:</p>
<div class="pane-k">Replay &middot; observed 2026-07-07 &middot; the stalled sync</div>
<pre class="pane">"image already demanded, waiting on channel"
$ kubectl -n zot rollout restart deploy zot <span class="cm">&lt;- recovery action (not a root cause)</span>
<span class="cm"># probe an actual manifest, not the process's opinion of itself:</span>
$ curl -sfS --max-time 10 -o /dev/null -w '%{http_code}\n' \
https://zot.bztmon.org/v2/library/busybox/manifests/latest \
-H 'Accept: application/vnd.oci.image.index.v1+json'
<span class="good">200</span> <span class="cm">&lt;- THIS is "the mirror works"</span></pre>
<p>Honesty about that incident, upgraded by reading the source: the log line is
<em>coalescing by design</em> - concurrent requests for one image join the first sync
rather than duplicating it, and since the deployed version syncs run on a detached
background context with a three-hour default timeout, surviving client disconnects.
Waiters blocking on a genuinely stalled sync until that timeout is consistent with what we
saw; the restart was a recovery action, and the root cause was never isolated. Two knobs
this config does not currently set - sync <code>maxRetries</code> (disabled by default
upstream) and a tighter <code>syncTimeout</code> - are the first things a recurrence should
change.</p>
<p>What this deployment actually exposes, verified against the running version's source:
<code>/livez</code>, <code>/readyz</code> and <code>/startupz</code> exist as real health
endpoints (absent from the docs at this version - a documentation gap, not an invention),
alongside the spec's <code>/v2/</code>. The Prometheus metrics extension exists upstream
(real series names include <code>zot_http_requests_total</code>,
<code>zot_repo_storage_bytes</code>, <code>zot_repo_downloads_total</code>,
<code>zot_storage_lock_latency_seconds</code>) - and is <strong>not enabled in this
deployment</strong>. Wiring it, with scrape auth, is on the open list; no dashboard here
pretends otherwise.</p>
<p>The layered checks that would actually mean something, each answering one question:</p>
<div class="tablewrap"><table>
<tr><th>Check</th><th>Question it answers</th><th>Status here</th></tr>
<tr><td><code>/livez</code> / <code>/readyz</code></td><td>is the process up / initialised</td><td><span class="st ok">available</span></td></tr>
<tr><td>authenticated manifest GET of a local-only canary</td><td>can the right identity read real content from local storage</td><td><span class="st prop">proposed</span></td></tr>
<tr><td>anonymous GET of the canary expecting 401</td><td>is the protection actually protecting</td><td><span class="st prop">proposed</span></td></tr>
<tr><td>original-reference pull on a cold node + mirror log line</td><td>is the runtime actually routed through the mirror</td><td><span class="st prop">proposed</span></td></tr>
<tr><td>digest pull with upstream blocked (disposable env)</td><td>does "cached" mean "served locally"</td><td><span class="st open">open - section 06</span></td></tr>
<tr><td>PVC usage + growth, GC activity, sync latency</td><td>when does capacity or eviction become the story</td><td><span class="st prop">needs metrics ext</span></td></tr>
<tr><td>certificate expiry, credential age</td><td>what breaks on a schedule</td><td><span class="st prop">proposed</span></td></tr>
</table></div>
<p>The test matrix for the caching claims - each cell is an experiment, not an assumption
(status: the two digest rows are documented upstream and consistent with observed
behaviour here; the disconnected rows have not been run in this lab):</p>
<div class="tablewrap"><table>
<tr><th>Scenario</th><th>Expected on v2.1.17</th><th>What proves it</th></tr>
<tr><td>cached tag, upstream reachable</td><td>served; upstream contacted anyway (revalidation)</td><td>zot log shows upstream request; latency includes round-trip</td></tr>
<tr><td>cached tag, upstream unreachable</td><td>fails or stalls until timeout, then local fallback path</td><td>pull timing vs sync timeout; error text</td></tr>
<tr><td>cached digest, upstream reachable</td><td>served locally, no upstream contact</td><td>absence of upstream request in zot logs during the pull</td></tr>
<tr><td>cached digest, upstream unreachable</td><td>served locally - IF the entry survived GC</td><td>the section-06 disposable-instance drill</td></tr>
<tr><td>cold node, warm mirror</td><td>mirror serves; node store fills</td><td>mirror log + no origin egress</td></tr>
<tr><td>warm node, evicted mirror entry</td><td>pull succeeds from node store - masking the eviction</td><td>this is the false-pass: only mirror-side inspection reveals it</td></tr>
<tr><td>multi-arch index + child manifests</td><td>index and per-platform manifests are separate cache entries</td><td>per-digest existence checks on the mirror</td></tr>
</table></div>
<h2 id="production"><span class="n" aria-hidden="true">09</span>The production translation</h2>
<div class="scene" id="s24"><div class="inner">
<img src="__M24__" alt="A monolithic vault with a wall of glowing bricks visible through its door, attended by four smaller self-sufficient way-stations - a central release source and site-local mirrors.">
<div class="cap">A central curated source and site-local registries: each site holds its own shelf, so a cut WAN idles nothing.</div>
</div></div>
<p>The first edition of this section asserted what "production" does. That was the wrong
register: production chooses from patterns against requirements. The honest version:</p>
<ul>
<li><strong>Single registry, accepted failure domain</strong> - exactly this lab's shape.
Legitimate wherever a mirror outage degrades to origin pulls (fallback on) or to a paused
deploy window (fallback off) and that cost is accepted in writing.</li>
<li><strong>Sync-based HA</strong> - upstream documents active/standby and active/active
pairs of <em>independent</em> zot instances, each with its own storage, mirroring each
other behind a load balancer; the documented caveat is the synchronisation window between
polls, which bounds what a failover can lose.</li>
<li><strong>Scale-out clustering</strong> - upstream's other documented shape: instances
shard repositories by hash and proxy to the owner, classically over shared S3-compatible
storage. That is horizontal scale for load; the shared storage is then the availability
story, which is a different property than the HA pair above. Naming which property you are
buying is the design act.</li>
<li><strong>Edge estates</strong> - a central curated source promotes releases by digest;
each site runs a small local registry holding its release set, synced on a schedule, so a
severed WAN idles nothing at pull time. A disconnection claim is only valid when the
required content, its referrers if policy demands them, trust roots, credentials and the
registry's own bootstrap image are already local - the checklist from diagram D, applied
per site.</li>
</ul>
<p>Whatever the topology: recovery time and recovery point get numbers before an incident
provides them; backups are restore-tested copies off the failure domain (array snapshots
and RAID protect against disks, not against the array, the site, or an operator error -
they are inputs to a backup strategy, not the strategy); capacity is planned against the
retention policy from section 06, because "how big does the cache get" is a policy output,
not a guess; and the circular dependencies from section 04 are drawn per site, because
every one of them exists at every scale - the only thing that changes is how expensive
they are to ignore.</p>
<h2 id="verified"><span class="n" aria-hidden="true">10</span>Verified, open, and where the claims come from</h2>
<div class="scene" id="s25"><div class="inner">
<img src="__M25__" alt="A wide flat node slab in an otherwise empty void; a single luminous brick hovers midway in its descent toward the slab - one small transfer of bytes as proof of life.">
<div class="cap">Proof of life is one authenticated byte transfer through the real path - not a status page.</div>
</div></div>
<p>When a node powers on, this fleet's gate makes it pull one known, first-party image
through its own runtime before it counts as a member. That gate is the whole page in
miniature: prove the path, not the process.</p>
<div class="tablewrap"><table>
<caption class="mono" style="text-align:left;color:var(--ink-dim);font-size:.78rem;padding-bottom:.4rem">Architecture decisions</caption>
<tr><th>Decision</th><th>Chosen</th><th>Why</th><th>Trade-off</th><th>Rollback / alternative</th><th>Status</th></tr>
<tr><td>Origin fallback</td><td>ON (default kept)</td><td>bootstrap survivability, Loop 1</td><td>silent mirror bypass possible</td><td>skipFallback:true + preseed (airgap shape)</td><td><span class="st ok">observed</span></td></tr>
<tr><td>Sync mode</td><td>onDemand, all upstreams</td><td>cache follows real usage; Hub-safe</td><td>tag pulls revalidate upstream; digest entries untagged</td><td>polled sync for a curated release set</td><td><span class="st ok">observed</span></td></tr>
<tr><td>Digest preservation</td><td>preserveDigest + docker2s2, x5</td><td>digest pins + signatures survive mirroring</td><td>mandatory config pairing (crashloop scar)</td><td>none - non-negotiable for digest-pinned estates</td><td><span class="st ok">observed</span></td></tr>
<tr><td>Namespace layout</td><td>flat (no destinations)</td><td>simplicity at build time</td><td>ordered-trial upstream selection; theoretical collisions</td><td>per-origin destinations + overridePath</td><td><span class="st prop">redesign proposed</span></td></tr>
<tr><td>Retention</td><td>none configured</td><td>predates understanding the untagged rule</td><td>digest-only entries evict within hours</td><td>v2.1.19+ keepUntagged; interim wider gcDelay</td><td><span class="st open">correction owed</span></td></tr>
<tr><td>Auth</td><td>anonymous read until per-node proof</td><td>containerd 401-fallback unreliability makes big-bang flips dangerous</td><td>window with no pull auth</td><td>staged flip w/ canary gate + named rollback</td><td><span class="st open">in flight</span></td></tr>
<tr><td>Metrics</td><td>not enabled</td><td>minimal first deployment</td><td>capacity/eviction invisible</td><td>metrics extension + scrape auth</td><td><span class="st prop">proposed</span></td></tr>
</table></div>
<h3 id="open">Open verification register</h3>
<ol>
<li>Digest-entry eviction reproduction in a disposable instance (section 06 procedure).</li>
<li>Canary repo negative test: anonymous 401 on <code>canary/**</code> while anonymous
read elsewhere still succeeds; observed 401-vs-403 boundaries recorded.</li>
<li>Mirror interception proof: original-reference pull on a cold node correlated with the
mirror's request log.</li>
<li>Disconnected-serve drill: cached digest pull with upstream blocked, disposable
environment first.</li>
<li>Upgrade evaluation: v2.1.19/v2.1.20 for <code>keepUntagged</code>, with the retention
config written and reviewed before the upgrade, not after.</li>
<li>Namespace redesign migration plan (per-origin destinations + overridePath), including
cache re-warm cost.</li>
</ol>
<h3 id="refs">Verified against</h3>
<ul class="refs">
<li>zot documentation (versioned): <a href="https://zotregistry.dev/v2.1.18/articles/retention/">retention</a> &middot; <a href="https://zotregistry.dev/v2.1.18/articles/storage/">storage</a> &middot; <a href="https://zotregistry.dev/v2.1.18/articles/mirroring/">mirroring</a> &middot; <a href="https://zotregistry.dev/v2.1.18/articles/authn-authz/">authn/authz</a> &middot; <a href="https://zotregistry.dev/v2.1.18/articles/high-availability/">high availability</a> &middot; <a href="https://zotregistry.dev/v2.1.18/articles/scaleout/">scale-out</a></li>
<li>zot upstream issues/PRs: <a href="https://github.com/project-zot/zot/issues/4148">4148 digest-only eviction</a> &middot; <a href="https://github.com/project-zot/zot/pull/4191">4191 keepUntagged (v2.1.19)</a> &middot; <a href="https://github.com/project-zot/zot/issues/4128">4128 tag revalidation</a> &middot; <a href="https://github.com/project-zot/zot/issues/4187">4187 containerd ns param</a> &middot; <a href="https://github.com/project-zot/zot/issues/2928">2928 mixed anonymous auth</a> &middot; <a href="https://github.com/project-zot/zot/issues/2505">2505 demand coalescing</a></li>
<li>Talos: <a href="https://docs.siderolabs.com/talos/v1.13/configure-your-talos-cluster/images-container-runtime/pull-through-cache">pull-through registry cache (v1.13)</a> &middot; containerd: <a href="https://github.com/containerd/containerd/blob/main/docs/hosts.md">registry hosts and the ns parameter</a></li>
<li>OCI: <a href="https://github.com/opencontainers/distribution-spec/blob/main/spec.md">distribution spec</a> &middot; <a href="https://opencontainers.org/posts/blog/2024-03-13-image-and-distribution-1-1/">image/distribution 1.1 (referrers)</a> &middot; signing: <a href="https://docs.sigstore.dev/cosign/verifying/verify/">cosign verify</a></li>
<li>Docker Hub <a href="https://docs.docker.com/docker-hub/usage/">usage and limits</a> &middot; buildah <a href="https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md">build --retry semantics</a></li>
</ul>
<footer>
DOSSIER / 005 &middot; last validated 2026-08-25 against the running estate and the
sources above &middot; corrections in this revision: retention/GC semantics, warm-cache
guarantees, retry arithmetic, upstream namespace routing &middot;
<a href="/">The Teaching Lab</a> &middot; <a href="https://git.bztmon.com/jwright/exploded-cluster">source</a>
</footer>
</div>
<script src="__MIRROR_JS__"></script>